commit 7e05d0a4e7f838446fd642fe8d74d60c1a5dbbb5 Author: npc0-hue Date: Sat Jul 11 14:56:10 2026 +0800 first commit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8ad0147 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,91 @@ +# AGENTS.md + +This file applies to the entire repository. + +## Scope + +This repository is a game server management platform. Do not add billing, cloud host sales, agent-provider/cloud-provider workflows, or unrelated SaaS marketplace features unless a future OpenSpec change explicitly requires them. + +The platform's required first-party areas are 首页、服务器管理、插件市场、用户管理、AI 提供商管理. + +The platform_web visual direction is a unified magical-girl crystal-moonlight game operations console. Preserve the style rules in `platform_web/AGENTS.md` and `platform_web/theme/README.md`; do not replace it with generic opaque SaaS cards or unrelated visual systems without a future OpenSpec change. Global magical ultimate effects belong in `platform_web/components/MagicalParticleLayer.tsx`, not in page-local fixed decoration spans or one-off backdrop CSS. + +## Frontend Design and Browser Tools + +For frontend work, agents are allowed and encouraged to use [@浏览器](plugin://browser@openai-bundled) to automatically open local frontend pages, inspect UI behavior, debug visual issues, and perform browser walkthrough verification. + +For frontend design work, agents are allowed and encouraged to use [$design-taste-frontend](/Users/tasia/.agents/skills/design-taste-frontend/SKILL.md) as a design review and implementation aid. Apply it in support of this repository's existing platform_web visual direction and local theme rules; it must not override the magical-girl crystal-moonlight game operations console style without a future OpenSpec change. + +## Project Roots + +- `platform/` contains backend platform code. +- `run/` contains the machine-side executor. +- `platform_web/` contains the management frontend. +- `plugins/` contains game management plugins and plugin SDK/examples. + +Do not place implementation code outside the matching root. Shared contracts must be generated or copied through explicit contract packages, not imported by reaching across ownership boundaries casually. + +## OpenSpec Rules + +- Every non-trivial change must start with an OpenSpec change under `openspec/changes/`. +- Update proposal/design/specs/tasks before implementation when behavior, architecture, or validation rules change. +- Do not mark tasks complete until their verification evidence exists. +- Run `openspec validate --strict` before completion. + +## Structure Rules + +Backend roots must keep these concerns in fixed directories: + +- API route declarations and handlers. +- Request/response DTOs. +- Domain types. +- Database models. +- Repository interfaces and implementations. +- Service interfaces and implementations. +- Protocol contracts. +- Validation rules. +- Shared utilities. + +Frontend and plugin page roots must keep these concerns in fixed directories: + +- API clients and API types. +- Route definitions. +- Page/view contracts. +- Component contracts. +- Schemas and validators. +- Bridge/SDK types. +- Shared utilities. + +Do not define business structs inside functions. Do not define request/response structs inside handlers. Do not define database models inside migrations. Do not hide shared frontend types inside page components. + +## Run and Channel Rules + +Run must not expose host paths, raw credentials, or direct sockets to plugins or platform_web. + +Run-platform communication must remain channelized: + +- Control is lightweight and high priority. +- Jobs carry lifecycle and bounded operations. +- Logs use durable batch ingest with local spool and sequence acknowledgement. +- Artifacts use chunked and resumable transfer with lower priority than logs/control. +- Optional game client bridge is separate from run lifecycle and log ingest. + +Large file transfer must not block control heartbeat, job ack/result, or log upload. + +## AI Provider Rules + +AI provider keys and base URLs belong to `platform/`. Plugins may request AI assistance only through platform-mediated capabilities. Plugin page must never receive raw AI keys. + +AI-suggested config changes must produce a reviewable diff or recommendation before platform dispatches a run-side write job. + +## Verification Rules + +Run this before completion: + +```bash +scripts/check-structure.sh +``` + +If you add or change a structural rule, update `scripts/check-structure.sh` in the same change. + +If work touches frontend pages, use a browser walkthrough before claiming the UI is accepted. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4648d7e --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# Game Server Management Platform + +This repository is the new game server management platform workspace. It replaces the old SCUM-specific coupling with four explicit project roots: + +- `platform/`: backend control plane for users, game management plugins, server instances, AI providers, jobs, artifacts, logs, and audit. +- `run/`: machine-side executor for scoped process, file, artifact, log, and lifecycle work. +- `platform_web/`: management console frontend. +- `plugins/`: game management plugin workspace. A plugin defines how to create and manage one server type, and one installed plugin can create many server instances. + +## Product Scope + +The platform focuses on: + +- 首页 +- 服务器管理 +- 插件市场 +- 用户管理 +- AI 提供商管理 + +AI 提供商 means model providers such as OpenAI-compatible endpoints, GPT providers, Claude-compatible relays, local model gateways, or custom base URL/key/model configurations. It does not mean run executors, host providers, billing providers, or cloud vendors. + +## Communication Model + +The platform must not use one overloaded channel for everything. Run communication is split by workload: + +- Control: hello, heartbeat, version, capabilities, capacity. +- Job: claim, ack, progress, result, cancel, reconcile. +- Logs: compressed batch ingest with sequence ack and local spool. +- Artifacts: chunked, resumable, checksummed, throttled file transfer. +- Game client bridge: optional in-game command and snapshot polling for games that require it. + +Logs are historical data, not a UI-only stream. Browser realtime tail may use platform SSE/WebSocket later, but run-to-platform logs must use durable ingest semantics. + +## Governance + +Read `AGENTS.md` before changing code. Each subproject also has a local `AGENTS.md` with stricter rules for that area. + +The bootstrap skeleton already includes fixed directories for backend DTOs/models/protocols, run channels, frontend API/routes/contracts, and plugin manifests/schemas/SDK files. `scripts/check-structure.sh` checks these required paths so future changes cannot silently drop or bypass them. + +## Development Baseline + +Current tool baseline: + +- Go 1.25.1 for `platform/` and `run/`. +- Node 22.17.0 and npm 11.6.1 for `platform_web/` and `plugins/`. + +Install JavaScript dependencies before the first full check: + +```bash +(cd platform_web && npm install) +(cd plugins && npm install) +``` + +Run all baseline checks from the repository root: + +```bash +scripts/check-all.sh +``` + +Run focused checks when working in one root: + +```bash +(cd platform && go test ./...) +(cd run && go test ./...) +(cd platform_web && npm run typecheck && npm run test && npm run build) +(cd plugins && npm run typecheck && npm run test && npm run validate:manifest) +``` + +Run the API-backed local debug workspace when you need platform, run, platform_web, and the dev plugin fixture together: + +```bash +scripts/local-debug-start.sh +scripts/local-debug-smoke.sh +``` + +See `docs/local-debug-workspace.md` for ports, disposable data roots, log files, reset steps, smoke evidence, and the required browser walkthrough. This workflow treats frontend local auth fallback as a verification failure. +Use `LOCAL_DEBUG_SELF_START=true scripts/local-debug-smoke.sh` when you need the smoke command to own the temporary local stack for the duration of the verification. + +Start local processes: + +```bash +(cd platform && go run ./cmd/platform) +(cd run && go run ./cmd/run) +(cd platform_web && npm run dev) +``` + +## Docker Deployment + +Use the root compose file for a local all-in-one deployment: + +```bash +docker compose up --build +``` + +Then open: + +- Web console: `http://127.0.0.1:5173` +- Platform API health: `http://127.0.0.1:8080/healthz` + +The compose deployment starts: + +- `platform`: backend on container port `8080`, published as host port `8080`. +- `run`: worker mode executor connected to `http://platform:8080`. +- `platform-web`: built static console served by Nginx on container port `80`, published as host port `5173`. + +Persistent Docker data lives in named volumes: + +- `platform-data`: platform metadata and segmented log bodies. +- `run-data`: run worker workspace and local spool data. + +The default Docker storage is file-backed: + +```text +PLATFORM_METADATA_PATH=/data/platform/metadata.json +PLATFORM_LOG_BODY_BACKEND=file +PLATFORM_LOG_DIR=/data/platform/logs +RUN_WORKSPACE_ROOT=/data/run/workspace +RUN_SPOOL_ROOT=/data/run/spool +``` + +To use MySQL for platform metadata in Docker, edit the existing `platform.environment` block in `docker-compose.yml`: + +```yaml +PLATFORM_STORAGE_BACKEND: mysql +PLATFORM_MYSQL_DSN: platform:platform@tcp(mysql:3306)/platform?parseTime=true +PLATFORM_LOG_BODY_BACKEND: file +``` + +Change the existing `PLATFORM_STORAGE_BACKEND: file` line to `mysql`, uncomment/add the `PLATFORM_MYSQL_DSN` line, then uncomment the `mysql` service and the `platform.depends_on.mysql` block in `docker-compose.yml`. + +MySQL stores platform metadata only: users, plugins, servers, jobs, audit events, log stream cursors, and indexes. Log bodies stay in `PLATFORM_LOG_DIR` as segmented files unless a future `LogBodyStore` adapter such as ClickHouse/Loki/OpenSearch is configured. Do not store hundreds or thousands of servers' log lines as one MySQL row per line. + +To change Docker ports, storage paths, MySQL DSN, or run identity, edit `docker-compose.yml`. Do not put real secrets in committed compose files; use a local untracked `.env` or shell environment for machine-specific values. + +## Local Debug Configuration + +Local direct execution uses environment variables, not a hard-required config file. Example files are provided so you can copy and modify them: + +```text +.env.example +platform/.env.example +run/.env.example +platform_web/.env.example +``` + +Typical local debugging: + +```bash +cp platform/.env.example platform/.env +cp run/.env.example run/.env +cp platform_web/.env.example platform_web/.env + +(cd platform && set -a && source .env && set +a && go run ./cmd/platform) +(cd run && set -a && source .env && set +a && go run ./cmd/run) +(cd platform_web && npm run dev) +``` + +Most common edits: + +- Platform port: `PLATFORM_ADDR=:8080`. +- Platform file persistence: `PLATFORM_STORAGE_BACKEND=file`, `PLATFORM_METADATA_PATH`, `PLATFORM_LOG_DIR`. +- Platform MySQL metadata: `PLATFORM_STORAGE_BACKEND=mysql`, `PLATFORM_MYSQL_DSN=platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true`. +- Log body persistence: `PLATFORM_LOG_BODY_BACKEND=file`, `PLATFORM_LOG_DIR`. +- Run worker mode: `RUN_MODE=worker`. +- Run-to-platform URL: `RUN_PLATFORM_URL=http://127.0.0.1:8080` locally, `http://platform:8080` in Docker. +- Run local data: `RUN_WORKSPACE_ROOT`, `RUN_SPOOL_ROOT`. +- Frontend API: `VITE_PLATFORM_API_BASE_URL=/api/v1`. +- Vite dev proxy: `PLATFORM_API_PROXY=http://127.0.0.1:8080`. + +For hundreds or thousands of servers, keep relational databases for platform metadata, stream state, indexes, retention policy, and audit. Do not store high-volume log bodies as one MySQL row per line; use a future `LogBodyStore` adapter for ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments. + +The frontend shell touches first-party pages, so UI changes require a browser walkthrough at desktop and mobile widths. + +Before claiming a change is complete, run: + +```bash +scripts/check-structure.sh +openspec validate --strict +``` + +If a change adds new required directories, contracts, generated artifacts, or architectural rules, update `scripts/check-structure.sh` in the same change. + +## OpenSpec Stream + +The bootstrap proposal and delivery stream live at: + +```text +openspec/changes/bootstrap-game-server-platform-architecture/ +openspec/changes/architecture-delivery-stream/ +``` + +Use `openspec/changes/architecture-delivery-stream/delivery-plan.md` to pick the next implementation change. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fa4374a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +services: + platform: + build: + context: . + dockerfile: platform/Dockerfile + environment: + PLATFORM_ADDR: ":8080" + # Metadata backend: + # - file: default Docker deployment, persisted in the platform-data volume. + # - mysql: change PLATFORM_STORAGE_BACKEND from file to mysql, uncomment PLATFORM_MYSQL_DSN, + # then uncomment the mysql service and depends_on block below. + PLATFORM_STORAGE_BACKEND: file + # PLATFORM_MYSQL_DSN: platform:platform@tcp(mysql:3306)/platform?parseTime=true + PLATFORM_DATA_DIR: /data/platform + PLATFORM_METADATA_PATH: /data/platform/metadata.json + # Log bodies are separate from metadata. Keep file for local Docker. + # MySQL should store metadata/cursors/indexes, not one row per log line. + PLATFORM_LOG_BODY_BACKEND: file + PLATFORM_LOG_DIR: /data/platform/logs + ports: + - "8080:8080" + volumes: + - platform-data:/data/platform + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 10s + timeout: 3s + retries: 12 + # To run platform metadata on MySQL, uncomment this dependency and the mysql service below. + # Also change PLATFORM_STORAGE_BACKEND above from file to mysql. + # depends_on: + # mysql: + # condition: service_healthy + + # Optional local MySQL metadata database. + # Uncomment this block plus PLATFORM_STORAGE_BACKEND/PLATFORM_MYSQL_DSN above. + # mysql: + # image: mysql:8.4 + # environment: + # MYSQL_DATABASE: platform + # MYSQL_USER: platform + # MYSQL_PASSWORD: platform + # MYSQL_ROOT_PASSWORD: platform-root + # ports: + # - "3306:3306" + # volumes: + # - mysql-data:/var/lib/mysql + # healthcheck: + # test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uplatform", "-pplatform"] + # interval: 10s + # timeout: 5s + # retries: 20 + + run: + build: + context: . + dockerfile: run/Dockerfile + depends_on: + platform: + condition: service_healthy + environment: + RUN_MODE: worker + RUN_PLATFORM_URL: http://platform:8080 + RUN_ENDPOINT_ID: run-docker + RUN_DISPLAY_NAME: Docker Run + RUN_VERSION: 0.1.0 + RUN_REGISTRATION_TOKEN: local-registration + RUN_WORKSPACE_ROOT: /data/run/workspace + RUN_SPOOL_ROOT: /data/run/spool + RUN_MAX_JOBS: "1" + RUN_HEARTBEAT_INTERVAL_MS: "15000" + RUN_POLL_INTERVAL_MS: "2000" + RUN_RETRY_BACKOFF_MS: "1000" + volumes: + - run-data:/data/run + + platform-web: + build: + context: . + dockerfile: platform_web/Dockerfile + depends_on: + platform: + condition: service_healthy + ports: + - "5173:80" + +volumes: + platform-data: + run-data: + # mysql-data: diff --git a/docs/local-debug-workspace.md b/docs/local-debug-workspace.md new file mode 100644 index 0000000..8032989 --- /dev/null +++ b/docs/local-debug-workspace.md @@ -0,0 +1,171 @@ +# Local Debug Workspace + +The local debug workspace runs the real platform API, run worker, platform_web console, and the development game plugin fixture together. It is for API-backed local proof, not demo-only fallback. + +## Scope + +- Platform listens on `http://127.0.0.1:18080` by default. +- platform_web listens on `http://127.0.0.1:5173` by default and proxies `/api/v1` plus `/healthz` to platform. +- Run worker registers as `run-local-debug`. +- Disposable state lives under `.local-debug/`. +- Logs live under `.local-debug/logs/`. +- PIDs live under `.local-debug/pids/`. +- Go build cache for local services lives under `.local-debug/go-build-cache/`. +- The dev plugin fixture is `plugins/examples/dev-game-plugin/manifest.json`. + +The workflow does not require Docker-only infrastructure, external cloud services, real game binaries, raw credentials, raw AI keys, direct run sockets, or browser/plugin direct access to run. + +## Start + +```bash +scripts/local-debug-start.sh +``` + +The script prints the resolved platform URL, platform_web URL, log files, and the exact local account for browser login: + +- Account: `operator.local@example.test` +- Password: `operator-local` + +Useful defaults can be overridden before running the script: + +```bash +LOCAL_DEBUG_PLATFORM_PORT=18081 LOCAL_DEBUG_WEB_PORT=5174 scripts/local-debug-start.sh +``` + +## Environment + +The scripts source `scripts/local-debug-env.sh`. + +Key platform variables: + +- `PLATFORM_ADDR=127.0.0.1:18080` +- `PLATFORM_STORAGE_BACKEND=file` +- `PLATFORM_DATA_DIR=.local-debug/platform` +- `PLATFORM_METADATA_PATH=.local-debug/platform/metadata.json` +- `PLATFORM_LOG_BODY_BACKEND=file` +- `PLATFORM_LOG_DIR=.local-debug/platform/logs` +- `GOCACHE=.local-debug/go-build-cache` + +Key run variables: + +- `RUN_MODE=worker` +- `RUN_PLATFORM_URL=http://127.0.0.1:18080` +- `RUN_ENDPOINT_ID=run-local-debug` +- `RUN_WORKSPACE_ROOT=.local-debug/run/workspace` +- `RUN_SPOOL_ROOT=.local-debug/run/spool` + +Key frontend variables: + +- `PLATFORM_API_PROXY=http://127.0.0.1:18080` +- `VITE_PLATFORM_API_BASE_URL=/api/v1` +- `VITE_ENABLE_LOCAL_AUTH_FALLBACK=false` + +`VITE_ENABLE_LOCAL_AUTH_FALLBACK=false` is required. Local fallback data is a smoke failure for this workspace. + +## Smoke + +Start the stack, then run: + +```bash +scripts/local-debug-smoke.sh +``` + +For one-command verification in environments that clean up background processes when a command returns, run: + +```bash +LOCAL_DEBUG_SELF_START=true scripts/local-debug-smoke.sh +``` + +The smoke command verifies: + +- platform health at `/healthz`. +- API-backed login for the seeded local platform administrator. +- dev plugin manifest validation and registration through `POST /api/v1/game-plugins/register-manifest`. +- run endpoint heartbeat through `GET /api/v1/run/endpoints?status=online`. +- server lifecycle fixture setup through `POST /api/v1/server-instances/workflows/create`. +- job, log stream, artifact, marketplace, and server list references. +- `PLATFORM_API_PROXY` and `VITE_PLATFORM_API_BASE_URL=/api/v1`. +- `VITE_ENABLE_LOCAL_AUTH_FALLBACK=false`. +- absence of forbidden fragments in API evidence: `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, and plugin-owned transport details. + +Smoke evidence is written to `.local-debug/smoke/`. + +## Automated Browser Acceptance + +Run the automated browser acceptance suite when you need repeatable proof for the API-backed console routes: + +```bash +LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh +``` + +By default the command safely resets the configured local debug root, starts platform, run worker, and platform_web, runs `scripts/local-debug-smoke.sh`, verifies browser-facing route contracts, and cleans up the self-started stack. Set `BROWSER_ACCEPTANCE_SELF_START=false` to run against an already-started local debug stack. + +Acceptance evidence is written to: + +```text +/browser-acceptance/browser-acceptance-evidence.json +``` + +The evidence records stack URLs, the smoke seed evidence directory, first-party route checks, plugin/server operation proof, fallback scans, and forbidden-fragment scans. + +## Browser Walkthrough + +After `scripts/local-debug-smoke.sh` passes, open platform_web: + +```text +http://127.0.0.1:5173 +``` + +Required walkthrough: + +- Login with `operator.local@example.test` / `operator-local`. +- Confirm no local fallback banner, local fallback workspace, or demo-only session is visible. +- Open 首页. +- Open 服务器管理. +- Open 插件市场. +- Open 用户管理. +- Open AI 提供商管理. +- Inspect `server-local-debug` server detail, plugin controls or marketplace detail, operation history, log references, and artifact references. +- Scan visible browser text for forbidden fragments: `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, and plugin-owned transport details. + +Acceptance requires logical IDs, platform routes, job refs, log refs, artifact refs, and safe metadata only. + +## Stop + +```bash +scripts/local-debug-stop.sh +``` + +## Reset + +```bash +scripts/local-debug-reset.sh +``` + +Reset stops the local debug processes and deletes only the documented local debug root. By default that root is: + +```text +.local-debug +``` + +The reset script refuses unexpected roots. It allows only: + +- `/.local-debug` +- `/private/tmp/browser-local-debug-*` +- `/tmp/browser-local-debug-*` + +## Manual Commands + +The start script wraps these commands with the local debug environment: + +```bash +go run ./platform/cmd/platform +go run ./run/cmd/run +npm --prefix platform_web run dev -- --port 5173 +``` + +The dev plugin fixture validation command is: + +```bash +cd plugins && npm run validate:manifest +``` diff --git a/go_build_main_go b/go_build_main_go new file mode 100755 index 0000000..bb7da68 --- /dev/null +++ b/go_build_main_go @@ -0,0 +1 @@ +ELF \ No newline at end of file diff --git a/main b/main new file mode 100755 index 0000000..478e41d Binary files /dev/null and b/main differ diff --git a/openspec/changes/add-local-docker-deployment/design.md b/openspec/changes/add-local-docker-deployment/design.md new file mode 100644 index 0000000..93d17eb --- /dev/null +++ b/openspec/changes/add-local-docker-deployment/design.md @@ -0,0 +1,32 @@ +# Design + +## Deployment shape + +The local Docker deployment runs three services: + +- `platform`: Go backend listening on `:8080`, using file-backed storage under `/data/platform`. +- `run`: Go worker in `worker` mode, connecting to `http://platform:8080`, using `/data/run/workspace` and `/data/run/spool`. +- `platform_web`: static Vite build served by Nginx, proxying `/api/v1` and `/healthz` to `platform:8080`. + +This matches the current architecture without introducing a new database adapter. The platform metadata and segmented log bodies are persisted in a Docker named volume. The worker has its own named volume for local workspace and spool data. + +## Local debugging + +Direct local execution remains environment-variable based: + +- Root `.env.example` documents the common three-process setup. +- `platform/.env.example` documents backend storage knobs. +- `run/.env.example` documents worker identity, platform URL, workspace, and scheduling knobs. +- `platform_web/.env.example` documents Vite browser API and dev proxy knobs. + +Developers can copy the example files to `.env` or source/export the variables before running `go run` / `npm run dev`. The project intentionally keeps Go config loading simple and does not require a checked-in secret-bearing config file. + +## Storage guidance + +The Docker compose default uses the durable file backend because it works without external services: + +- `PLATFORM_METADATA_PATH=/data/platform/metadata.json` +- `PLATFORM_LOG_DIR=/data/platform/logs` + +For larger production deployments, MySQL/Postgres should be introduced as a metadata repository adapter in a future OpenSpec change. High-volume log bodies should use a log-optimized backend such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments behind `LogBodyStore`; Docker compose does not pretend that MySQL is an adequate row-per-log-line body store. + diff --git a/openspec/changes/add-local-docker-deployment/proposal.md b/openspec/changes/add-local-docker-deployment/proposal.md new file mode 100644 index 0000000..65d12ab --- /dev/null +++ b/openspec/changes/add-local-docker-deployment/proposal.md @@ -0,0 +1,19 @@ +# Add local Docker deployment + +## Why + +Operators need a repeatable way to run the platform backend, run worker, and platform web console with persistent local data. Local debugging also needs clear environment files so storage paths, run identity, platform API proxying, and browser-facing API URLs are easy to modify without changing code. + +## What Changes + +- Add Dockerfiles for `platform/`, `run/`, and `platform_web/`. +- Add a root `docker-compose.yml` that starts platform, run worker, and web console with named volumes for platform data and run workspace/spool data. +- Add local `.env.example` files documenting the runtime variables for Docker and direct local execution. +- Update README files to explain Docker deployment, local direct execution, and which config files to edit. + +## Impact + +- Adds local deployment configuration only; it does not add billing, cloud host sales, external provider workflows, or production log database adapters. +- Keeps platform data durable through mounted volumes and the existing file storage backend. +- Keeps frontend/browser access platform-mediated through `/api/v1` proxying. + diff --git a/openspec/changes/add-local-docker-deployment/specs/local-docker-deployment/spec.md b/openspec/changes/add-local-docker-deployment/specs/local-docker-deployment/spec.md new file mode 100644 index 0000000..69115cc --- /dev/null +++ b/openspec/changes/add-local-docker-deployment/specs/local-docker-deployment/spec.md @@ -0,0 +1,37 @@ +# local-docker-deployment Specification + +## ADDED Requirements + +### Requirement: Docker deployment files + +The repository SHALL provide a local Docker deployment that starts the platform backend, run worker, and platform web console. + +#### Scenario: Compose starts local services with persistent directories + +- **GIVEN** an operator runs the local compose file +- **WHEN** the services start +- **THEN** the platform SHALL use a durable mounted data directory +- **AND** the run worker SHALL use a mounted workspace/spool directory +- **AND** the web console SHALL proxy platform API calls through platform-owned routes. + +### Requirement: Local debugging configuration examples + +The repository SHALL document direct local execution configuration through example environment files. + +#### Scenario: Developer wants to change ports or storage paths + +- **GIVEN** a developer wants to run the platform, run worker, and web console directly +- **WHEN** they inspect the environment examples +- **THEN** they SHALL find the platform listen address, storage backend, metadata path, log directory, run platform URL, run workspace/spool roots, and web API/proxy settings. + +### Requirement: Storage guidance remains scoped + +The deployment documentation SHALL distinguish metadata storage from log body storage. + +#### Scenario: Operator asks where large server logs should go + +- **GIVEN** a deployment with hundreds or thousands of servers +- **WHEN** the operator reads the deployment guidance +- **THEN** it SHALL say relational databases are for metadata and indexes +- **AND** it SHALL recommend log-optimized backends for high-volume log bodies in future adapters. + diff --git a/openspec/changes/add-local-docker-deployment/tasks.md b/openspec/changes/add-local-docker-deployment/tasks.md new file mode 100644 index 0000000..94e001c --- /dev/null +++ b/openspec/changes/add-local-docker-deployment/tasks.md @@ -0,0 +1,22 @@ +## 1. OpenSpec Artifacts + +- [x] 1.1 Create proposal, design, spec, and tasks for local Docker deployment and debugging configuration. +- [x] 1.2 Validate the change with `openspec validate add-local-docker-deployment --strict`. + +## 2. Docker Deployment + +- [x] 2.1 Add Dockerfiles for platform, run, and platform_web. +- [x] 2.2 Add a root compose file with persistent volumes and safe service wiring. +- [x] 2.3 Add Docker ignore rules to keep builds small and avoid copying local data. + +## 3. Local Debug Configuration + +- [x] 3.1 Add example environment files for root orchestration, platform, run, and platform_web. +- [x] 3.2 Document which variables to modify for Docker and direct local execution. + +## 4. Verification + +- [x] 4.1 Validate compose syntax with `docker compose config`. +- [x] 4.2 Run focused backend tests for platform and run config packages. +- [x] 4.3 Run `scripts/check-structure.sh`. +- [x] 4.4 Run `openspec validate add-local-docker-deployment --strict`. diff --git a/openspec/changes/add-mysql-platform-metadata-storage/design.md b/openspec/changes/add-mysql-platform-metadata-storage/design.md new file mode 100644 index 0000000..25c300a --- /dev/null +++ b/openspec/changes/add-mysql-platform-metadata-storage/design.md @@ -0,0 +1,36 @@ +# Design + +## Metadata backend + +`PLATFORM_STORAGE_BACKEND` selects the platform metadata store: + +- `file`: default local durable snapshot at `PLATFORM_METADATA_PATH`. +- `memory`: test/disposable storage. +- `mysql`: MySQL-backed metadata snapshot using `PLATFORM_MYSQL_DSN`. + +The first MySQL implementation stores one platform-owned JSON snapshot in a `platform_metadata_snapshots` table. This gives operators a real durable MySQL option now while preserving the existing `repo.Store` boundary. Later changes can normalize individual repositories into relational tables without changing handlers or services. + +## Log body backend + +`PLATFORM_LOG_BODY_BACKEND` selects log body storage separately: + +- empty: follows the metadata backend, except `mysql` maps to `file`. +- `file`: segmented JSONL log files in `PLATFORM_LOG_DIR`. +- `memory`: tests/disposable local runs. + +MySQL metadata storage does not imply MySQL log bodies. Hundreds or thousands of servers should use segmented files for local deployments and log-optimized stores such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments in production. + +## Docker guidance + +The root `docker-compose.yml` keeps the default file backend. It includes commented MySQL service/config blocks so operators can uncomment them when they want local MySQL metadata: + +```text +PLATFORM_STORAGE_BACKEND=mysql +PLATFORM_MYSQL_DSN=platform:platform@tcp(mysql:3306)/platform?parseTime=true +PLATFORM_LOG_BODY_BACKEND=file +``` + +## Failure behavior + +If `PLATFORM_STORAGE_BACKEND=mysql` is set without `PLATFORM_MYSQL_DSN`, platform startup fails with a direct configuration error. If the DSN is present but the database is unreachable, startup fails fast rather than silently falling back to memory. + diff --git a/openspec/changes/add-mysql-platform-metadata-storage/proposal.md b/openspec/changes/add-mysql-platform-metadata-storage/proposal.md new file mode 100644 index 0000000..cca6096 --- /dev/null +++ b/openspec/changes/add-mysql-platform-metadata-storage/proposal.md @@ -0,0 +1,20 @@ +# Add MySQL platform metadata storage + +## Why + +The platform now has durable file storage, but Docker/local configuration does not expose a real MySQL option. Operators need a clear `PLATFORM_STORAGE_BACKEND=mysql` path with commented configuration examples. They also need the deployment docs to make the log storage boundary explicit: MySQL is for platform metadata, not high-volume row-per-log-line bodies. + +## What Changes + +- Add MySQL metadata storage configuration through `PLATFORM_MYSQL_DSN`. +- Add a MySQL-backed `repo.Store` implementation that persists platform metadata snapshots in a platform-owned table. +- Separate metadata backend selection from log body backend selection with `PLATFORM_LOG_BODY_BACKEND`. +- Update Docker compose/env examples with commented MySQL configuration. +- Document how to configure MySQL locally and in Docker, and clarify that log bodies remain on `LogBodyStore`. + +## Impact + +- Operators can configure platform metadata persistence with MySQL without changing code. +- Existing file-backed storage remains the default. +- Logs continue to use file segments by default; production log analytics backends remain a future adapter behind `LogBodyStore`. + diff --git a/openspec/changes/add-mysql-platform-metadata-storage/specs/mysql-platform-metadata-storage/spec.md b/openspec/changes/add-mysql-platform-metadata-storage/specs/mysql-platform-metadata-storage/spec.md new file mode 100644 index 0000000..6397cd7 --- /dev/null +++ b/openspec/changes/add-mysql-platform-metadata-storage/specs/mysql-platform-metadata-storage/spec.md @@ -0,0 +1,45 @@ +# mysql-platform-metadata-storage Specification + +## ADDED Requirements + +### Requirement: MySQL metadata backend configuration + +The platform SHALL support `PLATFORM_STORAGE_BACKEND=mysql` for metadata persistence. + +#### Scenario: MySQL backend is configured with a DSN + +- **GIVEN** `PLATFORM_STORAGE_BACKEND=mysql` +- **AND** `PLATFORM_MYSQL_DSN` points to a reachable database +- **WHEN** the platform starts +- **THEN** it SHALL initialize a MySQL metadata store +- **AND** it SHALL create required metadata storage structures when missing. + +#### Scenario: MySQL backend is missing a DSN + +- **GIVEN** `PLATFORM_STORAGE_BACKEND=mysql` +- **AND** `PLATFORM_MYSQL_DSN` is empty +- **WHEN** the platform starts +- **THEN** startup SHALL fail with a clear configuration error. + +### Requirement: Log body backend remains separate + +The platform SHALL configure log body storage separately from metadata storage. + +#### Scenario: MySQL metadata uses file log bodies by default + +- **GIVEN** `PLATFORM_STORAGE_BACKEND=mysql` +- **AND** `PLATFORM_LOG_BODY_BACKEND` is empty +- **WHEN** the platform starts +- **THEN** log bodies SHALL use the file segmented backend +- **AND** log entries SHALL NOT be stored as row-per-line MySQL metadata. + +### Requirement: MySQL configuration is documented + +The repository SHALL include commented MySQL examples in local env and Docker configuration docs. + +#### Scenario: Operator wants to configure MySQL + +- **GIVEN** an operator reads the env examples or README +- **WHEN** they search for MySQL configuration +- **THEN** they SHALL find `PLATFORM_STORAGE_BACKEND=mysql`, `PLATFORM_MYSQL_DSN`, and `PLATFORM_LOG_BODY_BACKEND=file` examples. + diff --git a/openspec/changes/add-mysql-platform-metadata-storage/tasks.md b/openspec/changes/add-mysql-platform-metadata-storage/tasks.md new file mode 100644 index 0000000..a8fb996 --- /dev/null +++ b/openspec/changes/add-mysql-platform-metadata-storage/tasks.md @@ -0,0 +1,23 @@ +## 1. OpenSpec Artifacts + +- [x] 1.1 Create proposal, design, spec, and tasks for MySQL metadata storage configuration. +- [x] 1.2 Validate the change with `openspec validate add-mysql-platform-metadata-storage --strict`. + +## 2. MySQL Metadata Storage + +- [x] 2.1 Add platform config fields for `PLATFORM_MYSQL_DSN` and `PLATFORM_LOG_BODY_BACKEND`. +- [x] 2.2 Implement a MySQL-backed metadata snapshot store behind `repo.Store`. +- [x] 2.3 Wire router startup to support `PLATFORM_STORAGE_BACKEND=mysql`. +- [x] 2.4 Add tests for MySQL config loading, missing DSN failure, and log body backend selection. + +## 3. Documentation And Comments + +- [x] 3.1 Add commented MySQL examples to root/platform env examples and Docker compose. +- [x] 3.2 Update README docs with exact MySQL DSN examples and log storage guidance. + +## 4. Verification + +- [x] 4.1 Run `cd platform && go test ./config ./api ./repo -count=1`. +- [x] 4.2 Run `docker compose config`. +- [x] 4.3 Run `scripts/check-structure.sh`. +- [x] 4.4 Run `openspec validate add-mysql-platform-metadata-storage --strict`. diff --git a/openspec/changes/architecture-delivery-stream/.openspec.yaml b/openspec/changes/architecture-delivery-stream/.openspec.yaml new file mode 100644 index 0000000..8e26fbe --- /dev/null +++ b/openspec/changes/architecture-delivery-stream/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md b/openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md new file mode 100644 index 0000000..f97e93e --- /dev/null +++ b/openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md @@ -0,0 +1,55 @@ +# Next Architecture Stream Action + +## Current Guard + +- No active guard. +- `fix-env-profile-settings` task `3.5` is complete. +- `implement-browser-acceptance-suite` is complete. +- `polish-platform-interaction-design` is complete with desktop/mobile browser walkthrough evidence, black mecha and magical-girl theme evidence, automated browser acceptance evidence, frontend verification, structure verification, and strict OpenSpec validation. + +## Current Change To Implement + +- None. The current delivery stream has no active implementation target. + +## Latest Completed Change + +- Change name: `polish-platform-interaction-design` +- Status: complete. +- Primary roots: `platform_web/` +- Completion evidence: + - `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh` + - Evidence file: `/private/tmp/browser-local-debug-acceptance/browser-acceptance/browser-acceptance-evidence.json` + - `cd platform_web && npm run typecheck` + - `cd platform_web && npm test` + - `cd platform_web && npm run build` + - `scripts/check-structure.sh` + - `openspec validate polish-platform-interaction-design --strict` + +## Recommended Next Action + +No further concrete backlog item is defined in the current delivery stream. The next stream step should be one of: + +1. Archive completed OpenSpec changes, starting with `polish-platform-interaction-design`, if the user wants to finalize the completed stream state. +2. Create exactly one new OpenSpec change from fresh product/design feedback, if the user provides or approves a new concrete backlog item. + +## Prompt For The Next Chat + +```text +Continue the architecture delivery stream in /Users/tasia/Desktop/code/browser. + +Read first: +- AGENTS.md +- openspec/changes/architecture-delivery-stream/delivery-plan.md +- openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md + +Task: +- Confirm there is no active guard and no active implementation target. +- If asked to finalize completed work, archive completed OpenSpec changes according to the archive workflow. +- If asked to continue product work, create exactly one new OpenSpec change from fresh approved feedback before implementing anything. +- Run the required OpenSpec validation for any change you create or archive. +- Update delivery-plan.md and NEXT_CHANGE.md after the stream action. +``` + +## Stop Condition + +Stop after archiving completed work or preparing exactly one new approved OpenSpec change, unless the user explicitly asks to keep going. diff --git a/openspec/changes/architecture-delivery-stream/delivery-plan.md b/openspec/changes/architecture-delivery-stream/delivery-plan.md new file mode 100644 index 0000000..68bde33 --- /dev/null +++ b/openspec/changes/architecture-delivery-stream/delivery-plan.md @@ -0,0 +1,123 @@ +# Architecture Delivery Stream + +This file is the working progress record for the architecture stream. It is intentionally stored with the OpenSpec change so a new chat can read the current queue before creating or implementing the next change. + +## Status Legend + +- `complete`: tasks and verification evidence exist. +- `active`: current implementation target. +- `guard`: current blocker that must be closed or explicitly reprioritized before generating the next concrete OpenSpec. +- `pending`: planned but not active. +- `paused`: intentionally deferred by the user. +- `blocked`: cannot proceed without a user decision or external state change. + +## Current Guard + +No active guard. `fix-env-profile-settings` task `3.5` was completed on 2026-07-08 with an API-backed browser walkthrough for the personal settings page, and `scripts/check-structure.sh` plus `openspec validate fix-env-profile-settings --strict` passed. + +## Queue + +| Order | Status | Change | Primary Roots | Completion Gate | +| --- | --- | --- | --- | --- | +| 0 | complete | `bootstrap-game-server-platform-architecture` | all | `scripts/check-structure.sh`; `openspec validate bootstrap-game-server-platform-architecture --strict`. | +| 1 | complete | `establish-development-runtime-baseline` | all | `scripts/check-all.sh`; `scripts/check-structure.sh`; `openspec validate establish-development-runtime-baseline --strict`; browser walkthrough. | +| 2 | complete | `implement-platform-core-domain` | `platform/` | Platform domain unit tests and strict validation. | +| 3 | complete | `implement-platform-api-surface` | `platform/` | API handler tests, validation tests, strict validation. | +| 4 | complete | `implement-ai-provider-management` | `platform/`, `platform_web/` | Secret redaction tests, API tests, browser walkthrough, strict validation. | +| 5 | complete | `implement-run-control-registration` | `run/`, `platform/` | Control protocol tests, registration integration test, strict validation. | +| 6 | complete | `implement-run-job-channel` | `run/`, `platform/` | Job lifecycle tests, journal/idempotency tests, strict validation. | +| 7 | complete | `implement-log-ingest-pipeline` | `run/`, `platform/` | Spool retry tests, batch ack tests, query tests, strict validation. | +| 8 | complete | `implement-artifact-transfer-channel` | `run/`, `platform/` | Chunk/resume/checksum tests, priority isolation tests, strict validation. | +| 9 | complete | `implement-plugin-registry-and-manifest-validation` | `plugins/`, `platform/` | Schema validation tests, registry API tests, strict validation. | +| 10 | complete | `implement-plugin-bridge-and-sdk` | `plugins/`, `platform_web/`, `platform/` | Bridge permission tests, SDK type checks, strict validation. | +| 11 | complete | `implement-platform-web-console-shell` | `platform_web/` | Frontend build, route/page tests, browser walkthrough, strict validation. | +| 12 | complete | `implement-server-management-workflows` | all | Create/start/stop workflow tests, browser walkthrough, strict validation. | +| 13 | complete | `redesign-platform-web-interactions` | `platform_web/` | Frontend tests/build, browser walkthrough, strict validation. | +| 14 | complete | `fix-platform-auth-session-api` | `platform/`, `platform_web/` | Auth/session API tests, frontend auth flow tests, strict validation. | +| 15 | complete | `implement-role-scoped-server-access` | `platform/`, `platform_web/` | Role access tests, UI visibility tests, strict validation. | +| 16 | complete | `implement-platform-observability-and-config-read` | `platform/`, `run/`, `platform_web/` | Observability/config read tests, browser walkthrough, strict validation. | +| 17 | complete | `implement-plugin-marketplace-api-driven-ui` | `platform/`, `platform_web/`, `plugins/` | Marketplace API tests, frontend tests/build, browser walkthrough, strict validation. | +| 18 | complete | `implement-config-write-and-file-dispatch` | `platform/`, `run/`, `platform_web/`, `plugins/` | Config diff/write tests, file dispatch tests, browser walkthrough, strict validation. | +| 19 | complete | `implement-run-worker-real-execution` | `run/`, `platform/` | Real worker lifecycle tests, job result tests, strict validation. | +| 20 | complete | `implement-plugin-page-bridge-execution` | `platform_web/`, `plugins/`, `platform/` | Plugin page bridge tests, permission tests, strict validation. | +| 21 | complete | `implement-platform-mediated-ai-invocation` | `platform/`, `platform_web/`, `plugins/` | AI invocation tests, key redaction tests, reviewable diff tests, strict validation. | +| 22 | complete | `implement-artifact-download-and-browser-transfer` | `platform/`, `run/`, `platform_web/` | Artifact download/transfer tests, browser download walkthrough, strict validation. | +| 23 | complete | `sync-implemented-docs-and-comments` | all | Documentation/comment sync checks and strict validation. | +| 24 | complete | `implement-durable-platform-storage` | `platform/` | Durable repository tests and strict validation. | +| 25 | complete | `add-local-docker-deployment` | all | Local docker smoke path and strict validation. | +| 26 | complete | `add-mysql-platform-metadata-storage` | `platform/`, deployment | MySQL metadata tests and strict validation. | +| 27 | complete | `fix-env-profile-settings` | `platform/`, `platform_web/` | Browser walkthrough task `3.5`; `scripts/check-structure.sh`; `openspec validate fix-env-profile-settings --strict`. | +| 28 | complete | `verify-current-platform-e2e-baseline` | all | Browser walkthrough and API/run/plugin proof report that classifies every required first-party flow as real, partial, demo-only, or blocked. | +| 29 | complete | `implement-real-game-plugin-lifecycle-proof` | `plugins/`, `platform/`, `platform_web/`, `run/` | Plugin/SDK tests, platform lifecycle tests, run lifecycle tests, platform_web tests/build, API-backed browser walkthrough, `scripts/check-structure.sh`, and `openspec validate implement-real-game-plugin-lifecycle-proof --strict`. | +| 30 | complete | `harden-log-artifact-channel-isolation` | `run/`, `platform/` | Run/platform channel isolation tests, protocol docs, `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1`, `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1`, `scripts/check-structure.sh`, and `openspec validate harden-log-artifact-channel-isolation --strict`. | +| 31 | complete | `implement-local-debug-workspace` | all | Local debug docs/scripts, self-start smoke, browser walkthrough, frontend/plugin/platform/run checks, `scripts/check-structure.sh`, and `openspec validate implement-local-debug-workspace --strict`. | +| 32 | complete | `implement-browser-acceptance-suite` | `platform_web/`, all | Automated browser acceptance command, API-backed first-party route proof, plugin/server operation proof, frontend/plugin/platform/run checks, `scripts/check-structure.sh`, and `openspec validate implement-browser-acceptance-suite --strict`. | +| 33 | complete | `polish-platform-interaction-design` | `platform_web/` | Interaction/design polish criteria, desktop/mobile browser walkthroughs, automated browser acceptance, platform_web tests/build, `scripts/check-structure.sh`, and `openspec validate polish-platform-interaction-design --strict`. | + +## Next Pointer + +Read `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` before starting a fresh architecture-stream chat. That file contains the exact next action and prompt. + +## Generator Handoff Template + +Use this template when starting a fresh chat whose job is to create the next OpenSpec only: + +```text +Continue the architecture delivery stream in /Users/tasia/Desktop/code/browser. + +Read first: +- AGENTS.md +- openspec/changes/architecture-delivery-stream/delivery-plan.md +- openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md + +Task: +- Follow NEXT_CHANGE.md exactly. +- If the current guard is still open, close or explicitly record the blocker first. +- Create exactly one new OpenSpec change. +- Generate proposal.md, design.md, specs/**/*.md, and tasks.md for that one change. +- Run openspec validate --strict. +- Update NEXT_CHANGE.md to point at implementing the new change. +- Stop after the one new OpenSpec is ready; do not implement it in this chat unless explicitly asked. +``` + +## Implementation Handoff Template + +Use this template when starting a fresh implementation chat for a concrete change: + +```text +Implement OpenSpec change: + +Scope: +- Implement only openspec/changes//. +- Preserve root ownership boundaries in AGENTS.md. +- Do not add billing, cloud host sales, agent-provider/cloud-provider workflows, or unrelated marketplace features. +- Do not let browser or game management plugins access run directly; route plugin capabilities through platform-mediated contracts. +- Keep log ingest durable and independent from control, job result, and artifact/file transfer channels. + +Read first: +- AGENTS.md +- openspec/changes/architecture-delivery-stream/delivery-plan.md +- openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md +- openspec/changes/bootstrap-game-server-platform-architecture/proposal.md +- openspec/changes/bootstrap-game-server-platform-architecture/design.md +- openspec/changes//proposal.md +- openspec/changes//design.md +- openspec/changes//tasks.md + +Required closure: +- Complete the tasks in openspec/changes//tasks.md only after evidence exists. +- Run scripts/check-structure.sh. +- Run openspec validate --strict. +- Run all change-specific test/build/walkthrough commands listed in the task file. +- If frontend pages are touched, complete a browser walkthrough before claiming acceptance. +- Update delivery-plan.md and NEXT_CHANGE.md before closing. +- Stop after this change is closed; do not start the next backlog item in the same chat unless explicitly asked. +``` + +## Progress Rules + +1. Resolve the current guard before generating a new concrete product OpenSpec unless the user explicitly reprioritizes. +2. Create or implement only one concrete OpenSpec by default. +3. If implementation reveals that a pending item is too large, split it before writing product code. +4. Update this progress file and `NEXT_CHANGE.md` through an OpenSpec change when the queue order, active item, or completion gates materially change. +5. Keep final answers from implementation chats focused on changed files, verification evidence, and the next suggested backlog item. diff --git a/openspec/changes/architecture-delivery-stream/design.md b/openspec/changes/architecture-delivery-stream/design.md new file mode 100644 index 0000000..e32a70d --- /dev/null +++ b/openspec/changes/architecture-delivery-stream/design.md @@ -0,0 +1,94 @@ +## Context + +`bootstrap-game-server-platform-architecture` established the repository roots, ownership boundaries, and architecture contracts, but it intentionally did not build the full platform. The remaining work touches all four roots and needs to be delivered as a sequence of small OpenSpec changes so each implementation chat has a narrow scope, concrete verification commands, and clear handoff to the next change. + +The delivery stream is a process and governance layer. It does not replace the bootstrap specs. Each follow-up change must treat the bootstrap change and any archived specs as the baseline. + +## Goals / Non-Goals + +**Goals:** + +- Define one ordered backlog for the architecture implementation. +- Keep each OpenSpec change small enough for one focused implementation chat. +- Require a standard handoff block for every concrete change so the user can open a new chat and paste a precise implementation prompt. +- Require closing evidence before the next OpenSpec is created or implemented. +- Cover `platform/`, `run/`, `platform_web/`, and `plugins/` without mixing ownership boundaries. + +**Non-Goals:** + +- Do not add billing, cloud host sales, provider marketplace, or agent-provider workflows. +- Do not implement product code inside this stream change. +- Do not require automated creation or closing of chats; chat boundaries are user-operated. +- Do not redefine product scope already covered by the bootstrap architecture. + +## Decisions + +### Decision 1: Use a serial backlog, not parallel feature branches + +Only one concrete implementation OpenSpec should be active at a time unless the user explicitly pauses or reprioritizes the stream. This keeps validation evidence simple and prevents later changes from depending on unverified assumptions. + +Alternative considered: create all detailed OpenSpecs at once. Rejected because later specs would likely become stale after the first implementation changes discover concrete package, runtime, and data model constraints. + +### Decision 2: Start with development runtime baseline + +The first concrete implementation change is `establish-development-runtime-baseline`. It defines the executable skeleton, package managers, local commands, and test/verification entry points before any business capability is implemented. + +Alternative considered: start with platform domain APIs. Rejected because there is not yet a runnable backend or frontend baseline to attach tests and browser walkthroughs to. + +### Decision 3: Split the stream by dependency, not by team label + +Backlog items may touch multiple roots when the contract is cross-cutting, but each item must name its primary root ownership and forbid casual cross-root imports. Shared contracts must be generated, copied through explicit contract packages, or duplicated as documented API contracts until generation exists. + +Alternative considered: one backlog per root. Rejected because platform-run protocols, plugin bridge contracts, and frontend API clients require coordinated changes. + +### Decision 4: Every concrete change gets a handoff prompt + +Each concrete OpenSpec must end with a short implementation handoff containing the change name, exact target, required reads, verification commands, and stopping conditions. The prompt is the practical bridge between chats. + +Alternative considered: rely on OpenSpec files alone. Rejected because a new chat needs a compact instruction that prevents it from reopening already-settled scope. + +### Decision 5: Closing evidence gates progression + +A change is not considered closed until its tasks are checked with evidence. The minimum evidence is `scripts/check-structure.sh` plus `openspec validate --strict`; frontend page changes also require a browser walkthrough, and executable code changes require the relevant tests/builds documented by that change. + +Alternative considered: create the next change after implementation edits are made. Rejected because unverified work compounds defects into downstream specs. + +## Initial Delivery Queue + +| Order | Change | Primary Roots | Purpose | +| --- | --- | --- | --- | +| 0 | `bootstrap-game-server-platform-architecture` | all | Completed architecture baseline and repository skeleton. | +| 1 | `establish-development-runtime-baseline` | all | Add runnable project/tooling baselines and common verification commands. | +| 2 | `implement-platform-core-domain` | `platform/` | Add core domain, DTO, model, repository, service, validator, and route contracts for users, plugins, server instances, AI providers, jobs, artifacts, logs, and audit. | +| 3 | `implement-platform-api-surface` | `platform/` | Add HTTP API handlers, validation, error envelopes, and initial persistence wiring for the core resources. | +| 4 | `implement-ai-provider-management` | `platform/`, `platform_web/` | Store AI provider metadata safely, redact secrets, and expose first-party management APIs and UI. | +| 5 | `implement-run-control-registration` | `run/`, `platform/` | Add run hello, heartbeat, capability, version, and capacity registration. | +| 6 | `implement-run-job-channel` | `run/`, `platform/` | Add job claim, ack, progress, result, cancel, reconcile, and idempotent local journal behavior. | +| 7 | `implement-log-ingest-pipeline` | `run/`, `platform/` | Add local spool, compressed batch upload, sequence ack, retry, and platform log query metadata. | +| 8 | `implement-artifact-transfer-channel` | `run/`, `platform/` | Add chunked, resumable, checksummed, throttled artifact upload/download. | +| 9 | `implement-plugin-registry-and-manifest-validation` | `plugins/`, `platform/` | Validate plugin manifests, register installed game management plugins, and expose marketplace metadata. | +| 10 | `implement-plugin-bridge-and-sdk` | `plugins/`, `platform_web/`, `platform/` | Add safe plugin page bridge, SDK types, scoped platform abilities, and no raw key/run/path exposure. | +| 11 | `implement-platform-web-console-shell` | `platform_web/` | Add frontend app shell, routes, API client structure, theme tokens, and required first-party pages. | +| 12 | `implement-server-management-workflows` | all | Create server instances from plugins, dispatch lifecycle jobs to run, and show job/log/artifact state. | +| 13 | `implement-dev-game-plugin-proof` | `plugins/`, all | Add one development game management plugin proving multi-instance creation, logs, files, jobs, and AI assistance. | +| 14 | `implement-end-to-end-acceptance-suite` | all | Add cross-root acceptance checks and browser walkthrough coverage for the first complete workflow. | + +## Risks / Trade-offs + +- [Risk] The backlog may need to change after tooling decisions are implemented. Mitigation: update this stream through a new OpenSpec change if ordering or scope materially changes. +- [Risk] A change may grow too large for one chat. Mitigation: split it before implementation and keep the original change as a coordination parent only if needed. +- [Risk] Generated contracts may not exist early. Mitigation: use explicit copied contract files with documented ownership until generation is introduced by its own OpenSpec. +- [Risk] Chat handoff can omit important context. Mitigation: require each handoff to name exact files to read and exact commands to run. + +## Migration Plan + +1. Validate this stream change and use it as the current implementation queue. +2. Create `establish-development-runtime-baseline` as the first concrete OpenSpec. +3. In a new chat, implement only that change, run its verification, and check its tasks with evidence. +4. After closure, create or refine the next concrete OpenSpec from the queue. + +## Open Questions + +- Whether the initial persistence backend should be SQLite-first for local development or Postgres-first for production parity remains for the platform API changes. +- Whether log body storage starts as local compressed segments or a query engine adapter remains for the log ingest change. +- Whether run job transport starts as long polling or streaming remains for the run job channel change. diff --git a/openspec/changes/architecture-delivery-stream/proposal.md b/openspec/changes/architecture-delivery-stream/proposal.md new file mode 100644 index 0000000..4ab528e --- /dev/null +++ b/openspec/changes/architecture-delivery-stream/proposal.md @@ -0,0 +1,26 @@ +## Why + +The bootstrap architecture is broad enough that implementing it as one large change would make review, verification, and rollback hard. The project needs an explicit OpenSpec delivery stream that breaks the platform, run executor, frontend console, and plugin system into ordered, single-session changes that can be implemented one at a time. + +## What Changes + +- Add a delivery workflow for creating and implementing architecture OpenSpec changes in dependency order. +- Define a per-change handoff format so each new chat can implement exactly one OpenSpec change without guessing scope. +- Define closing criteria for each implementation chat before the next OpenSpec change is started. +- Define the initial architecture backlog across `platform/`, `run/`, `platform_web/`, and `plugins/`. +- Keep the bootstrap architecture as the baseline and require every follow-up change to reference it instead of redefining product scope. + +## Capabilities + +### New Capabilities +- `architecture-delivery-workflow`: Ordered OpenSpec backlog, per-change handoff rules, implementation-chat closure criteria, and progress tracking for the full architecture delivery stream. + +### Modified Capabilities +- None. + +## Impact + +- Adds planning artifacts under `openspec/changes/architecture-delivery-stream/`. +- Affects how future OpenSpec changes are created, implemented, validated, and handed off between chats. +- Does not implement backend, run, frontend, or plugin runtime code directly. +- Requires future implementation chats to run `scripts/check-structure.sh` and `openspec validate --strict` before marking work complete. diff --git a/openspec/changes/architecture-delivery-stream/specs/architecture-delivery-workflow/spec.md b/openspec/changes/architecture-delivery-stream/specs/architecture-delivery-workflow/spec.md new file mode 100644 index 0000000..a2e3697 --- /dev/null +++ b/openspec/changes/architecture-delivery-stream/specs/architecture-delivery-workflow/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: Ordered Architecture Backlog +The repository SHALL maintain an ordered architecture delivery backlog that maps each future OpenSpec change to its primary roots, purpose, dependencies, and verification expectations. + +#### Scenario: Backlog lists the next architecture change +- **WHEN** a contributor needs the next implementation target +- **THEN** the backlog identifies the next change name, affected roots, and why it follows the previous change + +#### Scenario: Backlog preserves bootstrap as baseline +- **WHEN** a follow-up change is planned +- **THEN** it references `bootstrap-game-server-platform-architecture` or archived baseline specs instead of redefining the product scope + +### Requirement: Single Active Implementation Change +The delivery workflow SHALL keep only one concrete implementation OpenSpec active at a time unless the user explicitly requests a pause, reprioritization, or parallel track. + +#### Scenario: Previous change is not closed +- **WHEN** the current implementation change has unchecked tasks or missing verification evidence +- **THEN** the next concrete implementation change is not started as active work + +#### Scenario: User requests a reprioritization +- **WHEN** the user explicitly changes the implementation order +- **THEN** the backlog is updated or superseded before the new active change is implemented + +### Requirement: Per-Change Handoff +Each concrete implementation OpenSpec SHALL include a handoff block suitable for a fresh chat, containing the change name, implementation objective, required context files, expected verification commands, and stopping conditions. + +#### Scenario: New chat starts implementation +- **WHEN** the user opens a fresh chat for a concrete change +- **THEN** the handoff block gives enough context to implement that change without expanding scope to unrelated backlog items + +#### Scenario: Handoff references verification +- **WHEN** the handoff is prepared +- **THEN** it includes `scripts/check-structure.sh`, `openspec validate --strict`, and any change-specific build, test, or browser walkthrough commands + +### Requirement: Closure Evidence +Implementation tasks SHALL remain unchecked until the implementing chat records verification evidence for the task or group of tasks. + +#### Scenario: Task is completed +- **WHEN** a task checkbox is marked complete +- **THEN** the change records the command, walkthrough, file reference, or artifact that proves the task is complete + +#### Scenario: Verification fails +- **WHEN** a required verification command fails +- **THEN** the implementation chat fixes the issue or records the blocker before the change is considered closed + +### Requirement: Ownership Boundaries +Each concrete change SHALL name its affected project roots and preserve root ownership boundaries from `AGENTS.md`. + +#### Scenario: Change touches multiple roots +- **WHEN** a change updates more than one of `platform/`, `run/`, `platform_web/`, and `plugins/` +- **THEN** the OpenSpec design explains the contract boundary and avoids casual cross-root imports + +#### Scenario: Shared contracts are needed +- **WHEN** two roots need the same request, response, or protocol shape +- **THEN** the change uses an explicit contract file, generated artifact, or documented copy boundary rather than importing implementation code across roots + +### Requirement: Progress Tracking +The delivery workflow SHALL maintain a progress record for the architecture stream that shows completed, active, pending, paused, and blocked changes. + +#### Scenario: Active change completes +- **WHEN** a concrete implementation change is closed +- **THEN** the progress record marks it complete with verification evidence and identifies the next pending change + +#### Scenario: Change is split +- **WHEN** a backlog item is too large for one implementation chat +- **THEN** the progress record replaces it with smaller ordered changes and records the reason for the split diff --git a/openspec/changes/architecture-delivery-stream/tasks.md b/openspec/changes/architecture-delivery-stream/tasks.md new file mode 100644 index 0000000..ea38b2d --- /dev/null +++ b/openspec/changes/architecture-delivery-stream/tasks.md @@ -0,0 +1,27 @@ +## 1. Stream Artifacts + +- [x] 1.1 Create the architecture delivery stream proposal. +- [x] 1.2 Create the delivery stream design with ordered backlog decisions. +- [x] 1.3 Create the delivery workflow spec with backlog, handoff, closure, ownership, and progress requirements. +- [x] 1.4 Create the delivery progress record with queue status and handoff template. + +## 2. First Concrete Change + +- [x] 2.1 Create the `establish-development-runtime-baseline` OpenSpec change. +- [x] 2.2 Add proposal, design, specs, and tasks for the runtime baseline change. +- [x] 2.3 Add a fresh-chat handoff block to the runtime baseline tasks. + +## 3. Verification + +- [x] 3.1 Run `scripts/check-structure.sh`. +- [x] 3.2 Run `openspec validate architecture-delivery-stream --strict`. +- [x] 3.3 Run `openspec validate establish-development-runtime-baseline --strict`. +- [x] 3.4 Confirm OpenSpec status shows both changes have required artifacts present. + +## Evidence + +- `scripts/check-structure.sh`: passed. +- `openspec validate architecture-delivery-stream --strict`: passed. +- `openspec validate establish-development-runtime-baseline --strict`: passed. +- `openspec status --change architecture-delivery-stream --json`: proposal, design, specs, and tasks present. +- `openspec status --change establish-development-runtime-baseline --json`: proposal, design, specs, and tasks present. diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/.openspec.yaml b/openspec/changes/bootstrap-game-server-platform-architecture/.openspec.yaml new file mode 100644 index 0000000..8e26fbe --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/design.md b/openspec/changes/bootstrap-game-server-platform-architecture/design.md new file mode 100644 index 0000000..aa8b6a5 --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/design.md @@ -0,0 +1,111 @@ +## Context + +The new project starts in `/Users/tasia/Desktop/code/browser` and intentionally splits the system into four subprojects: + +- `platform/`: backend control plane for users, game management plugins, server instances, AI providers, jobs, artifacts, logs, and audit. +- `run/`: machine-side executor that performs scoped process, file, log, artifact, and server lifecycle work. +- `platform_web/`: management console frontend for 首页、服务器管理、插件市场、用户管理、AI 提供商管理. +- `plugins/`: game management plugin workspace where each plugin defines how to create and manage one server type and can create many server instances. + +The previous implementation mixed API DTOs, models, store code, business logic, frontend types, runtime protocols, plugin execution, and generated rules across many directories. This design treats directory ownership and validation as product requirements, not style preferences. + +## Goals / Non-Goals + +**Goals:** + +- Make the platform a game server management platform, not a SCUM-only application. +- Keep plugin semantics narrow: plugins define server types and server management workflows; they do not own platform transport, AI credentials, or run connections. +- Give AI providers a clear role: model endpoint/key/model configuration used through platform-mediated plugin abilities. +- Split run-platform communication into control, job, log ingest, artifact, and optional game client bridge channels. +- Preserve log continuity when file transfer or plugin file operations are busy. +- Create mandatory directories for DTOs, models, schemas, shared utilities, validators, and frontend types. +- Add a repository structure checker that future changes must update when rules change. + +**Non-Goals:** + +- No billing, cloud resource sales, or SaaS marketplace features. +- No agent provider or cloud host provider system in this change. +- No direct browser-to-run or plugin-to-run connection. +- No raw UDP log transport for reliable historical logs. +- No implementation of the full backend, frontend, or run binaries in this proposal. + +## Decisions + +### Decision 1: Four subprojects are hard boundaries + +The root project SHALL contain `run/`, `platform/`, `platform_web/`, and `plugins/` only as first-class implementation roots. + +Alternative considered: one monorepo package tree with shared internal directories. Rejected because the old project already demonstrated that blurred roots let API structs, database models, protocol structs, and frontend types drift into business logic. + +### Decision 2: Plugins are game management plugins + +A plugin declares how to create and manage a class of game server. Installing `server.scum` or `server.minecraft` enables users to create multiple server instances from that plugin. + +Alternative considered: treating every game-side mod or feature as a platform plugin. Rejected because it fragments one game into many pseudo-platform units and makes server creation unclear. + +### Decision 3: AI provider management is a platform service + +AI providers store base URL, API key reference, model list, routing mode, timeout, and policy. Plugins call a platform AI invocation API with scoped purpose and inputs; plugins never receive raw keys. + +Alternative considered: plugin-owned AI provider configuration. Rejected because credentials would be duplicated, hard to audit, and unsafe for plugin frontends. + +### Decision 4: Run communication is channelized by workload + +The run executor SHALL use separate logical channels: + +- control: hello, heartbeat, capability, capacity, version. +- job: claim, ack, progress, result, cancel, reconcile. +- log ingest: compressed batches, sequence acknowledgement, local spool, retry. +- artifact: chunk upload/download, checksum, resumable transfer, throttling. +- game client bridge: optional game-inside command polling and snapshots when a game needs it. + +Alternative considered: one WebSocket with multiplexed message types. Rejected as the primary architecture because large files, long tasks, and high-volume logs can block each other and make backpressure hard to reason about. + +### Decision 5: Logs are a data pipeline + +Run SHALL collect process output and server log files into a local spool/WAL, upload compressed batches with monotonic sequence IDs, and delete local segments only after platform acknowledgement. Platform SHALL store log metadata separately from log bodies and support pluggable storage backends such as local compressed segments, Loki, ClickHouse, OpenSearch, or Elasticsearch. + +Alternative considered: browser-oriented WebSocket logs from run to platform. Rejected because historical query, GPT analysis, retry, and thousands of server streams require durable ingestion semantics. + +### Decision 6: File transfer is artifact-based + +Plugins and frontend actions SHALL reference `artifactId` or `fileRef`, not host paths or raw run connections. Artifact transfer SHALL be chunked, resumable, checksummed, rate limited, and lower priority than control and log flush. + +Alternative considered: synchronous file content inside job results. Accepted only for bounded small metadata or small text reads; rejected for general files because it can block logs and job status. + +### Decision 7: Definitions live in fixed directories + +Each backend subproject SHALL keep request/response DTOs, database models, domain types, protocol types, validation rules, shared helpers, and API route declarations in dedicated directories. Each frontend or plugin page SHALL keep API clients, page types, route definitions, schemas, bridge types, and shared utilities in dedicated directories. + +Alternative considered: colocating structs and helper functions beside handlers for speed. Rejected because the user explicitly wants structure definitions, common functions, database definitions, and API definitions in predictable locations. + +### Decision 8: Rules are validated by script + +The root `scripts/check-structure.sh` SHALL verify required directories and governance files. Future implementation changes MUST extend the checker when adding new architectural rules. + +Alternative considered: relying on AGENTS.md instructions only. Rejected because instructions alone do not prevent drift. + +## Risks / Trade-offs + +- [Risk] Directory rules may feel heavy before code exists -> Mitigation: start with lightweight presence checks and grow semantic checks with implementation. +- [Risk] HTTP polling jobs can add latency -> Mitigation: begin with pull/long-poll for NAT reliability, then add HTTP/2 or gRPC streaming only where measured latency needs it. +- [Risk] Log storage choice is premature -> Mitigation: define a storage adapter boundary and begin with local compressed segments plus metadata. +- [Risk] Plugin flexibility is reduced -> Mitigation: expose platform abilities through a typed bridge and job/artifact APIs instead of direct run access. +- [Risk] AI analysis may consume too much log context -> Mitigation: require log window extraction, redaction, summarization, and user confirmation before config writes. + +## Migration Plan + +1. Bootstrap the empty repository with four subproject roots, README files, AGENTS files, OpenSpec proposal artifacts, and the structure checker. +2. Implement minimal platform models and route contracts for game management plugins, server instances, AI providers, run sessions, jobs, artifacts, and log streams. +3. Implement run control, job claim/result, log spool/ingest, and artifact chunk APIs behind interfaces. +4. Implement platform_web pages in the required navigation set and consume only platform APIs. +5. Implement one dev game management plugin as the first proof that a plugin can create multiple server instances and use platform AI/file/log abilities. + +Rollback is simple during bootstrap: remove the new change artifacts or directories before implementation starts. After implementation starts, rollback must follow OpenSpec task boundaries. + +## Open Questions + +- Which backend database will be used first for platform metadata? +- Should log body MVP use local compressed files, ClickHouse, Loki, or OpenSearch first? +- Should the initial run job channel be short polling, long polling, or HTTP/2 streaming? +- What language/runtime should game management plugin action scripts use first? diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/proposal.md b/openspec/changes/bootstrap-game-server-platform-architecture/proposal.md new file mode 100644 index 0000000..3d8f7a1 --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/proposal.md @@ -0,0 +1,34 @@ +## Why + +The previous SCUM-specific platform grew into a tightly coupled mix of frontend shell, backend services, run executors, client bridges, plugin runtime, file operations, log streaming, database access, and generated rules. The new project needs a clean game server management platform foundation where each subproject has explicit ownership, fixed definition directories, and verifiable change rules from the first commit. + +## What Changes + +- Create a new four-part project layout: `run/`, `platform/`, `platform_web/`, and `plugins/`. +- Define the platform as a game server management system with 首页、服务器管理、插件市场、用户管理、AI 提供商管理. +- Treat plugins as game management plugins that define how to create and manage game servers; each installed plugin can create many server instances. +- Define AI providers as GPT/OpenAI-compatible/model-provider configuration used by plugins for assisted config reading, config generation, log diagnosis, and server file suggestions. +- Separate run-platform-plugin communication into dedicated channels for control, jobs, logs, artifacts/files, and optional game client bridge behavior. +- Require logs to be a first-class ingestion pipeline with batching, compression, sequence acknowledgement, local spool, storage adapters, and browser tail as a derived view rather than the primary transport. +- Require artifact/file transfer to be chunked, resumable, checksummed, throttled, and isolated from log ingestion and control heartbeats. +- Require every subproject to keep API DTOs, database models, domain structs, shared helpers, validation rules, and frontend types in dedicated directories instead of scattering definitions through business logic. +- Add repository governance files so future changes update rules and run automated structure checks before being considered complete. + +## Capabilities + +### New Capabilities +- `project-workspace-governance`: Project layout, AGENTS rules, README contracts, and automated structure validation requirements for the four subprojects. +- `game-server-platform-core`: Core platform resources for users, game management plugins, server instances, AI providers, jobs, artifacts, logs, and audit. +- `run-execution-channel`: The run-side control, job, log ingest, artifact transfer, and optional game client bridge contracts. +- `game-plugin-system`: Game management plugin packaging, local development, manifest rules, platform bridge, plugin marketplace, and multi-instance server creation. +- `platform-web-console`: The frontend console structure, plugin page bridge rules, page model, API client layout, and design constraints. + +### Modified Capabilities +- None. This is a new project with no existing specs. + +## Impact + +- Adds project-level rules and documentation under `/Users/tasia/Desktop/code/browser`. +- Establishes OpenSpec artifacts for the initial architecture before implementation begins. +- Affects all future implementation in `run`, `platform`, `platform_web`, and `plugins`. +- Introduces an initial repository structure verifier that future changes must keep updated as rules evolve. diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/specs/game-plugin-system/spec.md b/openspec/changes/bootstrap-game-server-platform-architecture/specs/game-plugin-system/spec.md new file mode 100644 index 0000000..ec7fc5f --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/specs/game-plugin-system/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Game management plugin manifest defines server creation +A game management plugin SHALL provide a manifest that declares plugin identity, supported server type, create form schema, server lifecycle actions, required run capabilities, optional plugin pages, and AI/file/log permissions. + +#### Scenario: Valid plugin installed +- **WHEN** a plugin manifest declares a valid server type and required capabilities +- **THEN** the platform MUST expose it in the plugin marketplace and allow creating server instances from it + +#### Scenario: Plugin manifest requests unsafe access +- **WHEN** a plugin manifest requests direct run credentials, raw host paths, or raw AI provider keys +- **THEN** the platform MUST reject or disable that contribution + +### Requirement: Plugins use platform bridge only +Plugin page and plugin actions SHALL access platform abilities through a typed bridge or platform API and MUST NOT connect directly to run, log storage, artifact storage internals, or AI provider endpoints. + +#### Scenario: Plugin reads logs +- **WHEN** a plugin needs logs for a server instance +- **THEN** it MUST query platform log APIs by server instance, stream, time range, cursor, or analysis window + +#### Scenario: Plugin invokes AI +- **WHEN** a plugin invokes AI for config or log assistance +- **THEN** it MUST send a scoped platform AI request and receive a bounded response that excludes provider secrets + +### Requirement: Local plugin development is first-class +The project SHALL support local game management plugin development where a plugin can be registered as a dev plugin, provide UI from a dev server or static directory, and exercise real platform-run job, file, log, and AI flows against a selected test server instance. + +#### Scenario: Developer runs local plugin +- **WHEN** a developer starts a local plugin in dev mode +- **THEN** platform_web MUST show the plugin as a dev game management plugin without requiring a marketplace publish + +### Requirement: Plugin definitions are organized +The `plugins/` workspace SHALL keep manifests, schemas, UI contracts, action definitions, test fixtures, and shared plugin SDK code in predictable directories. + +#### Scenario: Plugin adds action input schema +- **WHEN** a plugin adds or changes an action input +- **THEN** the schema MUST live in a dedicated schema/contract location and tests MUST cover validation behavior + +### Requirement: Plugin can create many server instances +A game management plugin installation SHALL be reusable for multiple server instances with isolated configuration, artifacts, jobs, logs, and permissions per server instance. + +#### Scenario: Two servers from one plugin +- **WHEN** a user creates two server instances from the same plugin +- **THEN** each instance MUST have separate configuration state, run binding, log streams, and artifact references diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/specs/game-server-platform-core/spec.md b/openspec/changes/bootstrap-game-server-platform-architecture/specs/game-server-platform-core/spec.md new file mode 100644 index 0000000..523dc42 --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/specs/game-server-platform-core/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Platform navigation scope +The platform SHALL define the primary product surface as 首页、服务器管理、插件市场、用户管理、AI 提供商管理. + +#### Scenario: Navigation is generated +- **WHEN** platform_web renders authenticated navigation +- **THEN** it MUST expose 首页、服务器管理、插件市场、用户管理、AI 提供商管理 as the primary areas + +### Requirement: Game management plugins create server instances +The platform SHALL model game management plugins as definitions for creating and managing game server types, and each installed game management plugin MUST be able to create multiple server instances. + +#### Scenario: Create server from plugin +- **WHEN** a user creates a server from an installed game management plugin +- **THEN** the platform MUST create a server instance linked to that plugin and a selected run endpoint + +#### Scenario: Multiple instances per plugin +- **WHEN** a game management plugin is installed once +- **THEN** users MUST be able to create more than one server instance from that plugin without reinstalling the plugin + +### Requirement: AI providers are platform-managed +The platform SHALL manage AI provider configuration for OpenAI-compatible, GPT, Claude, local, or relay endpoints, including base URL, key reference, model settings, timeout, and routing metadata. + +#### Scenario: Plugin requests AI assistance +- **WHEN** a plugin needs AI assistance for config reading, config generation, or log diagnosis +- **THEN** it MUST call a platform AI capability and MUST NOT receive raw provider API keys + +#### Scenario: AI suggests a config change +- **WHEN** AI generates a server configuration change +- **THEN** the platform MUST present a bounded diff or recommendation before any run-side file write job is dispatched + +### Requirement: Platform data definitions are centralized +The platform backend SHALL keep database models, DTOs, domain types, API route declarations, repository contracts, service interfaces, validators, and shared helpers in fixed directories. + +#### Scenario: New API added +- **WHEN** platform code adds a new HTTP/API endpoint +- **THEN** its request and response DTOs MUST be defined in the platform contract/DTO area and route declarations MUST be discoverable in the API area + +#### Scenario: New database table added +- **WHEN** platform code adds a new database table +- **THEN** its model MUST be defined in the database model area with field comments and tags before migrations or repositories reference it + +### Requirement: Platform does not expose run internals to plugins +The platform SHALL mediate all plugin access to files, jobs, logs, AI providers, and run endpoints. + +#### Scenario: Plugin requests file operation +- **WHEN** a plugin requests file access for a server instance +- **THEN** the platform MUST authorize the request and dispatch a scoped job or artifact operation instead of exposing host paths or run credentials diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/specs/platform-web-console/spec.md b/openspec/changes/bootstrap-game-server-platform-architecture/specs/platform-web-console/spec.md new file mode 100644 index 0000000..1b8a3c2 --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/specs/platform-web-console/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Console exposes required pages +The platform web console SHALL provide 首页、服务器管理、插件市场、用户管理、AI 提供商管理 as first-party pages. + +#### Scenario: Authenticated user opens console +- **WHEN** an authenticated user opens platform_web +- **THEN** the primary navigation MUST include 首页、服务器管理、插件市场、用户管理、AI 提供商管理 + +### Requirement: Server management uses routed or modal details +Server management SHALL avoid fixed left-list/right-detail master-detail layouts and MUST use routed details, modal details, or drawers for server detail flows. + +#### Scenario: User opens a server +- **WHEN** a user selects a server from the server list +- **THEN** platform_web MUST navigate to a detail route or open an overlay detail surface rather than permanently occupying a right-side detail pane + +### Requirement: Plugin page is hosted through platform context +Plugin page SHALL run inside a platform-controlled host that supplies theme tokens, server instance context, safe API access, AI invocation, log queries, artifact references, and job operations. + +#### Scenario: Plugin page loads +- **WHEN** a user opens an authorized plugin page for a server instance +- **THEN** the host MUST pass only safe context and MUST not expose platform auth storage, AI keys, run credentials, or host paths + +### Requirement: Frontend definitions are centralized +platform_web SHALL keep API clients, route definitions, page contracts, bridge contracts, shared component types, schemas, and validation helpers in dedicated directories. + +#### Scenario: New API call added +- **WHEN** a frontend change adds a platform API call +- **THEN** the call and related request/response types MUST live in the API/contract area rather than inside a view component + +### Requirement: Logs and files are separate user flows +The frontend SHALL treat log history/tail views and file/artifact operations as separate workflows so file operations do not imply log stream interruption. + +#### Scenario: User uploads a file while viewing logs +- **WHEN** a user uploads or downloads a server file from a plugin or file page +- **THEN** active log history or tail views MUST continue to query or subscribe through the platform log APIs independently diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/specs/project-workspace-governance/spec.md b/openspec/changes/bootstrap-game-server-platform-architecture/specs/project-workspace-governance/spec.md new file mode 100644 index 0000000..74f45fb --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/specs/project-workspace-governance/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Four-root project layout +The repository SHALL use `run/`, `platform/`, `platform_web/`, and `plugins/` as the only first-class implementation roots for executor, backend, frontend, and game management plugin work. + +#### Scenario: Bootstrap layout exists +- **WHEN** a contributor inspects the repository root +- **THEN** the root MUST contain `run/`, `platform/`, `platform_web/`, and `plugins/` + +#### Scenario: New implementation is placed under the correct root +- **WHEN** a change adds executor, backend, frontend, or game management plugin implementation +- **THEN** the files MUST be placed under the matching implementation root + +### Requirement: Governance documentation is mandatory +The repository and each first-class implementation root SHALL contain an `AGENTS.md` and `README.md` that describe scope, directory rules, and verification expectations. + +#### Scenario: Root governance files exist +- **WHEN** a contributor starts work from the repository root +- **THEN** root `AGENTS.md` and `README.md` MUST explain cross-project rules and verification commands + +#### Scenario: Subproject governance files exist +- **WHEN** a contributor works inside `run/`, `platform/`, `platform_web/`, or `plugins/` +- **THEN** that directory MUST contain local `AGENTS.md` and `README.md` with root-specific rules + +### Requirement: Definition directories are fixed +Backend subprojects SHALL keep DTOs, domain structs, database models, API route definitions, validation rules, protocols, and shared helpers in dedicated directories. Frontend and plugin page subprojects SHALL keep API clients, route definitions, page types, schemas, bridge types, and shared utilities in dedicated directories. + +#### Scenario: Backend code adds a request DTO +- **WHEN** backend code adds a request or response structure +- **THEN** the structure MUST live in a dedicated DTO or contract directory rather than inside a handler function + +#### Scenario: Frontend code adds a shared type +- **WHEN** frontend code adds a shared API, route, bridge, or component type +- **THEN** the type MUST live in a dedicated type, contract, schema, or API directory rather than inside a page component + +### Requirement: Structure checks gate completion +The repository SHALL provide a structure validation command that verifies mandatory roots and governance files, and future changes MUST update that validator when adding new structure rules. + +#### Scenario: Required directory missing +- **WHEN** `scripts/check-structure.sh` runs and a required root or governance file is missing +- **THEN** the command MUST fail with a clear missing-path message + +#### Scenario: Rule changes with no validator update +- **WHEN** a change adds a new mandatory directory or governance rule +- **THEN** the change MUST update the structure checker before the task can be marked complete diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/specs/run-execution-channel/spec.md b/openspec/changes/bootstrap-game-server-platform-architecture/specs/run-execution-channel/spec.md new file mode 100644 index 0000000..696a1ae --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/specs/run-execution-channel/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Run control channel is lightweight +The run executor SHALL use a lightweight control channel for hello, heartbeat, capability reporting, version reporting, and capacity reporting only. + +#### Scenario: Run starts +- **WHEN** run starts and reaches the platform +- **THEN** it MUST register through hello and report capabilities before accepting jobs + +#### Scenario: File transfer is active +- **WHEN** run is uploading or downloading large artifacts +- **THEN** control heartbeats MUST remain independent from artifact transfer progress + +### Requirement: Job channel supports lifecycle semantics +The run executor SHALL support job claim, ack, progress, result, cancel, and reconcile semantics for server lifecycle, config, database, backup, and plugin-triggered work. + +#### Scenario: Job accepted +- **WHEN** run accepts a job +- **THEN** it MUST return a structured ack before execution and terminal result after execution + +#### Scenario: Run restarts during job +- **WHEN** run restarts or reconnects after accepting a job +- **THEN** the platform MUST be able to request reconciliation using job identity or idempotency identity + +### Requirement: Logs use durable ingest +Run SHALL collect server logs into a local spool and upload compressed batches with stream identity, monotonic sequence range, checksum, and acknowledgement handling. + +#### Scenario: Platform unavailable +- **WHEN** platform log ingest is temporarily unavailable +- **THEN** run MUST retain unacknowledged log batches locally and retry without losing sequence continuity + +#### Scenario: User transfers files while logs are active +- **WHEN** artifact transfer is consuming bandwidth +- **THEN** log flush MUST keep priority over artifact chunks so historical logs continue to advance + +### Requirement: Artifact transfer is isolated +Run SHALL transfer files through an artifact channel with chunking, checksums, resume support, concurrency limits, and throttling separate from logs and control. + +#### Scenario: Plugin writes a config file +- **WHEN** a plugin asks the platform to write a config file +- **THEN** run MUST receive a scoped job that references an artifact or bounded inline content and MUST write through a safe temp-and-replace flow + +#### Scenario: Large file download active +- **WHEN** a large file download is active +- **THEN** job ack/result and log batch upload MUST NOT wait behind all artifact chunks + +### Requirement: Game client bridge is optional and separate +The system SHALL support an optional game client bridge channel for games that require in-game command execution or structured snapshots, but it MUST remain separate from run lifecycle and log ingestion channels. + +#### Scenario: Game needs in-game command bridge +- **WHEN** a game management plugin declares that a game needs an in-game client bridge +- **THEN** the platform MUST route game command polling and snapshot reporting through the client bridge contract rather than the run artifact or log channels diff --git a/openspec/changes/bootstrap-game-server-platform-architecture/tasks.md b/openspec/changes/bootstrap-game-server-platform-architecture/tasks.md new file mode 100644 index 0000000..aa843bb --- /dev/null +++ b/openspec/changes/bootstrap-game-server-platform-architecture/tasks.md @@ -0,0 +1,43 @@ +## 1. Workspace Bootstrap + +- [x] 1.1 Create the four first-class implementation roots: `run/`, `platform/`, `platform_web/`, and `plugins/`. +- [x] 1.2 Add root `README.md` explaining the game server management platform scope and subproject boundaries. +- [x] 1.3 Add root `AGENTS.md` with cross-project code organization, OpenSpec, verification, and no-scope-creep rules. +- [x] 1.4 Add local `README.md` and `AGENTS.md` files in each implementation root. +- [x] 1.5 Add `scripts/check-structure.sh` and wire it into documented verification commands. + +## 2. Platform Foundation Contracts + +- [x] 2.1 Define platform directories for API DTOs, database models, domain types, service contracts, repositories, validators, routes, config, and shared helpers. +- [x] 2.2 Draft platform contracts for users, game management plugins, server instances, AI providers, run endpoints, jobs, artifacts, log streams, and audit events. +- [x] 2.3 Define AI provider contract fields for provider kind, base URL, key reference, model list, relay mode, timeout, status, and redaction policy. +- [x] 2.4 Define game management plugin and server instance lifecycle contracts proving one installed plugin can create many server instances. + +## 3. Run Channel Contracts + +- [x] 3.1 Define run control contract for hello, heartbeat, capabilities, version, and capacity. +- [x] 3.2 Define run job contract for claim, ack, progress, result, cancel, reconcile, idempotency, and local journal behavior. +- [x] 3.3 Define log ingest contract for local spool, batch compression, sequence acknowledgement, retry, and storage adapter boundaries. +- [x] 3.4 Define artifact contract for chunk upload/download, checksum, resume, throttling, and priority separation from logs. +- [x] 3.5 Define optional game client bridge contract for in-game commands and structured snapshots. + +## 4. Plugin System Contracts + +- [x] 4.1 Define game management plugin manifest schema with server type, create form, lifecycle actions, run capabilities, pages, and AI/file/log permissions. +- [x] 4.2 Define local plugin development flow for dev registration, local UI hosting, action schema validation, and real platform-run flows. +- [x] 4.3 Define plugin bridge contract that blocks direct run credentials, host paths, AI keys, and storage internals. +- [x] 4.4 Add example plugin skeleton under `plugins/examples/` once implementation begins. + +## 5. Frontend Console Contracts + +- [x] 5.1 Define platform_web directories for API clients, route definitions, page contracts, schemas, bridge types, components, stores, and utilities. +- [x] 5.2 Define first-party page skeletons for 首页、服务器管理、插件市场、用户管理、AI 提供商管理. +- [x] 5.3 Define plugin page bridge UI contract for theme tokens, server instance context, safe API calls, job dispatch, log queries, artifacts, and AI invocation. +- [x] 5.4 Add frontend verification rules that prevent API types and route definitions from living inside page components. + +## 6. Verification + +- [x] 6.1 Run `scripts/check-structure.sh` and fix missing required files or directories. +- [x] 6.2 Run `openspec validate bootstrap-game-server-platform-architecture --strict` and fix proposal/spec/task issues. +- [x] 6.3 Confirm OpenSpec status shows `tasks` done or ready for apply with all required artifacts present. +- [x] 6.4 Update README verification instructions if any new required checker is added during implementation. diff --git a/openspec/changes/establish-development-runtime-baseline/.openspec.yaml b/openspec/changes/establish-development-runtime-baseline/.openspec.yaml new file mode 100644 index 0000000..8e26fbe --- /dev/null +++ b/openspec/changes/establish-development-runtime-baseline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/establish-development-runtime-baseline/design.md b/openspec/changes/establish-development-runtime-baseline/design.md new file mode 100644 index 0000000..ff4d200 --- /dev/null +++ b/openspec/changes/establish-development-runtime-baseline/design.md @@ -0,0 +1,75 @@ +## Context + +The repository has the required project roots and architecture contracts, but there is no executable backend, run executor, frontend app, plugin SDK package, or common check command. Future changes need a stable local development baseline so every OpenSpec implementation can run tests and builds in the same way. + +The local environment currently has Go 1.25.1, Node 22.17.0, and npm 11.6.1. This change uses those tool families without adding business behavior beyond minimal health or placeholder shells. + +## Goals / Non-Goals + +**Goals:** + +- Add separate Go module baselines under `platform/` and `run/`. +- Add minimal executable entry points and tests for both Go roots. +- Add a Vite React TypeScript baseline under `platform_web/` with required route/page placeholders and a browser-verifiable shell. +- Add a TypeScript/npm baseline under `plugins/` for manifest schema validation, SDK exports, example fixtures, and tests. +- Add root orchestration scripts that run all baseline checks while keeping implementation code inside the owning roots. +- Document development commands and update structure checks for new required baseline files. + +**Non-Goals:** + +- No platform database implementation. +- No real platform API resource behavior beyond minimal health/bootstrap endpoints needed to prove the server starts. +- No run job execution, log ingest, artifact transfer, or game server lifecycle work. +- No plugin marketplace behavior or hosted plugin page runtime. +- No production deployment packaging. + +## Decisions + +### Decision 1: Use separate Go modules for `platform/` and `run/` + +`platform/` and `run/` SHALL each own a Go module, command entry point, internal packages, config loading, and tests. They must not import code from each other. Protocol sharing stays in documented contract files until a later OpenSpec introduces generated contracts. + +Alternative considered: one root Go module for both backend roots. Rejected because it would make casual cross-root imports too easy and weaken the ownership boundary required by `AGENTS.md`. + +### Decision 2: Use Vite, React, and TypeScript for `platform_web/` + +`platform_web/` SHALL use npm scripts for dev, build, typecheck, test, and preview. The baseline app should render the required first-party navigation entries and page placeholders without implementing backend-driven workflows. + +Alternative considered: a static HTML placeholder. Rejected because future frontend work needs route definitions, component structure, schema typing, and browser verification from the start. + +### Decision 3: Use npm TypeScript tooling for `plugins/` + +`plugins/` SHALL own its SDK package metadata, TypeScript sources, JSON schema validation scripts, example manifest fixtures, and tests. This keeps plugin contract checks close to plugin ownership while leaving platform registration behavior for a later change. + +Alternative considered: validate plugin schemas from `platform/`. Rejected because plugin authoring and fixture tests belong in the plugin workspace; platform can later consume the same published or copied contracts through an explicit boundary. + +### Decision 4: Root scripts orchestrate checks only + +Root `scripts/` may contain shell scripts such as `check-structure.sh` and `check-all.sh`, but no application logic. These scripts call commands inside each root and provide a single verification entry point for future OpenSpec changes. + +Alternative considered: a root package manager workspace. Deferred because there is not enough shared package structure yet, and root-level dependency metadata could blur ownership boundaries before generated contracts exist. + +### Decision 5: Minimal UI still requires browser verification + +Because this change creates the initial frontend shell, closure requires a local dev server and browser walkthrough. The walkthrough only needs to prove the shell renders, required navigation exists, and layout does not visibly overlap on desktop and mobile widths. + +Alternative considered: rely on build and unit tests only. Rejected because the repository rules require a browser walkthrough when frontend pages are touched. + +## Risks / Trade-offs + +- [Risk] Separate Go modules add repeated tooling setup. Mitigation: add root orchestration scripts and keep shared protocol files documented until generation is introduced. +- [Risk] Vite baseline may look like product UI before APIs exist. Mitigation: keep pages minimal and avoid fake workflows; later changes will implement data-backed pages. +- [Risk] npm dependency versions may drift. Mitigation: commit lockfiles during implementation and document the Node/npm baseline. +- [Risk] `scripts/check-all.sh` may be slow as features grow. Mitigation: start with baseline commands and allow future changes to add narrower scripts when needed. + +## Migration Plan + +1. Add module/package metadata and minimal source files inside each project root. +2. Add root orchestration scripts and update `scripts/check-structure.sh` for new required baseline files. +3. Update README files with local development commands. +4. Run root structure checks, per-root tests/builds, strict OpenSpec validation, and frontend browser walkthrough. + +## Open Questions + +- Whether future generated contracts should be produced from OpenAPI, protobuf, JSON Schema, or TypeScript source remains for a later contract-generation change. +- Whether `platform/` starts with SQLite or Postgres remains for the platform API surface change. diff --git a/openspec/changes/establish-development-runtime-baseline/proposal.md b/openspec/changes/establish-development-runtime-baseline/proposal.md new file mode 100644 index 0000000..d6a5f37 --- /dev/null +++ b/openspec/changes/establish-development-runtime-baseline/proposal.md @@ -0,0 +1,26 @@ +## Why + +The repository currently has architecture documents and ownership directories, but it does not yet have runnable project baselines. Later platform, run, frontend, and plugin changes need consistent local commands, package boundaries, and verification entry points before business behavior is implemented. + +## What Changes + +- Establish Go module baselines for `platform/` and `run/` with minimal executable entry points and tests. +- Establish a TypeScript/Vite baseline for `platform_web/` with a minimal browser-rendered management console shell. +- Establish a TypeScript baseline for `plugins/` covering SDK exports, schema validation scripts, examples, and contract tests. +- Add root orchestration scripts for build/test/check workflows without placing implementation code outside the matching project roots. +- Document local development commands and required tool versions. +- Extend structure verification only for new required baseline files and directories introduced by this change. + +## Capabilities + +### New Capabilities +- `development-runtime-baseline`: Runtime, tooling, command, and verification baseline for the four project roots. + +### Modified Capabilities +- None. + +## Impact + +- Affects `platform/`, `run/`, `platform_web/`, `plugins/`, root documentation, and root verification scripts. +- Introduces Go and npm-based development commands but does not implement platform business APIs, run job execution, plugin marketplace behavior, or full frontend pages. +- Future OpenSpec changes will rely on these commands for tests, builds, and local walkthroughs. diff --git a/openspec/changes/establish-development-runtime-baseline/specs/development-runtime-baseline/spec.md b/openspec/changes/establish-development-runtime-baseline/specs/development-runtime-baseline/spec.md new file mode 100644 index 0000000..1632f66 --- /dev/null +++ b/openspec/changes/establish-development-runtime-baseline/specs/development-runtime-baseline/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: Platform Go Runtime Baseline +`platform/` SHALL contain an independent Go module with a minimal command entry point, configuration package, HTTP health surface, and automated tests. + +#### Scenario: Platform tests run +- **WHEN** a contributor runs the documented platform test command +- **THEN** the platform Go module test suite completes successfully without importing code from `run/`, `platform_web/`, or `plugins/` + +#### Scenario: Platform server starts +- **WHEN** a contributor runs the documented platform development command +- **THEN** the process starts a local HTTP server with a health response suitable for smoke testing + +### Requirement: Run Go Runtime Baseline +`run/` SHALL contain an independent Go module with a minimal command entry point, configuration package, platform client boundary, and automated tests. + +#### Scenario: Run tests run +- **WHEN** a contributor runs the documented run test command +- **THEN** the run Go module test suite completes successfully without importing code from `platform/`, `platform_web/`, or `plugins/` + +#### Scenario: Run executor starts +- **WHEN** a contributor runs the documented run development command +- **THEN** the process starts in a local smoke-test mode without exposing host paths, raw credentials, or direct sockets to plugins or frontend code + +### Requirement: Platform Web TypeScript Runtime Baseline +`platform_web/` SHALL contain a Vite React TypeScript app with route definitions, required first-party page placeholders, API client boundaries, shared components, theme tokens, and automated build/typecheck/test scripts. + +#### Scenario: Frontend checks run +- **WHEN** a contributor runs the documented platform_web verification commands +- **THEN** TypeScript typecheck, tests, and production build complete successfully + +#### Scenario: Required navigation renders +- **WHEN** the platform_web dev server is opened in a browser +- **THEN** the shell renders navigation entries for 首页、服务器管理、插件市场、用户管理、AI 提供商管理 without visible overlap on desktop and mobile widths + +### Requirement: Plugin Workspace TypeScript Baseline +`plugins/` SHALL contain npm TypeScript tooling for SDK exports, JSON schema validation, example plugin fixtures, and automated tests. + +#### Scenario: Plugin checks run +- **WHEN** a contributor runs the documented plugin verification commands +- **THEN** SDK typecheck, schema validation, and tests complete successfully + +#### Scenario: Example manifest validates +- **WHEN** the plugin schema validation command is run +- **THEN** `plugins/examples/dev-game-plugin/manifest.json` validates against `plugins/manifests/game-plugin.manifest.schema.json` + +### Requirement: Root Verification Orchestration +The repository SHALL provide root-level verification scripts that orchestrate structure checks and per-root build/test commands without containing application logic. + +#### Scenario: Full baseline check runs +- **WHEN** a contributor runs the documented full check command from the repository root +- **THEN** it runs structure verification plus platform, run, platform_web, and plugin baseline checks + +#### Scenario: Structure rules include new baseline files +- **WHEN** `scripts/check-structure.sh` runs after this change +- **THEN** it verifies the new required module, package, command, test, and documentation baseline files added by this change + +### Requirement: Development Documentation +The repository SHALL document required tool versions, local development commands, verification commands, and the scope limits of this runtime baseline. + +#### Scenario: Contributor reads the README +- **WHEN** a contributor reads the root and per-root README files +- **THEN** they can identify how to install dependencies, start local processes, run tests, run builds, and perform the frontend browser walkthrough + +#### Scenario: Future change reads baseline docs +- **WHEN** a future OpenSpec implementation needs to add product behavior +- **THEN** it can reuse the documented baseline commands instead of inventing a new verification surface diff --git a/openspec/changes/establish-development-runtime-baseline/tasks.md b/openspec/changes/establish-development-runtime-baseline/tasks.md new file mode 100644 index 0000000..1135323 --- /dev/null +++ b/openspec/changes/establish-development-runtime-baseline/tasks.md @@ -0,0 +1,75 @@ +## 1. Go Runtime Baselines + +- [x] 1.1 Add an independent Go module under `platform/` with minimal command, config, HTTP health surface, and tests. +- [x] 1.2 Add an independent Go module under `run/` with minimal command, config, platform client boundary, smoke-test mode, and tests. +- [x] 1.3 Verify neither Go module imports implementation code from another project root. + +## 2. Platform Web Baseline + +- [x] 2.1 Add npm, Vite, React, and TypeScript baseline files under `platform_web/`. +- [x] 2.2 Add route definitions, API client boundaries, required page placeholders, shared components, theme tokens, and test setup in the required directories. +- [x] 2.3 Add scripts for `dev`, `build`, `typecheck`, `test`, and `preview`. + +## 3. Plugin Workspace Baseline + +- [x] 3.1 Add npm and TypeScript baseline files under `plugins/`. +- [x] 3.2 Add SDK export stubs, schema validation scripts, example manifest validation, and tests inside the plugin root. +- [x] 3.3 Keep plugin checks scoped to plugin contracts and do not add platform marketplace behavior in this change. + +## 4. Root Orchestration And Documentation + +- [x] 4.1 Add a root verification script that runs structure checks plus each root's baseline checks without containing application logic. +- [x] 4.2 Update `scripts/check-structure.sh` for new required baseline files and directories. +- [x] 4.3 Update root and per-root README files with tool versions, dependency install commands, local start commands, verification commands, and baseline scope limits. + +## 5. Verification + +- [x] 5.1 Run `go test ./...` in `platform/`. +- [x] 5.2 Run `go test ./...` in `run/`. +- [x] 5.3 Run the documented install, typecheck, test, and build commands in `platform_web/`. +- [x] 5.4 Run the documented install, typecheck, test, and schema validation commands in `plugins/`. +- [x] 5.5 Run the root full-check script and `scripts/check-structure.sh`. +- [x] 5.6 Start the platform_web dev server and complete a browser walkthrough at desktop and mobile widths. +- [x] 5.7 Run `openspec validate establish-development-runtime-baseline --strict`. + +## Evidence + +- `go test ./...` in `platform/`: passed. +- `go test ./...` in `run/`: passed. +- `npm install`, `npm run typecheck`, `npm run test`, and `npm run build` in `platform_web/`: passed. +- `npm install`, `npm run typecheck`, `npm run test`, and `npm run validate:manifest` in `plugins/`: passed. +- Cross-root import checks: `rg "browser\.local/(run|platform_web|plugins)" platform` and `rg "browser\.local/(platform|platform_web|plugins)" run` returned no matches. +- `scripts/check-all.sh`: passed. +- `scripts/check-structure.sh`: passed. +- Browser walkthrough: Vite dev server at `http://127.0.0.1:5173/`; desktop 1440x900 and mobile 390x844 checks confirmed required labels, 5 navigation items, 3 metric cards, no nav/metric/header overlap, and mobile document width equal to viewport. +- `openspec validate establish-development-runtime-baseline --strict`: passed. + +## Implementation Handoff + +```text +Implement OpenSpec change: establish-development-runtime-baseline + +Scope: +- Implement only openspec/changes/establish-development-runtime-baseline/. +- Add runtime/tooling baselines for platform, run, platform_web, and plugins. +- Do not implement platform business APIs, run job execution, log ingest, artifact transfer, plugin marketplace workflows, billing, cloud host sales, or agent-provider/cloud-provider workflows. + +Read first: +- AGENTS.md +- platform/AGENTS.md +- run/AGENTS.md +- platform_web/AGENTS.md +- plugins/AGENTS.md +- openspec/changes/bootstrap-game-server-platform-architecture/proposal.md +- openspec/changes/bootstrap-game-server-platform-architecture/design.md +- openspec/changes/architecture-delivery-stream/delivery-plan.md +- openspec/changes/establish-development-runtime-baseline/proposal.md +- openspec/changes/establish-development-runtime-baseline/design.md +- openspec/changes/establish-development-runtime-baseline/tasks.md + +Required closure: +- Mark task checkboxes complete only after evidence exists. +- Run platform Go tests, run Go tests, platform_web install/typecheck/test/build, plugin install/typecheck/test/schema validation, the root full-check script, scripts/check-structure.sh, and openspec validate establish-development-runtime-baseline --strict. +- Because platform_web pages are touched, start the dev server and perform a browser walkthrough at desktop and mobile widths before claiming the UI is accepted. +- Stop after this change is closed; do not start implement-platform-core-domain in the same chat unless explicitly asked. +``` diff --git a/openspec/changes/fix-env-profile-settings/.openspec.yaml b/openspec/changes/fix-env-profile-settings/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/fix-env-profile-settings/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/fix-env-profile-settings/design.md b/openspec/changes/fix-env-profile-settings/design.md new file mode 100644 index 0000000..bd06bee --- /dev/null +++ b/openspec/changes/fix-env-profile-settings/design.md @@ -0,0 +1,51 @@ +## Context + +The platform already supports file and MySQL-backed metadata stores behind `repo.Store`. MySQL initialization is selected by `PLATFORM_STORAGE_BACKEND=mysql` and uses `PLATFORM_MYSQL_DSN`, but `platform/cmd/platform` calls `config.Load()` directly and `config.Load()` only reads process environment variables. A developer who edits `platform/.env` and starts the binary without sourcing that file still gets the default file-backed store. + +The platform web shell currently embeds profile editing, theme palette selection, background presets, upload background, and logout inside the sidebar account popover. Those controls already call the current-user profile and theme APIs, but the interaction is cramped and visually hard to use. + +## Goals / Non-Goals + +**Goals:** +- Load repository-local `.env` values into platform configuration before storage initialization. +- Preserve process environment precedence over `.env` values so deployment systems can override local files. +- Keep profile and theme persistence backed by existing user APIs and the configured metadata repository. +- Replace the account popover editor with a dedicated personal settings page that is available to all authenticated users. +- Preserve the current magical crystal-moonlight shell style and shared surface classes. + +**Non-Goals:** +- Add a normalized relational user schema or migrations beyond the existing MySQL metadata snapshot table. +- Add external account providers, billing, cloud host provisioning, or plugin marketplace workflows. +- Expose raw credentials, host paths, or direct run/plugin internals to the web UI. + +## Decisions + +1. **Load `.env` inside `platform/config`** + - `config.Load()` will call a small local dotenv loader before reading values. + - The loader will check common local paths such as `.env` and `platform/.env` relative to the current working directory. + - Existing process environment values win over file values. + - Alternative considered: requiring users to `source .env`. Rejected because the observed failure is that local `.env` exists but platform startup does not consume it. + +2. **Keep MySQL persistence through the existing snapshot repository** + - The fix only makes backend selection reliable; it does not introduce normalized SQL tables for users. + - Profile and theme updates already flow through `UpdateUser`, which persists through `repo.Store`; this remains the single write path. + - Alternative considered: adding user-specific SQL tables now. Rejected because it is broader than the current bug and would duplicate the existing store abstraction. + +3. **Move personal settings to a route instead of a popover** + - Add a `profileSettings` page id, route, registry entry, and page component. + - The sidebar account control becomes a navigation entry point to that page, with logout remaining available from the settings page. + - Theme controls move into the page but continue to use `theme/tokens.ts` helpers and the session store API methods. + - Alternative considered: converting the popover into a larger drawer. Rejected because the user specifically wants a normal personal configuration interface, and a page is more ergonomic for forms and preview grids. + +## Risks / Trade-offs + +- [Risk] `.env` parsing can accidentally override production environment values. → Mitigation: only set variables that are not already present in `os.Environ`. +- [Risk] Multiple working directories make `.env` discovery ambiguous. → Mitigation: try root `.env` and `platform/.env` from the process working directory, and use deterministic later-file fallback only for missing keys. +- [Risk] Uploaded background data URLs can be large. → Mitigation: preserve the existing client-side behavior and persistence API contract rather than expanding backend payload rules in this change. +- [Risk] Removing the popover editor changes a familiar access point. → Mitigation: keep the sidebar account button visible and route it directly to the new personal settings page. + +## Migration Plan + +1. Add dotenv loading tests that prove `platform/.env` selects MySQL settings and explicit process env overrides file values. +2. Add the personal settings route/page and update existing shell/session wiring to keep API-backed persistence. +3. Validate OpenSpec, backend config tests, frontend tests/typecheck/build, structure checks, and a browser walkthrough for the personal settings page. diff --git a/openspec/changes/fix-env-profile-settings/proposal.md b/openspec/changes/fix-env-profile-settings/proposal.md new file mode 100644 index 0000000..228de70 --- /dev/null +++ b/openspec/changes/fix-env-profile-settings/proposal.md @@ -0,0 +1,30 @@ +## Why + +Local operators can configure `platform/.env`, but the platform process currently reads only inherited environment variables. This makes MySQL metadata storage look uninitialized even when the `.env` file contains the correct `PLATFORM_STORAGE_BACKEND=mysql` and `PLATFORM_MYSQL_DSN` settings. + +The current personal configuration experience is embedded in the sidebar account popover, which is cramped for profile, theme, and background preferences. Operators need a normal first-party personal settings page that saves profile and theme changes through platform APIs so values are durable in the configured metadata store. + +## What Changes + +- Load platform environment variables from local `.env` files before building platform configuration, while preserving explicit process environment overrides. +- Keep MySQL metadata initialization database-backed and make configured storage selection testable so operators can verify the remote metadata store is actually used. +- Replace the sidebar profile popover with a dedicated personal settings page for profile, theme palette, background preset, uploaded background, and logout actions. +- Add the personal settings route to the shell for authenticated users and keep account edits wired to `/api/v1/users/current/profile` and `/api/v1/users/current/theme`. +- Preserve the magical-girl crystal-moonlight visual system by reusing shared shell/page surfaces and theme tokens rather than adding a one-off UI style. + +## Capabilities + +### New Capabilities + +- `platform-env-bootstrap`: Platform startup loads local environment configuration and initializes the configured metadata backend predictably. +- `personal-settings-workspace`: Authenticated users manage their own profile and console theme preferences from a full settings page backed by platform APIs. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform/config` and platform startup tests for `.env` loading and storage backend selection. +- Affects `platform_web` route definitions, page registry, shell account controls, session usage, API-bound personal settings UI, tests, and shared styling. +- Does not add billing, cloud host sales, external marketplace behavior, raw AI key exposure, or plugin direct access to platform/run internals. diff --git a/openspec/changes/fix-env-profile-settings/specs/personal-settings-workspace/spec.md b/openspec/changes/fix-env-profile-settings/specs/personal-settings-workspace/spec.md new file mode 100644 index 0000000..f91d4af --- /dev/null +++ b/openspec/changes/fix-env-profile-settings/specs/personal-settings-workspace/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Authenticated users have a personal settings page +The platform web application SHALL provide a normal page-level personal settings workspace for every authenticated user. + +#### Scenario: User opens personal settings +- **WHEN** an authenticated user activates the account settings entry point +- **THEN** the application MUST navigate to a full personal settings page instead of opening a cramped profile popover + +#### Scenario: User sees current account data +- **WHEN** the personal settings page renders +- **THEN** it MUST show the current user's display name, email, role labels, status, profile fields, theme palette, background preset, and custom background state + +### Requirement: Personal profile saves through platform APIs +The personal settings page SHALL save editable profile fields through platform-mediated current-user APIs. + +#### Scenario: User saves profile +- **WHEN** a user submits display name, avatar URL, phone, QQ, or contact note changes +- **THEN** the page MUST call the current-user profile API and render the updated current user from the response + +#### Scenario: Profile API is unavailable +- **WHEN** the current-user profile API cannot be reached in local development fallback mode +- **THEN** the page MUST mark the resulting profile state as local fallback rather than pretending database persistence succeeded + +### Requirement: Theme preferences save through platform APIs +The personal settings page SHALL save theme palette, background preset, and custom background preferences through platform-mediated current-user APIs where available. + +#### Scenario: User changes theme preference +- **WHEN** a user selects a palette, selects a background preset, uploads a background, or removes a background +- **THEN** the page MUST update the visible theme and persist the preference through the current-user theme API + +#### Scenario: Theme API is unavailable +- **WHEN** the current-user theme API cannot be reached in local development fallback mode +- **THEN** the page MUST preserve local theme preference behavior and clearly show that the preference is local diff --git a/openspec/changes/fix-env-profile-settings/specs/platform-env-bootstrap/spec.md b/openspec/changes/fix-env-profile-settings/specs/platform-env-bootstrap/spec.md new file mode 100644 index 0000000..6b1e2f2 --- /dev/null +++ b/openspec/changes/fix-env-profile-settings/specs/platform-env-bootstrap/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Platform startup loads local environment files +The platform SHALL load local `.env` configuration before constructing runtime configuration for storage initialization. + +#### Scenario: Platform env file selects MySQL storage +- **WHEN** `platform/.env` contains `PLATFORM_STORAGE_BACKEND=mysql` and `PLATFORM_MYSQL_DSN` +- **THEN** platform configuration MUST use the MySQL storage backend and DSN from the env file + +#### Scenario: Process environment overrides env file +- **WHEN** a process environment variable and a local `.env` file both define the same platform setting +- **THEN** platform configuration MUST use the process environment value + +### Requirement: Metadata backend selection remains database-backed +The platform SHALL initialize the configured metadata repository through the existing store boundary rather than falling back to hardcoded local sample data. + +#### Scenario: MySQL storage is configured +- **WHEN** `PLATFORM_STORAGE_BACKEND=mysql` and a non-empty `PLATFORM_MYSQL_DSN` are loaded +- **THEN** platform startup MUST initialize the MySQL metadata store + +#### Scenario: MySQL storage is missing DSN +- **WHEN** `PLATFORM_STORAGE_BACKEND=mysql` is loaded without `PLATFORM_MYSQL_DSN` +- **THEN** platform startup MUST fail with a direct configuration error instead of silently using file or memory storage diff --git a/openspec/changes/fix-env-profile-settings/tasks.md b/openspec/changes/fix-env-profile-settings/tasks.md new file mode 100644 index 0000000..08ff86e --- /dev/null +++ b/openspec/changes/fix-env-profile-settings/tasks.md @@ -0,0 +1,31 @@ +## 1. Platform Env Bootstrap + +- [x] 1.1 Add a small dotenv loader in `platform/config` that reads local `.env` files without overriding explicit process environment values. +- [x] 1.2 Add config tests for `platform/.env` MySQL settings, process env precedence, and missing DSN behavior through storage initialization. + +## 2. Personal Settings Workspace + +- [x] 2.1 Add a first-party personal settings route, page id, registry entry, and navigation entry point for authenticated users. +- [x] 2.2 Move profile, theme palette, background preset, uploaded background, and logout controls from the sidebar popover into the new page. +- [x] 2.3 Keep profile and theme saves wired to current-user APIs and show API vs local fallback persistence state. +- [x] 2.4 Add/update frontend tests for routing, shell account navigation, and profile/theme API calls. + +## 3. Verification + +- [x] 3.1 Run `cd platform && go test ./config ./api ./service ./repo -count=1`. +- [x] 3.2 Run `cd platform_web && npm run typecheck && npm test && npm run build`. +- [x] 3.3 Run `scripts/check-structure.sh`. +- [x] 3.4 Run `openspec validate fix-env-profile-settings --strict`. +- [x] 3.5 Perform a browser walkthrough for the personal settings page and record the result. + +## Verification Evidence + +- 2026-07-07: `cd platform && go test ./config ./api ./service ./repo -count=1` passed. +- 2026-07-07: `cd platform_web && npm run typecheck` passed. +- 2026-07-07: `cd platform_web && npm test` passed with 11 files / 47 tests. +- 2026-07-07: `cd platform_web && npm run build` passed and Vite produced `dist/` assets. +- 2026-07-07: `scripts/check-structure.sh` passed. +- 2026-07-07: `openspec validate fix-env-profile-settings --strict` reported the change is valid; PostHog telemetry flush failed due restricted DNS and did not affect validation. +- 2026-07-07: Started `cd platform_web && npm run dev -- --port 5173`; Vite served the app at `http://127.0.0.1:5174/` because 5173 was occupied. Browser walkthrough could not be completed in this tool session because no in-app browser/Chrome control tool or local Playwright/Puppeteer dependency was exposed. +- 2026-07-08: Rechecked task `3.5` before generating the next architecture-stream OpenSpec. The browser walkthrough remains explicitly blocked in this session: an in-app browser connection opened the auth page at `http://127.0.0.1:5177/`, but DOM snapshot capture failed with `TypeError: o.incrementalAriaSnapshot is not a function`; the fallback-enabled dev server then failed to bind requested localhost ports with `listen EPERM` for `127.0.0.1:5180`, `127.0.0.1:5173`, and `127.0.0.1:5177`. The walkthrough is not accepted; it must be rerun manually or in a working browser/dev-server session before closing this change. +- 2026-07-08: Browser walkthrough accepted after starting the platform API with file storage at `127.0.0.1:18080` and using the existing Vite dev server at `127.0.0.1:5173`. Logged in with the seeded API-backed platform administrator `operator.local@example.test`, landed on `#/home`, opened `#/profile`, verified `个人设置` showed `API 已连接`, `Operator`, `operator.local@example.test`, `active`, and platform-admin navigation. Edited the contact note to `api walkthrough verified 2026-07-08`, clicked `保存资料`, observed `个人资料已保存到数据库`, reloaded `#/profile`, and confirmed the note value persisted through the API-backed session. diff --git a/openspec/changes/fix-platform-auth-session-api/.openspec.yaml b/openspec/changes/fix-platform-auth-session-api/.openspec.yaml new file mode 100644 index 0000000..d86f152 --- /dev/null +++ b/openspec/changes/fix-platform-auth-session-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-04 diff --git a/openspec/changes/fix-platform-auth-session-api/design.md b/openspec/changes/fix-platform-auth-session-api/design.md new file mode 100644 index 0000000..3c996a9 --- /dev/null +++ b/openspec/changes/fix-platform-auth-session-api/design.md @@ -0,0 +1,16 @@ +## Design + +- Sessions are in-memory platform sessions keyed by a random bearer token. Clients send the token as `Authorization: Bearer `. +- The local development platform seeds one explicit platform administrator account so real login can reach the admin console: + - account/email: `operator.local@example.test` + - password: `operator-local` +- Passwords are stored as PBKDF2-SHA256 hashes with per-user salts using only Go standard library primitives. +- Public registration creates a pending user with `server-admin` role and returns `status=pending` rather than authenticating the user. +- Current-user profile and theme updates operate only on the authenticated session user and return bounded DTOs. +- User management updates reuse `PUT /api/v1/users/{id}` and allow status, roles, display name, email, and profile fields to be changed through service validation. + +## Security Notes + +- Password hashes are not returned in DTOs. +- Pending/disabled users cannot log in. +- The frontend local fallback is disabled unless `VITE_ENABLE_LOCAL_AUTH_FALLBACK=true`, and its fallback user is not a platform admin. diff --git a/openspec/changes/fix-platform-auth-session-api/proposal.md b/openspec/changes/fix-platform-auth-session-api/proposal.md new file mode 100644 index 0000000..08f1315 --- /dev/null +++ b/openspec/changes/fix-platform-auth-session-api/proposal.md @@ -0,0 +1,17 @@ +## Why + +The platform_web console already calls authentication, current-user, profile, theme, and user update endpoints, but the platform API has those routes deferred. That mismatch makes login/register appear broken and encourages the frontend local fallback to grant a platform administrator session without credentials. + +## What Changes + +- Add a minimal first-party username/email + password session API for login, registration, logout, and current-user lookup. +- Store password hashes in platform-owned user records and never expose password material to platform_web. +- Default public registration to pending server-admin scope instead of platform administrator privileges. +- Add controlled user update support so the 用户管理 page can change user status through the API. +- Restrict frontend local fallback to development/demo mode and downgrade it away from platform administrator privileges. + +## Impact + +- Affects `platform/` and `platform_web/`. +- Keeps authentication in platform only; plugins do not receive raw credentials or auth secrets. +- Does not add OAuth, SMS, production persistence, billing, cloud host sales, or unrelated marketplace behavior. diff --git a/openspec/changes/fix-platform-auth-session-api/specs/platform-auth-session/spec.md b/openspec/changes/fix-platform-auth-session-api/specs/platform-auth-session/spec.md new file mode 100644 index 0000000..c20245c --- /dev/null +++ b/openspec/changes/fix-platform-auth-session-api/specs/platform-auth-session/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Platform authentication sessions are implemented +The platform SHALL expose login, registration, logout, and current-user routes backed by platform-owned user records and in-memory session tokens. + +#### Scenario: Active user logs in +- **WHEN** a client posts a valid account and password to `POST /api/v1/auth/login` +- **THEN** the platform MUST return `200` with an authenticated `AuthSessionResponse` and a session token + +#### Scenario: Pending user cannot log in +- **WHEN** a pending user submits valid credentials +- **THEN** the platform MUST reject the login with `403` and MUST NOT issue a session token + +#### Scenario: Current user is requested +- **WHEN** a client sends `GET /api/v1/users/current` with a valid bearer session token +- **THEN** the platform MUST return the bounded current user DTO without password material + +#### Scenario: Session logs out +- **WHEN** a client posts to `POST /api/v1/auth/logout` with a valid bearer token +- **THEN** the platform MUST invalidate that session token + +### Requirement: Registration is low privilege by default +Public registration SHALL create pending users with server scope and SHALL NOT grant platform administrator privileges. + +#### Scenario: Visitor registers +- **WHEN** a visitor submits display name, email, and password to `POST /api/v1/auth/register` +- **THEN** the platform MUST create a pending user with a non-platform-admin role and return `status=pending` + +### Requirement: User management updates are supported +The platform SHALL support controlled user updates through `PUT /api/v1/users/{id}` using named DTOs and service validation. + +#### Scenario: User status is updated +- **WHEN** a platform client sends a valid status update for an existing user +- **THEN** the platform MUST persist and return the updated user DTO + +### Requirement: Current user preferences are supported +The platform SHALL allow an authenticated current user to update bounded profile and theme preference fields. + +#### Scenario: Current user profile is updated +- **WHEN** a client sends `PUT /api/v1/users/current/profile` with a valid bearer session token +- **THEN** the platform MUST persist the bounded profile fields and return the updated current user DTO + +#### Scenario: Current user theme is updated +- **WHEN** a client sends `PUT /api/v1/users/current/theme` with a valid bearer session token +- **THEN** the platform MUST persist the theme preference and return a `UserThemePreferenceResponse` + +### Requirement: Frontend fallback cannot silently grant platform admin +The frontend SHALL NOT persist a local platform administrator user as a fallback authentication path. + +#### Scenario: Auth API is unavailable +- **WHEN** the auth API is unavailable and local fallback is not explicitly enabled +- **THEN** the frontend MUST keep the user on the authentication screen and MUST NOT enter the console as platform administrator + +#### Scenario: Development fallback is enabled +- **WHEN** local fallback is explicitly enabled +- **THEN** the fallback user MUST have server-scoped access only and MUST NOT expose platform administrator navigation diff --git a/openspec/changes/fix-platform-auth-session-api/tasks.md b/openspec/changes/fix-platform-auth-session-api/tasks.md new file mode 100644 index 0000000..06b1cde --- /dev/null +++ b/openspec/changes/fix-platform-auth-session-api/tasks.md @@ -0,0 +1,21 @@ +## 1. OpenSpec And Contracts + +- [x] 1.1 Add auth/session requirements covering login, registration, logout, current-user, profile/theme updates, user update, and local fallback limits. + +## 2. Platform Implementation + +- [x] 2.1 Extend user domain, DTO, model, validation, and service contracts for password hashes, profile, theme, and controlled user updates. +- [x] 2.2 Implement in-memory platform auth sessions and route handlers for `/api/v1/auth/*` and `/api/v1/users/current*`. +- [x] 2.3 Implement `PUT /api/v1/users/{id}` for the 用户管理 page. + +## 3. Frontend Implementation + +- [x] 3.1 Send bearer session tokens on API calls and persist only the API session token, not a privileged local user. +- [x] 3.2 Gate local fallback behind an explicit dev/demo env flag and ensure fallback never grants platform administrator privileges. +- [x] 3.3 Keep metrics/config/AI suggestion gaps in graceful page-local fallback behavior. + +## 4. Verification + +- [x] 4.1 Add backend API/service tests for login/register/current-user/logout/pending/disabled/user-update behavior. +- [x] 4.2 Add frontend session tests for API login, failed auth, refresh session restoration, and fallback gating. +- [x] 4.3 Run platform tests, platform_web tests/typecheck/build, `scripts/check-structure.sh`, and `openspec validate fix-platform-auth-session-api --strict`. diff --git a/openspec/changes/harden-log-artifact-channel-isolation/.openspec.yaml b/openspec/changes/harden-log-artifact-channel-isolation/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/harden-log-artifact-channel-isolation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/harden-log-artifact-channel-isolation/design.md b/openspec/changes/harden-log-artifact-channel-isolation/design.md new file mode 100644 index 0000000..73eb77d --- /dev/null +++ b/openspec/changes/harden-log-artifact-channel-isolation/design.md @@ -0,0 +1,66 @@ +## Context + +The platform/run architecture already separates control registration, job lifecycle calls, durable log ingest, and artifact transfer into typed routes and protocol packages. Prior changes proved those channels individually, and the lifecycle proof showed that real plugin operations can route through platform-owned lifecycle APIs into run jobs. + +This change hardens the cross-channel behavior. The important failure mode is not just malformed payloads; it is starvation under concurrent work. A large artifact or file transfer must not delay control heartbeat, job acknowledgement, job result delivery, or durable log spool upload. Likewise, retry queues must stay independently bounded so a blocked artifact transfer cannot consume the execution path needed for log ingest or job completion. + +## Goals / Non-Goals + +**Goals:** +- Prove run-side scheduling keeps control heartbeat, job ack/result, log upload, and artifact/file transfer on independently bounded paths. +- Prove platform APIs validate and mutate state independently when log, artifact, job, and control requests interleave. +- Add focused tests that simulate slow or large artifact/file work while verifying timely heartbeat, job ack/result, and log acknowledgement. +- Document the channel priority and non-starvation invariants in run/platform protocol docs. +- Preserve the existing channel APIs unless implementation reveals a contract gap that must be made explicit in the spec. + +**Non-Goals:** +- Do not add new plugin-facing transport or direct run access. +- Do not add a browser UI flow unless implementation discovers an existing platform_web surface incorrectly exposes channel details. +- Do not redesign storage backends, introduce external queues, or require distributed infrastructure. +- Do not change artifact/log/job payload semantics except where needed to enforce bounded isolation. + +## Decisions + +1. Keep isolation proof local to run/platform packages before adding broader e2e tooling. + + The current risk lives in queueing, retry, route handling, and worker scheduling. Package-level tests can deterministically simulate slow artifact uploads, retryable platform failures, and interleaved requests without relying on brittle timing from a full browser stack. A later browser acceptance suite can reuse this confidence without becoming the primary proof. + + Alternative considered: start with a full local platform/run/browser smoke. That gives nice operator evidence but is weaker for starvation because browser timing is noisy and harder to make deterministic. + +2. Treat control and job lifecycle calls as high-priority bounded work. + + Heartbeats, job claim/ack/progress/result, and cancellation/reconcile calls remain small JSON payloads. They must never carry artifact chunks, file bodies, or large inline logs. Tests should assert that delayed artifact/file uploads cannot prevent these calls from completing. + + Alternative considered: one shared retry worker for all run-to-platform calls. That is simpler, but a stuck artifact transfer could monopolize retries and delay lifecycle visibility. + +3. Keep log ingest durable and independently retryable. + + The log spool already persists batches until platform acknowledgement. This change should assert that log batch selection, upload, ack handling, and retry bookkeeping stay independent from artifact chunk retry queues and job result submission. + + Alternative considered: merge log and artifact retry state because both are upload queues. That would blur priority boundaries and make it easier for large artifact payloads to starve small log acknowledgements. + +4. Verify platform state isolation with interleaved service/API tests. + + Platform tests should interleave control heartbeat, job ack/result, log batch ingest, and artifact transfer requests for the same run endpoint. Success means each route validates only its own contract, mutates only its own state, and preserves idempotency when requests are retried or reordered within valid channel rules. + + Alternative considered: only test run-side clients. That would miss platform-side cross-route coupling, such as artifact completion accidentally blocking log acknowledgement state. + +## Risks / Trade-offs + +- Timing-sensitive tests become flaky -> Use deterministic fakes, channels, contexts, and bounded wait helpers instead of wall-clock sleeps wherever possible. +- Hardening may reveal that current worker scheduling is too serial -> Introduce small, explicit channel executors or queue limits rather than broad worker rewrites. +- Additional docs can drift -> Keep docs close to `run/protocol/` and `platform/protocol/` route contracts, and update them in the same implementation task as tests. +- Full starvation proof can become too broad -> Scope the first pass to platform/run package behavior and exact commands in `tasks.md`; leave browser-wide automation to the later acceptance-suite queue item. + +## Migration Plan + +1. Add failing tests for platform and run channel isolation around existing APIs and queues. +2. Adjust run scheduling, retry queues, or client sequencing only where tests prove coupling. +3. Update protocol documentation with the enforced invariants. +4. Run platform/run tests, structure check, and strict OpenSpec validation. + +Rollback is straightforward because expected changes are test and scheduling hardening around existing APIs. If a scheduling change regresses behavior, revert that implementation while keeping the new tests as the contract for the corrected approach. + +## Open Questions + +- None currently. The implementation should stay within `run/` and `platform/` unless a failing test proves a shared contract needs a spec update. diff --git a/openspec/changes/harden-log-artifact-channel-isolation/proposal.md b/openspec/changes/harden-log-artifact-channel-isolation/proposal.md new file mode 100644 index 0000000..8928fbb --- /dev/null +++ b/openspec/changes/harden-log-artifact-channel-isolation/proposal.md @@ -0,0 +1,25 @@ +## Why + +Log ingest, artifact/file transfer, control heartbeat, and job ack/result delivery already exist as separate platform/run channels, but the current evidence mostly proves each channel in isolation. The next risk is starvation under load: a large artifact or file operation must not delay heartbeat, lifecycle acknowledgement, job result delivery, or durable log upload. + +## What Changes + +- Add channel-isolation requirements that define priority, bounded payloads, retry behavior, and non-starvation guarantees across run/platform channels. +- Add run-side concurrency and queue tests proving large artifact/file work cannot block control heartbeat, job ack/result submission, or log spool upload. +- Add platform service/API tests proving artifact/log/job/control endpoints preserve independent validation, state mutation, and idempotency under interleaved requests. +- Add a local verification command set that exercises platform and run test suites plus strict OpenSpec validation. +- No breaking API changes are expected; the change hardens behavior and verification around existing channel contracts. + +## Capabilities + +### New Capabilities +- `log-artifact-channel-isolation`: Defines cross-channel non-starvation, bounded-transfer, durable-retry, and verification guarantees for run/platform control, job, log, artifact, and file operations. + +### Modified Capabilities +- None. + +## Impact + +- Affected roots: `run/` and `platform/`. +- Affected areas: run worker scheduling, platform client calls, log spool retry, artifact/file queue retry, platform run-facing APIs, service tests, API tests, and protocol documentation. +- Validation impact: requires focused run/platform concurrency tests, existing package tests, `scripts/check-structure.sh`, and `openspec validate harden-log-artifact-channel-isolation --strict`. diff --git a/openspec/changes/harden-log-artifact-channel-isolation/specs/log-artifact-channel-isolation/spec.md b/openspec/changes/harden-log-artifact-channel-isolation/specs/log-artifact-channel-isolation/spec.md new file mode 100644 index 0000000..3b19176 --- /dev/null +++ b/openspec/changes/harden-log-artifact-channel-isolation/specs/log-artifact-channel-isolation/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Run channels preserve non-starvation under large transfers +The run executor SHALL keep control heartbeat, job acknowledgement, job result delivery, and log batch upload on bounded execution paths that are not blocked by large artifact or file transfer work. + +#### Scenario: Artifact upload does not block lifecycle calls +- **WHEN** a run endpoint is uploading or retrying a large artifact or file transfer +- **THEN** control heartbeat, job acknowledgement, job progress, cancellation polling, reconciliation, and terminal job result calls MUST remain able to complete through their typed platform client methods without waiting for the transfer payload to finish + +#### Scenario: Log upload continues during transfer pressure +- **WHEN** artifact or file transfer chunks are queued, slow, or retrying +- **THEN** the run log spool MUST still select bounded log batches, upload them through the log ingest client, and remove acknowledged batches independently from artifact/file queue state + +### Requirement: Platform routes mutate only their own channel state +The platform SHALL handle interleaved control, job, log, artifact, and file requests for the same run endpoint without one channel accepting another channel's payload or mutating another channel's state. + +#### Scenario: Interleaved valid requests succeed independently +- **WHEN** a registered run endpoint interleaves valid heartbeat, job ack/result, log batch ingest, and artifact chunk or completion requests +- **THEN** each route MUST validate its own typed DTO, update only the corresponding control/job/log/artifact state, and return the same acknowledgement semantics as if the requests were sent without interleaving + +#### Scenario: Heavy payload is rejected from lightweight routes +- **WHEN** a control, job, or log route receives an artifact chunk, file body, host path, raw credential, direct socket, or other transport payload owned by another channel +- **THEN** the platform MUST reject the request as a JSON validation error and MUST NOT mutate control session, job lifecycle, log acknowledgement, or artifact state + +### Requirement: Retry queues remain independently bounded +The run executor SHALL keep log retry state and artifact/file retry state independently bounded and independently acknowledged. + +#### Scenario: Artifact retry backlog does not consume log retry state +- **WHEN** artifact or file chunks remain unacknowledged after platform upload failures +- **THEN** the artifact/file retry queue MUST retain those chunks without preventing log spool retry listing, log batch upload, or acknowledged log batch removal + +#### Scenario: Log retry backlog does not consume artifact retry state +- **WHEN** log batches remain unacknowledged after platform ingest failures +- **THEN** the log spool MUST retain those batches without preventing artifact/file retry listing, chunk upload, or acknowledged artifact chunk removal + +### Requirement: Job terminal results remain bounded and prioritized +The run job channel SHALL submit terminal job results as bounded metadata and result references, not inline logs, artifact chunks, file bodies, host paths, raw credentials, or direct sockets. + +#### Scenario: Terminal result arrives while transfer is active +- **WHEN** a job finishes while artifact/file transfer work is still active or retrying +- **THEN** run MUST submit the terminal job result through the job result endpoint with bounded result metadata and the platform MUST accept or reject it only according to job lease and idempotency rules + +#### Scenario: Duplicate terminal result remains idempotent under pressure +- **WHEN** run retries an equivalent terminal job result while log and artifact retries are also pending +- **THEN** platform MUST return the accepted idempotent terminal result response and MUST NOT duplicate logs, chunks, artifacts, or unrelated job metadata + +### Requirement: Channel isolation is documented and verified +The change SHALL document the enforced priority and isolation rules and SHALL include deterministic platform/run tests for interleaved requests, retry independence, and large-transfer non-starvation. + +#### Scenario: Contributor inspects channel docs +- **WHEN** a contributor opens run or platform protocol documentation +- **THEN** the docs MUST state that control and job lifecycle calls are lightweight, log ingest is durable and independently retried, artifact/file transfer is chunked and lower priority, and no lightweight route accepts heavy transfer payloads + +#### Scenario: Verification commands run +- **WHEN** the change is complete +- **THEN** `go test ./...` from `platform/`, `go test ./...` from `run/`, `scripts/check-structure.sh`, and `openspec validate harden-log-artifact-channel-isolation --strict` MUST pass diff --git a/openspec/changes/harden-log-artifact-channel-isolation/tasks.md b/openspec/changes/harden-log-artifact-channel-isolation/tasks.md new file mode 100644 index 0000000..b9ce2e2 --- /dev/null +++ b/openspec/changes/harden-log-artifact-channel-isolation/tasks.md @@ -0,0 +1,56 @@ +## 1. Run-Side Channel Isolation + +- [x] 1.1 Add deterministic run tests that simulate slow or retrying artifact/file transfer work while control heartbeat and job ack/progress/result calls continue through bounded client calls. +- [x] 1.2 Add run tests proving log spool selection, upload acknowledgement, and retry cleanup continue while artifact/file chunks are queued, slow, or retrying. +- [x] 1.3 Add run tests proving artifact/file retry listing, chunk acknowledgement, and cleanup continue while log batches are queued, slow, or retrying. +- [x] 1.4 Update run scheduling, retry queue, or worker orchestration code only where needed to make the tests pass without exposing host paths, raw credentials, direct sockets, or large inline payloads through lightweight channels. +- [x] 1.5 Run `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` and record evidence. + +## 2. Platform Interleaving and Validation + +- [x] 2.1 Add platform service/API tests that interleave valid heartbeat, job ack/result, log batch ingest, and artifact transfer requests for one registered run endpoint. +- [x] 2.2 Prove each interleaved platform route mutates only its own state and preserves existing idempotency semantics. +- [x] 2.3 Add negative platform tests proving control, job, and log routes reject artifact chunks, file bodies, host paths, raw credentials, direct sockets, and channel-owned transport payloads from other routes. +- [x] 2.4 Update platform validators, DTOs, service logic, or route documentation only where needed to enforce the isolation contract. +- [x] 2.5 Run `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` and record evidence. + +## 3. Protocol Documentation + +- [x] 3.1 Update `run/protocol/*.md`, `run/spool/README.md`, `run/artifact/README.md`, `platform/protocol/run-contracts.md`, or `platform/api/routes.md` as needed to document channel priority and payload boundaries. +- [x] 3.2 Confirm docs state that control/job calls remain lightweight, log ingest is durable and independently retried, artifact/file transfer is chunked and lower priority, and lightweight routes never accept heavy transfer payloads. + +## 4. Verification and Stream Handoff + +- [x] 4.1 Record implementation evidence in this tasks file only after each command has actually run. +- [x] 4.2 Run `scripts/check-structure.sh` and record evidence. +- [x] 4.3 Run `openspec validate harden-log-artifact-channel-isolation --strict` and record evidence. +- [x] 4.4 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` to mark `harden-log-artifact-channel-isolation` complete only after evidence exists and move the next queue item to active. +- [x] 4.5 Update `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` with the next implementation/generator handoff after this change closes. + +## Evidence + +- Run-side channel isolation: + - Added `run/api/channel_isolation_test.go`, proving heartbeat, terminal job result, and log ingest complete while an artifact chunk upload is deliberately blocked. + - Added `run/spool/channel_isolation_test.go`, proving log acknowledgement cleanup remains independent from artifact backlog and artifact acknowledgement cleanup remains independent from log backlog. + - No run scheduling or queue production code changes were required; existing separate client calls and separate `logs` / `artifacts` spool areas satisfied the new regression tests. + - Initial sandbox run of `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./api ./spool -count=1` was blocked by `httptest` loopback bind permissions after `run/spool` passed. + - Escalated rerun of `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./api ./spool -count=1` passed for `browser.local/run/api` and `browser.local/run/spool`. + - Full sandbox run of `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` was blocked by `httptest` loopback bind permissions in `run/api` and `run/runtime`; non-listener packages passed. + - Escalated rerun of `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` passed for `api`, `config`, `protocol`, `runtime`, and `spool`. + +- Platform interleaving and validation: + - Added `platform/api/channel_isolation_handlers_test.go`, proving interleaved heartbeat, job ack/result, log batch ingest, and artifact transfer requests mutate only their own channel state. + - Added negative API coverage proving lightweight control/job/log routes reject artifact chunks, inline log arrays, host paths, raw credential fragments, direct socket strings, and heavy transfer payload fields through strict JSON decoding. + - Added rejection-state coverage proving a rejected heavy heartbeat payload does not mutate endpoint capacity or store heavy payload text. + - Focused command `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./api -run 'TestRunChannelAPI|TestLightweightRunRoutes' -count=1` passed. + - Full command `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` passed for `api`, `config`, `domain`, `dto`, `model`, `repo`, `service`, and `validator`. + +- Protocol documentation: + - Updated `run/spool/README.md`, `run/artifact/README.md`, `run/protocol/artifact.md`, `run/protocol/log-ingest.md`, and `run/protocol/job.md` with channel priority, independent retry, and heavy-payload boundary rules. + - Updated `platform/protocol/run-contracts.md` and `platform/api/routes.md` to state that control/job calls remain lightweight, log ingest is durable and independently retried, artifact/file transfer is lower priority and chunked, and lightweight routes reject heavy transfer payloads. + +- Final gates and stream handoff: + - `scripts/check-structure.sh` passed with `structure check passed`. + - `openspec validate harden-log-artifact-channel-isolation --strict` passed with `Change 'harden-log-artifact-channel-isolation' is valid`; the process exited 0. PostHog telemetry flush reported `ENOTFOUND edge.openspec.dev`, which did not affect validation. + - `openspec/changes/architecture-delivery-stream/delivery-plan.md` now marks `harden-log-artifact-channel-isolation` complete and `implement-local-debug-workspace` active. + - `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` now points the next generator chat at creating `implement-local-debug-workspace`, because that OpenSpec directory does not exist yet. diff --git a/openspec/changes/implement-ai-provider-management/.openspec.yaml b/openspec/changes/implement-ai-provider-management/.openspec.yaml new file mode 100644 index 0000000..8e26fbe --- /dev/null +++ b/openspec/changes/implement-ai-provider-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/implement-ai-provider-management/design.md b/openspec/changes/implement-ai-provider-management/design.md new file mode 100644 index 0000000..1e5b5d6 --- /dev/null +++ b/openspec/changes/implement-ai-provider-management/design.md @@ -0,0 +1,77 @@ +## Context + +The platform already has domain, DTO, validator, repository, service, and HTTP API foundations for AI provider resources. The route catalog previously deferred provider test/model actions, and `platform_web/pages/AiProvidersPage.tsx` is still a placeholder. The architecture requires AI provider credentials and base URLs to remain platform-owned, and plugin pages must never receive raw provider keys. + +This change turns AI provider management into a usable first-party workflow across `platform/` and `platform_web/` while keeping the scope intentionally local: configuration validation, status management, and model inventory are platform metadata operations, not live external model calls. + +## Goals / Non-Goals + +**Goals:** + +- Add backend AI provider management APIs for update, enable/disable, configuration test, and model listing. +- Keep all provider responses redacted to `apiKeyRef`; reject raw keys in create and update paths. +- Keep management behavior inside `service.Core` and named DTOs, with handlers acting as transport adapters. +- Implement a functional AI provider management page in `platform_web` with API client/types, create/edit form, status filters, model display, enable/disable, and test actions. +- Add backend and frontend tests for management behavior and secret redaction. + +**Non-Goals:** + +- No real OpenAI/Claude/local provider network calls. +- No secret vault implementation or raw secret storage. +- No plugin-facing AI invocation API. +- No AI-generated config diff/write dispatch. +- No authentication, RBAC, SQL persistence, run-side behavior, billing, cloud host sales, or agent-provider/cloud-provider workflows. + +## Decisions + +### Decision 1: Provider test is metadata validation + +The test endpoint will validate stored provider metadata and report whether the provider is active, has a secret reference when required, includes a default model in its model list, and passes existing validator rules. It will not contact external AI services. + +Alternative considered: performing a live chat/model request. Rejected because this change must not introduce external network behavior, raw key handling, or provider-specific clients. + +### Decision 2: Status changes use a dedicated action route + +Enable/disable behavior will use `POST /api/v1/ai-providers/{id}/status` with a named status request DTO. General update will edit provider metadata while preserving status unless the dedicated action changes it. + +Alternative considered: overloading generic update with status changes. Rejected because explicit status actions are easier to audit and test. + +### Decision 3: Update uses full provider metadata + +The update request will accept the same safe fields as create plus provider metadata fields, with no raw key field. `apiKeyRef` remains a secret reference string and is validated the same way as create. + +Alternative considered: partial patch semantics. Rejected for this stage because full update is deterministic, simpler to validate, and matches the existing in-memory repository implementation. + +### Decision 4: Frontend page owns UI state but not contracts + +`AiProvidersPage` will manage local loading/form selection state, while API DTOs and client functions remain in `platform_web/api`. The page will use API responses for persisted provider data and seed a local demo fallback only when the backend is unavailable in standalone frontend development. + +Alternative considered: hard-coded page data only. Rejected because this would not exercise the platform API client or management workflow. + +### Decision 5: UI stays operational and dense + +The AI provider page will use a table, compact metrics, a form panel, filter controls, and action buttons. It will avoid marketing layout and will not display instructional copy or raw secrets. + +Alternative considered: a large hero/empty-state page. Rejected because this is an operational console area used for repeated configuration work. + +## Risks / Trade-offs + +- [Risk] The test endpoint can only validate metadata, not live connectivity. Mitigation: return an explicit `mode` value and reserve live tests for a later provider invocation change. +- [Risk] Frontend fallback data could be mistaken for persisted data. Mitigation: mark fallback state as local-only in view state and prefer API data whenever the backend responds. +- [Risk] Full update requires clients to send all editable provider fields. Mitigation: centralize the request builder in the page and API client. +- [Risk] In-memory backend state remains process-local. Mitigation: retain service/router injection and leave persistence to a future storage change. + +## Migration Plan + +1. Add backend DTOs, service methods, handler routes, and route catalog updates for AI provider management. +2. Add backend service/API tests covering update, status, test/model responses, duplicate/missing resources, and raw key rejection. +3. Add frontend API types/client methods, replace the placeholder AI provider page, and add rendering/client tests. +4. Run backend tests, frontend tests/build, structure check, browser walkthrough, and strict OpenSpec validation. + +Rollback before dependent changes is removal of the new AI provider management endpoints/page and this OpenSpec change. After dependent plugin or frontend workflows consume these APIs, rollback must be handled through a new OpenSpec change. + +## Open Questions + +- Which persistence-backed secret reference provider should store `apiKeyRef` targets? +- Which later change should add live provider connectivity tests and model discovery calls? +- Which authorization policy will restrict who can create or disable providers? diff --git a/openspec/changes/implement-ai-provider-management/proposal.md b/openspec/changes/implement-ai-provider-management/proposal.md new file mode 100644 index 0000000..0ddbfab --- /dev/null +++ b/openspec/changes/implement-ai-provider-management/proposal.md @@ -0,0 +1,28 @@ +## Why + +AI providers are a required first-party platform area, but the backend and console currently expose only the generic core resource API and a placeholder page. Operators need a usable management workflow that configures model endpoints safely without exposing raw provider credentials to plugins or UI responses. + +## What Changes + +- Add AI-provider-specific backend management actions for update, enable/disable, configuration test, and configured model listing. +- Preserve the existing create/list/detail API while tightening response behavior around secret references and raw key rejection. +- Add service methods and DTOs for AI provider management without adding external provider calls or raw secret storage. +- Replace the `platform_web` placeholder AI provider page with a functional management view that lists providers, creates/edits provider metadata, toggles status, tests configuration, and displays model inventory. +- Add frontend API types/client methods and tests that assert raw keys are never part of returned provider shapes. + +## Capabilities + +### New Capabilities + +- `ai-provider-management`: Safe platform and management-console workflows for creating, editing, enabling/disabling, testing, and viewing AI provider configuration. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform/` and `platform_web/` only. +- Extends AI provider DTOs, service methods, API handlers, route catalog, frontend API contracts, and the AI provider page. +- Adds backend API tests, frontend rendering/client tests, and a browser walkthrough. +- Does not add raw key exposure, plugin-facing raw credentials, run-side behavior, external AI network invocation, billing, cloud host sales, or agent-provider/cloud-provider workflows. diff --git a/openspec/changes/implement-ai-provider-management/specs/ai-provider-management/spec.md b/openspec/changes/implement-ai-provider-management/specs/ai-provider-management/spec.md new file mode 100644 index 0000000..0019c3b --- /dev/null +++ b/openspec/changes/implement-ai-provider-management/specs/ai-provider-management/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: AI providers can be managed through platform APIs +The platform SHALL expose AI provider management APIs for create, list, detail, update, enable/disable, configuration test, and configured model listing. + +#### Scenario: Provider is updated +- **WHEN** a client sends a valid provider update request to an existing AI provider +- **THEN** the platform MUST validate the request, persist the metadata through `service.Core`, and return a redacted `AIProviderResponse` + +#### Scenario: Provider status is changed +- **WHEN** a client enables or disables an existing AI provider through the status action route +- **THEN** the platform MUST persist the requested status and return a redacted `AIProviderResponse` + +#### Scenario: Provider configuration is tested +- **WHEN** a client tests an existing AI provider +- **THEN** the platform MUST validate stored metadata locally and return a named test result DTO without contacting external AI services + +#### Scenario: Provider model list is requested +- **WHEN** a client requests configured models for an existing AI provider +- **THEN** the platform MUST return the provider ID, default model, and configured model names without exposing credentials + +### Requirement: AI provider management preserves secret boundaries +AI provider management SHALL reject raw key material in request fields and SHALL never expose raw API keys in API responses or frontend-visible types. + +#### Scenario: Raw key is submitted during update +- **WHEN** a create or update request includes raw key material instead of a secret reference in `apiKeyRef` +- **THEN** the platform MUST reject the request with a validation error and MUST NOT persist the provider + +#### Scenario: Provider is returned to UI +- **WHEN** the backend or frontend API client returns provider data +- **THEN** the response/type MUST include `apiKeyRef` only and MUST NOT include `apiKey`, `rawApiKey`, or equivalent raw credential fields + +### Requirement: AI provider service owns management invariants +The platform service layer SHALL own AI provider update, status, local test, and model-list behavior rather than implementing those rules directly in HTTP handlers. + +#### Scenario: Management handler receives request +- **WHEN** an AI provider management HTTP handler accepts a request +- **THEN** it MUST decode named DTOs, call `service.Core`, and encode named DTO responses + +#### Scenario: Missing provider is managed +- **WHEN** a management action targets a missing provider ID +- **THEN** the platform MUST return a stable `404` JSON error response + +### Requirement: AI provider console page is functional +The management console SHALL replace the placeholder AI provider page with a functional operational view for configured providers. + +#### Scenario: Operator opens AI provider page +- **WHEN** the AI provider page renders +- **THEN** it MUST show provider counts, status distribution, configured model counts, and a provider table + +#### Scenario: Operator edits provider form +- **WHEN** an operator creates or edits a provider through the page form +- **THEN** the page MUST submit named API requests and refresh or update the provider list without displaying raw key material + +#### Scenario: Operator uses provider actions +- **WHEN** an operator triggers enable/disable, test, or model-list actions +- **THEN** the page MUST call the matching API client methods and display the redacted result state + +### Requirement: Frontend contracts are centralized +The frontend SHALL keep AI provider API types and client methods in `platform_web/api` and SHALL keep shared UI contracts out of page-local hidden types. + +#### Scenario: Page consumes provider data +- **WHEN** `AiProvidersPage` needs provider data or actions +- **THEN** it MUST use named API types and `PlatformApiClient` methods instead of inline fetch contracts + +#### Scenario: Frontend tests inspect provider types +- **WHEN** frontend tests check provider response shapes +- **THEN** they MUST confirm raw key fields are absent from returned provider data + +### Requirement: AI provider management is verified end to end +The change SHALL include backend API/service tests, frontend tests/build, a browser walkthrough, structure validation, and strict OpenSpec validation. + +#### Scenario: Verification commands run +- **WHEN** the change is complete +- **THEN** `go test ./...` from `platform/`, frontend tests/build, `scripts/check-structure.sh`, and `openspec validate implement-ai-provider-management --strict` MUST pass + +#### Scenario: Browser walkthrough runs +- **WHEN** frontend AI provider page behavior is claimed complete +- **THEN** a browser walkthrough MUST verify the page renders, exposes the AI provider workflow, and does not show raw credential fields diff --git a/openspec/changes/implement-ai-provider-management/tasks.md b/openspec/changes/implement-ai-provider-management/tasks.md new file mode 100644 index 0000000..e540877 --- /dev/null +++ b/openspec/changes/implement-ai-provider-management/tasks.md @@ -0,0 +1,36 @@ +## 1. Backend Contracts And Service + +- [x] 1.1 Add AI provider update, status, test, and model-list DTO contracts with redacted response shapes. +- [x] 1.2 Extend `service.Core` with AI provider update, status, local test, and model-list methods using existing validators and repositories. + +## 2. Backend API Surface + +- [x] 2.1 Implement AI provider management routes for update, status, test, and models using named DTOs and service methods. +- [x] 2.2 Update platform route/protocol documentation for implemented AI provider management routes and deferred live invocation. +- [x] 2.3 Add backend service/API tests for update, enable/disable, test/models, missing resources, duplicate handling, and raw key rejection. + +## 3. Frontend Contracts And Page + +- [x] 3.1 Add centralized `platform_web/api` AI provider types and `PlatformApiClient` methods for list/create/update/status/test/models. +- [x] 3.2 Replace the placeholder AI provider page with a functional operational management view using the API client and no raw key display. +- [x] 3.3 Add frontend tests for page rendering, management actions, API client calls, and raw-key field absence. + +## 4. Verification + +- [x] 4.1 Run `go test ./...` from `platform/` and record evidence. +- [x] 4.2 Run frontend tests/build from `platform_web/` and record evidence. +- [x] 4.3 Run a browser walkthrough of the AI provider page and record evidence. +- [x] 4.4 Run `scripts/check-structure.sh` and record evidence. +- [x] 4.5 Run `openspec validate implement-ai-provider-management --strict` and record evidence. + +## Evidence + +- `go test ./domain ./dto`: passed. +- `go test ./service ./api`: passed. +- `go test ./...` from `platform/`: passed. +- `npm test` from `platform_web/`: passed. +- `npm run typecheck` from `platform_web/`: passed. +- `npm run build` from `platform_web/`: passed. +- Browser walkthrough with Playwright Chromium against `http://127.0.0.1:5173/#/aiProviders`: passed; rendered AI provider management, created `Browser Check Provider`, tested metadata, toggled status, and verified visible text did not contain `rawApiKey`, `api_key=`, `Bearer `, or `sk-`. +- `scripts/check-structure.sh`: passed. +- `openspec validate implement-ai-provider-management --strict`: passed. diff --git a/openspec/changes/implement-artifact-download-and-browser-transfer/.openspec.yaml b/openspec/changes/implement-artifact-download-and-browser-transfer/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/implement-artifact-download-and-browser-transfer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/implement-artifact-download-and-browser-transfer/design.md b/openspec/changes/implement-artifact-download-and-browser-transfer/design.md new file mode 100644 index 0000000..1f63754 --- /dev/null +++ b/openspec/changes/implement-artifact-download-and-browser-transfer/design.md @@ -0,0 +1,61 @@ +## Context + +The existing artifact transfer channel handles run-to-platform upload with chunk/resume semantics and platform-owned artifact metadata. Browser consumers need the opposite user-facing surface: list and download available artifacts from server/job/plugin contexts through platform authorization. The browser should receive safe references and platform routes, not raw storage locations. + +## Goals / Non-Goals + +**Goals:** + +- Add platform artifact download metadata and content routes for authorized browser users and plugin bridge actions. +- Support bounded chunk/range reads or download responses that can report progress in the frontend. +- Enforce artifact owner scope, user/server access, plugin permissions, availability state, and response redaction. +- Add frontend API client methods and UI controls for artifact download/open from operational pages. +- Add plugin bridge artifact helpers that return safe references rather than raw paths or storage credentials. +- Verify with tests and browser walkthrough. + +**Non-Goals:** + +- No external object storage backend or presigned raw storage URLs. +- No browser direct access to run endpoints, host filesystem paths, sockets, or storage backend credentials. +- No artifact upload from browser unless a future change explicitly adds it. +- No archive extraction, malware scanning, lifecycle cleanup, billing, cloud host sales, or unrelated marketplace behavior. + +## Decisions + +### Decision 1: Browser downloads go through platform routes + +The platform exposes artifact content through authorized API routes. Any download URL or token is a platform route scoped to the requesting user/session and artifact, not a raw backend location. + +Alternative considered: return storage adapter paths or presigned backend URLs. Rejected because no storage backend exists yet and raw locations can leak internals. + +### Decision 2: Artifact availability is required for download + +Only artifacts in an available/complete state can be downloaded by browser consumers. Uploading, failed, missing, or unauthorized artifacts return stable safe errors. + +Alternative considered: stream partial uploading artifacts. Rejected because partial reads complicate integrity and user expectations. + +### Decision 3: Plugin bridge receives artifact references, not bytes by default + +Bridge actions can request artifact metadata/open/download references. Large byte transfer stays in platform/browser client code, preserving bounded payloads across plugin bridge messages. + +Alternative considered: pass base64 artifact bytes through plugin page bridge messages. Rejected because large payloads can block UI and violate channel separation. + +## Risks / Trade-offs + +- [Risk] In-memory artifact payload storage limits realistic download size. Mitigation: keep interfaces ready for storage adapters and test bounded content behavior. +- [Risk] Browser downloads can expose sensitive server files if ownership checks are weak. Mitigation: validate artifact owner scope, user/server access, plugin permissions, and availability before content reads. +- [Risk] Plugin pages may expect direct bytes. Mitigation: provide safe artifact references and frontend host download helpers. + +## Migration Plan + +1. Add platform artifact download contracts, validators, service methods, routes, and docs. +2. Add frontend API client, UI controls, bridge host helpers, and tests. +3. Add plugin SDK artifact reference helpers/tests if needed. +4. Run browser walkthrough, structure check, and strict OpenSpec validation. + +Rollback removes browser download routes/client integration and this change's artifacts before plugin pages depend on them. + +## Open Questions + +- Which durable artifact storage adapter should back downloads after in-memory transfer state is replaced? +- Whether browser upload should be a separate future transfer direction. diff --git a/openspec/changes/implement-artifact-download-and-browser-transfer/proposal.md b/openspec/changes/implement-artifact-download-and-browser-transfer/proposal.md new file mode 100644 index 0000000..4dc8ad8 --- /dev/null +++ b/openspec/changes/implement-artifact-download-and-browser-transfer/proposal.md @@ -0,0 +1,28 @@ +## Why + +Run can upload artifacts to the platform transfer channel, but operators and plugin pages still need a safe way to discover, download, and hand off artifact references in the browser. Artifact download must remain platform-mediated so browser code never receives raw storage backend credentials, raw host paths, or direct run sockets. + +## What Changes + +- Add platform artifact download/read APIs that authorize artifact access and stream or return bounded content through platform-owned routes. +- Add browser-facing artifact metadata, download URL/token, chunk read, and transfer progress contracts without exposing storage internals. +- Add frontend API client and UI behavior for artifact download from server/job/plugin contexts. +- Integrate plugin bridge artifact actions with browser-safe artifact references. +- Add platform and frontend tests plus browser walkthrough for download, access denial, resume/progress, and no-secret rendering. + +## Capabilities + +### New Capabilities + +- `artifact-download-and-browser-transfer`: Browser-safe platform-mediated artifact discovery, download, and plugin bridge transfer references. + +### Modified Capabilities + +- Builds on `artifact-transfer-channel`, plugin bridge contracts, and server/job workflows without changing run upload semantics. + +## Impact + +- Affects `platform/` artifact DTOs, validators, services, APIs, and docs. +- Affects `platform_web/` API contracts, artifact UI/download behavior, plugin bridge host integration, and tests. +- May affect `plugins/` SDK artifact reference helpers/tests. +- Does not add external object storage, presigned raw backend URLs, direct plugin-to-run access, billing, cloud host sales, or unrelated SaaS marketplace features. diff --git a/openspec/changes/implement-artifact-download-and-browser-transfer/specs/artifact-download-and-browser-transfer/spec.md b/openspec/changes/implement-artifact-download-and-browser-transfer/specs/artifact-download-and-browser-transfer/spec.md new file mode 100644 index 0000000..cdd0d3f --- /dev/null +++ b/openspec/changes/implement-artifact-download-and-browser-transfer/specs/artifact-download-and-browser-transfer/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Browser artifact downloads are platform-mediated + +The platform SHALL provide browser-safe artifact metadata and content download APIs that authorize access and do not expose storage backend credentials, raw host paths, or direct run sockets. + +#### Scenario: Authorized artifact download starts +- **WHEN** an authorized operator requests download metadata for an available artifact in an accessible server or job context +- **THEN** the platform MUST return a browser-safe artifact reference or platform download route with filename, content type, size, checksum, and expiry metadata + +#### Scenario: Unauthorized artifact download is denied +- **WHEN** a user or plugin page requests an artifact outside its server, job, or plugin permission scope +- **THEN** the platform MUST return a stable safe error and MUST NOT return artifact bytes or download references + +### Requirement: Artifact content reads are bounded and integrity-aware + +The platform SHALL validate artifact availability, requested range/chunk bounds, checksum metadata, and response size before returning artifact content to browser clients. + +#### Scenario: Available artifact content is read +- **WHEN** a browser client requests a valid byte range or full download for an available artifact +- **THEN** the platform MUST return content with safe headers and integrity metadata + +#### Scenario: Incomplete artifact cannot be downloaded +- **WHEN** a browser client requests an uploading, failed, missing, or incomplete artifact +- **THEN** the platform MUST reject the request and leave artifact state unchanged + +### Requirement: Frontend exposes artifact download workflow + +The frontend SHALL provide centralized API client methods and UI controls for artifact download/open flows from server, job, or plugin contexts. + +#### Scenario: Operator downloads artifact +- **WHEN** an operator clicks an artifact download/open action +- **THEN** the page MUST request platform download metadata/content, show progress or completion state, and avoid raw path/credential display + +#### Scenario: Download fails safely +- **WHEN** an artifact download request fails validation, authorization, or availability checks +- **THEN** the UI MUST show a safe error state without exposing backend paths, run sockets, storage credentials, or raw secrets + +### Requirement: Plugin bridge uses artifact references safely + +The plugin bridge SHALL expose artifact actions as safe metadata or download references rather than raw bytes, host paths, direct run endpoints, or storage backend credentials. + +#### Scenario: Plugin page opens artifact reference +- **WHEN** a plugin page requests an allowed artifact action +- **THEN** the platform/host MUST return a scoped artifact reference that the browser host can download through platform APIs + +#### Scenario: Plugin page lacks artifact permission +- **WHEN** a plugin page requests artifact access without required manifest/page permission +- **THEN** the platform MUST deny the request before returning metadata, bytes, or download references + +### Requirement: Artifact download is verified end to end + +The change SHALL include backend tests, frontend tests/build, plugin SDK tests if artifact bridge helpers are added, browser walkthrough evidence, structure validation, and strict OpenSpec validation. + +#### Scenario: Verification commands pass +- **WHEN** the change is complete +- **THEN** platform tests, platform_web tests/typecheck/build, relevant plugin tests, `scripts/check-structure.sh`, and `openspec validate implement-artifact-download-and-browser-transfer --strict` MUST pass diff --git a/openspec/changes/implement-artifact-download-and-browser-transfer/tasks.md b/openspec/changes/implement-artifact-download-and-browser-transfer/tasks.md new file mode 100644 index 0000000..6fa9e54 --- /dev/null +++ b/openspec/changes/implement-artifact-download-and-browser-transfer/tasks.md @@ -0,0 +1,46 @@ +## 1. Platform Artifact Download Contracts + +- [x] 1.1 Add domain and DTO contracts for browser artifact metadata, download references, range/content requests, progress, and safe errors. +- [x] 1.2 Add validators for artifact IDs, owner/user/plugin scope, availability state, range bounds, response size, checksum metadata, and unsafe secret/path/socket content. +- [x] 1.3 Add service methods for authorized artifact metadata lookup, download reference creation, and bounded content reads. + +## 2. Platform Artifact Download API + +- [x] 2.1 Implement artifact metadata/download reference route using named DTOs and service methods. +- [x] 2.2 Implement bounded artifact content/range route with safe headers and integrity metadata. +- [x] 2.3 Integrate plugin bridge artifact actions with safe artifact references. +- [x] 2.4 Update platform route/protocol documentation for browser artifact download and deferred storage backend behavior. +- [x] 2.5 Add platform tests for successful download, range reads, unavailable artifacts, unauthorized scope, unsafe references, and no raw path/credential responses. + +## 3. Frontend Browser Transfer + +- [x] 3.1 Add centralized `platform_web/api` artifact download types and client methods. +- [x] 3.2 Add UI controls/state for artifact download/open flows from relevant server/job/plugin contexts. +- [x] 3.3 Add bridge host handling for artifact references and browser-mediated download actions. +- [x] 3.4 Add frontend tests for progress/success/error states, unauthorized failures, and no raw secret/path rendering. + +## 4. Plugin SDK Artifact Helpers + +- [x] 4.1 Add or update plugin SDK helpers for artifact bridge request/reference parsing if bridge artifact actions need new helper types. +- [x] 4.2 Add plugin tests for artifact reference helpers and forbidden direct run/storage access assumptions if helper code changes. + +## 5. Verification + +- [x] 5.1 Run `cd platform && go test ./...` and record evidence. +- [x] 5.2 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. +- [x] 5.3 Run relevant plugin tests/typecheck if plugin SDK helpers changed and record evidence. +- [x] 5.4 Run browser walkthrough for artifact download/browser transfer and record evidence. +- [x] 5.5 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.6 Run `openspec validate implement-artifact-download-and-browser-transfer --strict` and record evidence. + +## Evidence + +- 1.1-2.5: `cd platform && go test ./api -run TestArtifactDownload` passed, covering browser-safe references, bounded content/range reads, unavailable artifact rejection, unauthorized scope denial, bridge `artifacts.open`, and forbidden fragment checks. +- 3.1-3.4: `cd platform_web && npm run typecheck` passed. `cd platform_web && npm test -- --run api/client.test.ts utils/pluginBridgeHost.test.ts pages/ServerDetailPage.test.tsx` passed, covering artifact client methods, chunk metadata, bridge artifact reference parsing/rejection, and server detail artifact workflow source checks. +- 4.1-4.2: `cd plugins && npm run typecheck` passed. `cd plugins && npm test` passed, covering `createArtifactOpenRequest`, `parseArtifactReference`, permission checks, and rejection of direct storage URL assumptions. +- 5.1: `cd platform && go test ./...` passed. +- 5.2: `cd platform_web && npm run typecheck` passed; `cd platform_web && npm test` passed; `cd platform_web && npm run build` passed. +- 5.3: `cd plugins && npm run typecheck` passed; `cd plugins && npm test` passed. +- 5.4: Browser walkthrough passed with a temporary local mock server and headless Chrome: opened server detail, selected `操作历史`, clicked artifact `打开`, observed `已打开 artifact-walk.bin`, and checked rendered text for forbidden path/token/storage fragments. Temporary walkthrough files were removed. +- 5.5: `scripts/check-structure.sh` passed. +- 5.6: `openspec validate implement-artifact-download-and-browser-transfer --strict` reported `Change 'implement-artifact-download-and-browser-transfer' is valid`. PostHog telemetry flush logged DNS errors afterward, but validation exited 0. diff --git a/openspec/changes/implement-artifact-transfer-channel/.openspec.yaml b/openspec/changes/implement-artifact-transfer-channel/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/implement-artifact-transfer-channel/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/implement-artifact-transfer-channel/design.md b/openspec/changes/implement-artifact-transfer-channel/design.md new file mode 100644 index 0000000..ae72979 --- /dev/null +++ b/openspec/changes/implement-artifact-transfer-channel/design.md @@ -0,0 +1,86 @@ +## Context + +Run control, job lifecycle, and durable log ingest are implemented as separate HTTP JSON channels. Artifact metadata exists in the platform, and job results can reference artifacts, but there is no transfer workflow that can move large run-produced files into platform-managed artifact records with resume and checksum semantics. + +This change implements the first run-to-platform artifact upload channel. Platform storage remains in memory and artifact payloads are held only long enough to prove chunk ordering and final checksum behavior. The channel is intentionally separate from control, jobs, logs, and the optional game client bridge so large payloads do not share those routes or DTOs. + +## Goals / Non-Goals + +**Goals:** + +- Define typed artifact transfer payloads in `run/protocol` and matching platform DTO/domain contracts. +- Add platform artifact transfer routes for open, chunk upload, resume/status, and complete. +- Validate active run session, owner relationship, transfer identity, bounded chunk size, chunk checksum, byte ranges, resume state, and final checksum. +- Update existing `Artifact` metadata from `uploading` to `available` only after every chunk is present and the final checksum matches. +- Add a run-side artifact spool/queue that persists unacknowledged chunk upload requests and deletes them only after platform acknowledgement. +- Extend `run/api.PlatformClient` with typed artifact transfer methods. +- Add tests for platform service/API transfer behavior, resume, duplicate chunk acknowledgement, checksum errors, completion errors, run spool retention, and client request/response handling. + +**Non-Goals:** + +- No platform-to-run download flow, browser upload/download UI, external object storage backend, presigned URL flow, or streaming transport. +- No plugin bridge file APIs, AI artifact inspection, archive extraction, or artifact lifecycle cleanup jobs. +- No raw host paths, raw credentials, direct sockets, logs, or job result bodies inside artifact chunk requests. +- No billing, cloud host sales, agent-provider/cloud-provider workflows, or direct plugin-to-run access. + +## Decisions + +### Decision 1: HTTP JSON chunk endpoints first + +The initial channel uses separate JSON `POST` endpoints under `/api/v1/run/artifacts/*`: `open`, `chunks`, `status`, and `complete`. Chunk payloads use JSON byte encoding, which Go represents as base64, and validators enforce a bounded maximum chunk size. + +Alternative considered: multipart upload or object-storage signed URLs. Rejected for this change because there is no storage backend yet, and the first requirement is to prove protocol, validation, resume, and checksum semantics in tests. + +### Decision 2: Run uploads only in this change + +The transfer direction is explicit but only `upload` is accepted. Platform-to-run download will need separate authorization, cache, and throttling semantics after upload behavior is stable. + +Alternative considered: implementing upload and download together. Rejected because download would add browser/plugin access questions and storage-adapter behavior that are outside this queue item. + +### Decision 3: Existing Artifact metadata remains the public resource + +Opening a transfer creates or validates the existing `Artifact` metadata record in `uploading` state. Completion updates that same record to `available`; failed checksum or missing chunk errors leave the artifact non-available. + +Alternative considered: adding a separate persisted transfer model now. Rejected because current platform persistence is in-memory and the transfer session can stay behind `service.Core` until a database-backed storage change exists. + +### Decision 4: Chunks are accepted idempotently by checksum + +The platform records received chunk indexes, byte ranges, sizes, checksums, and payload bytes in memory. Re-uploading the same chunk with the same checksum returns a duplicate acknowledgement; re-uploading a different payload for an acknowledged index is rejected. + +Alternative considered: allowing overwrite of existing chunk indexes. Rejected because resumable upload cleanup must be deterministic and conflicting retries should be visible immediately. + +### Decision 5: Owner authorization is platform mediated + +Run uploads are accepted only for job-owned or server-instance-owned artifacts that belong to the requesting run endpoint. Platform/plugin-owned artifact records can still be created through metadata APIs, but this run transfer channel does not let a run endpoint spoof unrelated owners. + +Alternative considered: accepting any artifact owner kind. Rejected because run must not become a direct write path for platform/plugin-owned data without an explicit authorization change. + +### Decision 6: Run spool stores chunk upload requests, not host paths + +The run-side artifact spool writes one JSON file per pending chunk request. It stores the bounded request payload needed for retry and never stores or exposes the local host path that originally produced the bytes. + +Alternative considered: storing file path plus offset for retry. Rejected because run must enforce scoped paths and must not expose raw host paths through platform-facing transfer state. + +## Risks / Trade-offs + +- [Risk] In-memory platform chunk storage disappears on restart. Mitigation: keep transfer state behind `service.Core`; storage adapters and durable transfer sessions can replace it later. +- [Risk] JSON/base64 chunks are inefficient for large production artifacts. Mitigation: enforce bounded chunks now and leave streaming/object-storage transfer to a later change. +- [Risk] No background artifact uploader exists. Mitigation: run client and spool semantics are implemented and tested; scheduling and priority throttling can build on them later. +- [Risk] Upload-only support does not cover all artifact use cases. Mitigation: explicitly keep direction in the protocol so a future download change can extend without renaming the channel. + +## Migration Plan + +1. Add artifact transfer protocol, DTO, domain, validation, and service contracts. +2. Add platform API handlers and tests for open, chunk upload, resume/status, and complete. +3. Add run artifact spool implementation and tests. +4. Add run client methods and tests. +5. Update protocol and route docs. +6. Verify with platform tests, run tests, structure check, and strict OpenSpec validation. + +Rollback before dependent changes is removal of the artifact transfer route/client/spool additions and this OpenSpec change. After server workflows depend on artifact transfer, rollback must use a new OpenSpec change. + +## Open Questions + +- Which durable artifact storage backend should be implemented first: local segments, filesystem package storage, S3-compatible object storage, or another adapter? +- What production chunk size, concurrency limits, and backoff policy should artifact uploaders use? +- How should platform-to-run download authorization interact with plugin pages and server management workflows? diff --git a/openspec/changes/implement-artifact-transfer-channel/proposal.md b/openspec/changes/implement-artifact-transfer-channel/proposal.md new file mode 100644 index 0000000..1c66b78 --- /dev/null +++ b/openspec/changes/implement-artifact-transfer-channel/proposal.md @@ -0,0 +1,29 @@ +## Why + +Jobs and logs now have separate run-platform channels, but large files still only exist as artifact metadata or opaque result references. This change adds the first artifact transfer channel so run can upload and resume bounded file chunks with checksum verification without blocking control, job, or log traffic. + +## What Changes + +- Add typed run artifact transfer protocol payloads for transfer creation, chunk upload, resume status, and completion acknowledgements. +- Add platform API routes that create artifact transfer sessions, accept bounded chunks, validate sequence/order/checksums, report resume state, and complete verified artifacts. +- Extend platform service behavior to store chunk state in memory, update existing artifact metadata, and keep artifact transfer traffic separate from control, job, and log workflows. +- Add a run-side local artifact transfer queue/spool abstraction that records pending chunk manifests and removes chunks only after platform acknowledgement. +- Extend the run-side platform client with typed artifact transfer methods. +- Add platform service/API tests and run queue/client tests covering chunk resume, checksum failures, completion validation, retry cleanup, and channel isolation assumptions. + +## Capabilities + +### New Capabilities + +- `artifact-transfer-channel`: Chunked and resumable run-to-platform artifact transfer, checksum validation, local retry retention, and transfer completion workflow. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform/` and `run/` only. +- Adds Go protocol/DTO/domain/service/API/queue code and tests for artifact transfer. +- Updates run/platform protocol and route documentation. +- Does not implement browser upload/download UI, external object storage backends, plugin bridge file access, AI artifact inspection, billing, cloud host sales, or direct plugin/run access. diff --git a/openspec/changes/implement-artifact-transfer-channel/specs/artifact-transfer-channel/spec.md b/openspec/changes/implement-artifact-transfer-channel/specs/artifact-transfer-channel/spec.md new file mode 100644 index 0000000..ed776ac --- /dev/null +++ b/openspec/changes/implement-artifact-transfer-channel/specs/artifact-transfer-channel/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: Artifact transfer channel is separate from other run channels + +The platform SHALL expose artifact transfer behavior through dedicated run artifact routes and SHALL NOT require control, job, or log routes to carry artifact chunk payloads. + +#### Scenario: Dedicated artifact routes handle chunks +- **WHEN** a registered run endpoint uploads an artifact chunk +- **THEN** the request is handled by a run artifact transfer route and no control, job, or log route accepts the chunk payload + +### Requirement: Run opens upload transfer sessions + +The platform SHALL allow an active run session to open an upload transfer for a job-owned or server-instance-owned artifact assigned to that run endpoint. + +#### Scenario: Valid upload session opens +- **WHEN** a run endpoint opens an upload transfer with a valid session token, artifact metadata, owner, total size, chunk size, checksum, and idempotency key +- **THEN** the platform returns an accepted transfer ID, records the artifact in uploading state, and reports no received chunks + +#### Scenario: Invalid owner is rejected +- **WHEN** a run endpoint opens an upload transfer for an artifact owner that is not assigned to that run endpoint +- **THEN** the platform rejects the request without marking the artifact available + +### Requirement: Platform validates chunk upload integrity + +The platform SHALL validate artifact transfer ID, run session, chunk index, byte range, payload size, and chunk checksum before acknowledging an uploaded chunk. + +#### Scenario: Valid chunk is acknowledged +- **WHEN** a run endpoint uploads a chunk whose byte range, payload size, and checksum match the opened transfer +- **THEN** the platform records the chunk and returns an acknowledgement with the accepted chunk index and received chunk list + +#### Scenario: Conflicting duplicate chunk is rejected +- **WHEN** a run endpoint uploads a chunk index that was already acknowledged with different payload bytes or checksum +- **THEN** the platform rejects the request as a validation error + +### Requirement: Artifact transfer resume state is queryable + +The platform SHALL report the current transfer state, received chunk indexes, next missing chunk index, total chunk count, and completion status for an active artifact transfer. + +#### Scenario: Resume status reports missing chunk +- **WHEN** a run endpoint queries transfer status after only part of an artifact has uploaded +- **THEN** the platform returns the acknowledged chunk indexes and the next missing chunk index + +### Requirement: Artifact completion verifies full checksum + +The platform SHALL mark an artifact available only after all chunks are present and the final artifact checksum matches the opened transfer metadata. + +#### Scenario: Complete verified artifact +- **WHEN** every chunk has been uploaded and the run endpoint completes the transfer with the correct final checksum +- **THEN** the platform marks the artifact available and returns the updated artifact metadata + +#### Scenario: Missing chunk prevents completion +- **WHEN** the run endpoint completes a transfer before every chunk is present +- **THEN** the platform rejects completion and leaves the artifact non-available + +### Requirement: Run retains unacknowledged artifact chunks + +The run executor SHALL persist pending artifact chunk upload requests locally and SHALL remove a chunk from the pending queue only after platform acknowledgement for that artifact transfer and chunk index. + +#### Scenario: Acknowledged chunk is removed from retry queue +- **WHEN** a pending artifact chunk receives a platform acknowledgement for the same transfer ID and chunk index +- **THEN** the run artifact queue removes that chunk from pending retry state + +#### Scenario: Unacknowledged chunk remains pending +- **WHEN** an artifact chunk has not received a matching platform acknowledgement +- **THEN** the run artifact queue keeps the chunk available for retry diff --git a/openspec/changes/implement-artifact-transfer-channel/tasks.md b/openspec/changes/implement-artifact-transfer-channel/tasks.md new file mode 100644 index 0000000..8a21266 --- /dev/null +++ b/openspec/changes/implement-artifact-transfer-channel/tasks.md @@ -0,0 +1,35 @@ +## 1. Artifact Transfer Contracts + +- [x] 1.1 Add typed run artifact transfer protocol payloads in `run/protocol` for open, chunk upload, status/resume, completion, and acknowledgements. +- [x] 1.2 Add matching platform DTO/domain contracts and conversion helpers for artifact transfer requests and responses. +- [x] 1.3 Add validation rules for active upload direction, owner scope, bounded chunk size, byte ranges, chunk checksums, final checksums, and completion state. + +## 2. Platform Artifact Transfer + +- [x] 2.1 Extend platform service behavior to open upload transfers, accept idempotent chunks, reject conflicting chunks, report resume status, and mark artifacts available only after verified completion. +- [x] 2.2 Implement platform artifact transfer HTTP routes using named DTOs and service methods. +- [x] 2.3 Add platform service/API tests for successful upload, resume status, duplicate ack, checksum mismatch, invalid owner/session, and missing-chunk completion rejection. + +## 3. Run Artifact Queue And Client + +- [x] 3.1 Implement a run-side local artifact queue that writes pending chunk requests to disk, lists them for retry, and removes acknowledged chunks. +- [x] 3.2 Extend `run/api.PlatformClient` with typed artifact transfer methods. +- [x] 3.3 Add run queue/client tests for retry retention, acknowledgement cleanup, request paths, JSON payloads, response decoding, and platform error handling. + +## 4. Documentation + +- [x] 4.1 Update run and platform protocol/route documentation to mark artifact open/chunk/status/complete implemented and keep control/job/log/game-client channels separate. + +## 5. Verification + +- [x] 5.1 Run `go test ./...` from `platform/` and record evidence. +- [x] 5.2 Run `go test ./...` from `run/` and record evidence. +- [x] 5.3 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.4 Run `openspec validate implement-artifact-transfer-channel --strict` and record evidence. + +## Evidence + +- 2026-07-03: `go test ./...` from `platform/` passed. +- 2026-07-03: `go test ./...` from `run/` passed. +- 2026-07-03: `scripts/check-structure.sh` passed with `structure check passed`. +- 2026-07-03: `openspec validate implement-artifact-transfer-channel --strict` passed with `Change 'implement-artifact-transfer-channel' is valid`. diff --git a/openspec/changes/implement-browser-acceptance-suite/.openspec.yaml b/openspec/changes/implement-browser-acceptance-suite/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/implement-browser-acceptance-suite/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/implement-browser-acceptance-suite/design.md b/openspec/changes/implement-browser-acceptance-suite/design.md new file mode 100644 index 0000000..a2aa0cb --- /dev/null +++ b/openspec/changes/implement-browser-acceptance-suite/design.md @@ -0,0 +1,77 @@ +## Context + +The architecture stream now has API-backed platform, run, platform_web, and plugin proof, plus a documented local debug workspace that can start the real stack, seed `game.example`, create `server-local-debug`, and verify a manual browser walkthrough. The remaining gap is repeatability: browser acceptance currently lives as task evidence and operator procedure, so regressions can slip in when route text, auth behavior, local fixture setup, plugin marketplace data, lifecycle operation surfaces, or safety redaction drift. + +The automated suite should preserve the existing architecture boundaries. Browser checks must exercise platform_web through platform-owned API routes, not direct run or plugin transports. Fixture setup should reuse the local debug scripts and safe dev plugin manifest instead of inventing a second seed path. The suite should be useful locally and in CI-like verification while staying scoped to the game server management platform. + +## Goals / Non-Goals + +**Goals:** +- Provide one automated browser acceptance command that verifies the API-backed local debug console path end to end. +- Reuse or orchestrate the documented local debug stack and smoke fixture so acceptance data matches the manual proof path. +- Verify login and required first-party routes: 首页、服务器管理、插件市场、用户管理、AI 提供商管理. +- Verify server detail and core plugin/server operation surfaces, including plugin marketplace data, lifecycle controls or operation-history proof, and log/artifact references. +- Fail when platform_web uses local/demo fallback data, when required API-backed evidence is missing, or when visible content exposes forbidden sensitive fragments. +- Record deterministic evidence that can be cited from OpenSpec tasks. + +**Non-Goals:** +- Do not change product behavior, visual direction, authorization semantics, or plugin/runtime contracts. +- Do not implement a cloud, billing, host marketplace, or unrelated SaaS workflow. +- Do not require Docker-only infrastructure, real game binaries, raw credentials, raw AI keys, direct sockets, or plugin/browser direct access to run. +- Do not replace unit, API, manifest, or local-debug smoke tests; this suite complements those checks by verifying browser-visible behavior. +- Do not broaden acceptance into full visual regression testing or screenshot comparison. + +## Decisions + +1. Reuse the local debug workspace as the acceptance fixture. + + The suite should either self-start the documented local debug stack or require an explicit command that does so, then run `scripts/local-debug-smoke.sh` to seed and verify the platform/run/plugin fixture before browser checks. This keeps the browser acceptance data aligned with `docs/local-debug-workspace.md` and avoids parallel fixture drift. + + Alternative considered: seed browser acceptance through a separate frontend-only mock. That would make the suite faster but would not prove real platform/run/plugin integration or prevent demo-only regressions. + +2. Put the acceptance harness near platform_web but keep stack orchestration at repository script level. + + Browser route assertions are frontend-facing and should live with platform_web tests or a clearly named acceptance harness. Starting platform, run, smoke, and frontend should remain in scripts so contributors can run one documented command without learning test internals. + + Alternative considered: hide all service orchestration inside a test file. That makes local failures harder to diagnose because logs, ports, and reset behavior become less visible than the existing local debug workflow. + +3. Assert stable user-visible and route-level signals, not screenshots. + + The suite should inspect URLs, visible text, buttons/links, and API-backed markers such as `数据已加载`, `账号 API 已连接`, `game.example`, `server-local-debug`, `run-local-debug`, lifecycle controls, and marketplace bridge actions. It should avoid brittle pixel assertions and should not depend on decorative theme details beyond preserving the existing UI test/build gates. + + Alternative considered: screenshot or visual diff acceptance. That is higher maintenance and better suited for a later design-polish workflow. + +4. Centralize fallback and forbidden-fragment scanning. + + Every route check should run a shared scanner for fallback/demo indicators and forbidden fragments: `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, and plugin-owned transport details. Keeping this scanner shared makes new route coverage safer to add. + + Alternative considered: duplicate string checks per page. That is easy to start but likely to drift and miss new route surfaces. + +5. Produce machine-readable and human-readable evidence. + + The acceptance command should print concise pass/fail output and write an evidence artifact, such as JSON or markdown, listing routes visited, assertions passed, stack URLs, seed evidence directory, and any failure details. OpenSpec tasks should cite that evidence after the command actually runs. + + Alternative considered: rely only on terminal output. Terminal output is useful but too easy to lose during long stream handoffs. + +## Risks / Trade-offs + +- Port collisions -> Allow configurable local debug ports and make the acceptance command print resolved URLs and log paths. +- Browser automation flakiness -> Use deterministic local data, stable route URLs, bounded waits for specific page states, and concise route assertions rather than long click chains. +- Long-running service cleanup -> Reuse `scripts/local-debug-reset.sh` and ensure self-started acceptance runs stop tracked processes on success and failure. +- False positives from sensitive text inside docs or forms -> Scan only visible browser content and acceptance evidence intended for users, while still treating sensitive visible strings as failures. +- Fixture drift from smoke data -> Run or require `scripts/local-debug-smoke.sh` before browser assertions and fail if expected local debug IDs are missing. +- Sandbox limitations around localhost listeners or browser tooling -> Document when elevated permissions are needed for local listener tests, while keeping the suite command itself explicit and reproducible. + +## Migration Plan + +1. Add the browser acceptance harness and repository command wrapper. +2. Reuse the local debug smoke fixture and add evidence output for route assertions and forbidden-fragment scanning. +3. Run the new acceptance command against a clean local debug root. +4. Run frontend typecheck/tests/build, relevant local debug smoke checks, `scripts/check-structure.sh`, and strict OpenSpec validation. +5. Update stream evidence and next pointer after the change is implemented. + +Rollback is straightforward: remove the acceptance harness and command wrapper if it proves unstable; no product runtime state or persisted user data model changes are introduced. + +## Open Questions + +- None currently. During implementation, follow existing platform_web test tooling and local debug script conventions rather than adding a new browser framework if a suitable one already exists in the repository. diff --git a/openspec/changes/implement-browser-acceptance-suite/proposal.md b/openspec/changes/implement-browser-acceptance-suite/proposal.md new file mode 100644 index 0000000..c8c8a5b --- /dev/null +++ b/openspec/changes/implement-browser-acceptance-suite/proposal.md @@ -0,0 +1,26 @@ +## Why + +The architecture stream now has a repeatable local debug workspace and a manually verified API-backed browser walkthrough, but the walkthrough still depends on an operator remembering route checks, seed order, and forbidden-fragment scans. An automated browser acceptance suite is needed now so required first-party console flows stay real, API-backed, and safe as platform, run, platform_web, and plugin features keep evolving. + +## What Changes + +- Add an automated browser acceptance suite for the local debug/API-backed console path. +- Cover login and navigation across 首页、服务器管理、插件市场、用户管理、AI 提供商管理, plus server detail and core plugin/server operation surfaces. +- Require the suite to seed or reuse the documented local debug fixture before browser checks run. +- Require fallback/demo-only data rejection and visible forbidden-fragment scanning on every accepted route. +- Require concrete commands that can run the suite locally and in CI-like verification without relying on manual browser-only evidence. +- No breaking changes are expected; this change automates an existing verified workflow instead of changing product behavior. + +## Capabilities + +### New Capabilities +- `browser-acceptance-suite`: Defines automated browser acceptance coverage for the API-backed management console, local debug fixture prerequisites, required route assertions, plugin/server lifecycle proof, fallback rejection, forbidden-fragment scanning, and verification commands. + +### Modified Capabilities +- None. + +## Impact + +- Affected roots: `platform_web/`, `scripts/`, documentation, and potentially shared local debug fixtures under `platform/`, `run/`, and `plugins/` only as needed to support deterministic acceptance setup. +- Expected implementation areas: browser acceptance test harness, local debug stack orchestration or reuse hooks, visible content assertions, forbidden-fragment scanner, route coverage fixtures, and task evidence. +- Validation impact: requires frontend typecheck/tests/build, automated browser acceptance command, local debug smoke prerequisites, `scripts/check-structure.sh`, and `openspec validate implement-browser-acceptance-suite --strict`. diff --git a/openspec/changes/implement-browser-acceptance-suite/specs/browser-acceptance-suite/spec.md b/openspec/changes/implement-browser-acceptance-suite/specs/browser-acceptance-suite/spec.md new file mode 100644 index 0000000..d53138a --- /dev/null +++ b/openspec/changes/implement-browser-acceptance-suite/specs/browser-acceptance-suite/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Acceptance suite runs against the real local debug stack +The repository SHALL provide an automated browser acceptance suite that verifies platform_web against the API-backed local debug stack and safe game plugin fixture. + +#### Scenario: Suite prepares local debug fixture +- **WHEN** the acceptance suite is run from a clean checkout with documented local debug prerequisites +- **THEN** it MUST start or reuse platform, run worker, platform_web, and the dev game plugin fixture through documented local debug commands and MUST seed or verify `game.example`, `server-local-debug`, and `run-local-debug` before browser assertions begin + +#### Scenario: Suite uses platform-owned browser API path +- **WHEN** platform_web is exercised by the acceptance suite +- **THEN** browser requests MUST go through the configured platform API proxy with `VITE_PLATFORM_API_BASE_URL=/api/v1` and MUST NOT require direct run URLs, run credentials, or plugin-owned transports in browser code or visible output + +### Requirement: Acceptance suite verifies first-party console areas +The browser acceptance suite SHALL verify the required first-party platform_web areas with an API-backed user session. + +#### Scenario: Suite logs in with local debug user +- **WHEN** the acceptance suite opens platform_web +- **THEN** it MUST log in with the documented local debug operator account and confirm the session lands on an API-backed workspace rather than local fallback data + +#### Scenario: Suite verifies required routes +- **WHEN** browser acceptance route checks run +- **THEN** they MUST open 首页、服务器管理、插件市场、用户管理、AI 提供商管理 and assert stable API-backed content for each route + +#### Scenario: Suite rejects fallback content +- **WHEN** any required route renders fallback, mock, demo-only, or local-auth fallback content +- **THEN** the acceptance suite MUST fail and report the route, visible evidence, and failed assertion + +### Requirement: Acceptance suite verifies plugin and server operation surfaces +The browser acceptance suite SHALL verify core plugin/server operation surfaces that prove the console is connected to platform-mediated lifecycle and plugin data. + +#### Scenario: Suite verifies server detail lifecycle surface +- **WHEN** the suite opens the local debug server detail route +- **THEN** it MUST assert that `Local Debug Example Server`, `server-local-debug`, `game.example`, `run-local-debug`, lifecycle controls, operation history, log entry points, and artifact or artifact-reference entry points are visible or otherwise represented through platform-owned UI state + +#### Scenario: Suite verifies plugin marketplace data +- **WHEN** the suite opens the plugin marketplace route or plugin detail surface +- **THEN** it MUST assert that `game.example`, manifest reference metadata, installed state, platform-mediated permissions, bridge actions, and lifecycle capabilities are visible without exposing unsafe runtime transport details + +#### Scenario: Suite verifies operation proof without direct run access +- **WHEN** the suite triggers or inspects a lifecycle operation +- **THEN** it MUST verify platform-owned job or operation-history evidence and MUST NOT rely on platform_web or plugin pages contacting run directly + +### Requirement: Acceptance suite scans visible safety boundaries +The browser acceptance suite SHALL scan accepted browser-visible content for fallback indicators and forbidden sensitive fragments. + +#### Scenario: Suite scans each accepted route +- **WHEN** a required route or plugin/server operation surface is accepted +- **THEN** the suite MUST scan visible text for `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, and plugin-owned transport details + +#### Scenario: Suite fails on forbidden visible fragments +- **WHEN** any forbidden sensitive fragment is visible on an accepted route +- **THEN** the suite MUST fail and report the route, matched fragment class, and enough nearby evidence to debug the leak without printing raw credentials + +### Requirement: Acceptance suite produces reproducible evidence +The browser acceptance suite SHALL provide concrete commands and evidence outputs that can be used to close OpenSpec tasks. + +#### Scenario: Suite command is documented +- **WHEN** contributors read the change documentation or tasks +- **THEN** they MUST find concrete commands for running local debug smoke, browser acceptance, frontend checks, structure checks, and strict OpenSpec validation + +#### Scenario: Suite writes acceptance evidence +- **WHEN** browser acceptance passes +- **THEN** it MUST write or print evidence including stack URLs, seed evidence directory, routes checked, required assertions, fallback scan results, forbidden-fragment scan results, and plugin/server operation proof + +#### Scenario: Suite cleans up self-started services +- **WHEN** the suite starts local debug services itself +- **THEN** it MUST stop or reset only the documented local debug root after completion or failure, using the same safe reset scope as the local debug workspace diff --git a/openspec/changes/implement-browser-acceptance-suite/tasks.md b/openspec/changes/implement-browser-acceptance-suite/tasks.md new file mode 100644 index 0000000..f19ffc3 --- /dev/null +++ b/openspec/changes/implement-browser-acceptance-suite/tasks.md @@ -0,0 +1,65 @@ +## 1. Acceptance Harness and Command Shape + +- [x] 1.1 Add an automated browser acceptance harness in the existing platform_web test/tooling structure, keeping route assertions near frontend code and stack orchestration in repository scripts. +- [x] 1.2 Add a repository command wrapper for running browser acceptance against the local debug stack, with configurable `LOCAL_DEBUG_PLATFORM_PORT`, `LOCAL_DEBUG_WEB_PORT`, and `LOCAL_DEBUG_ROOT`. +- [x] 1.3 Ensure the command can self-start or explicitly reuse the documented local debug stack, and records the resolved platform URL, platform_web URL, log paths, and evidence directory. +- [x] 1.4 Ensure self-started runs clean up with `scripts/local-debug-reset.sh` and only remove allowed local debug roots. + +## 2. Local Debug Fixture Prerequisites + +- [x] 2.1 Reuse `scripts/local-debug-smoke.sh` or equivalent platform-owned setup before browser assertions so `game.example`, `server-local-debug`, and `run-local-debug` exist. +- [x] 2.2 Fail early when platform health, API login, plugin manifest validation, plugin registration, run heartbeat, server lifecycle fixture creation, or job/log/artifact/marketplace references are missing. +- [x] 2.3 Preserve the existing browser/API boundary: platform_web must use `PLATFORM_API_PROXY` and `VITE_PLATFORM_API_BASE_URL=/api/v1`, with `VITE_ENABLE_LOCAL_AUTH_FALLBACK=false`. +- [x] 2.4 Keep fixture commands harmless and bounded, with no Docker-only dependency, real game binaries, raw credentials, raw AI keys, direct sockets, or browser/plugin direct access to run. + +## 3. Browser Route Assertions + +- [x] 3.1 Automate login at platform_web with `operator.local@example.test` / `operator-local` and verify the session lands on an API-backed workspace. +- [x] 3.2 Verify 首页 `#/home` includes API-backed platform overview signals such as `平台概览`, `数据已加载`, game/plugin instance counts, and run node state. +- [x] 3.3 Verify 服务器管理 `#/servers` includes `Local Debug Example Server` and `server-local-debug`. +- [x] 3.4 Verify 插件市场 `#/plugins` includes `game.example`, installed state, manifest reference metadata, lifecycle capabilities, platform-mediated permissions, and bridge actions. +- [x] 3.5 Verify 用户管理 `#/users` includes API-connected account data for `operator.local@example.test`. +- [x] 3.6 Verify AI 提供商管理 `#/aiProviders` includes API-backed provider rows with redacted key references only. + +## 4. Plugin and Server Operation Surface Assertions + +- [x] 4.1 Verify server detail `#/servers/server-local-debug` includes `Local Debug Example Server`, `game.example@0.1.0`, `run-local-debug`, lifecycle controls, logs, config, plugin controls, AI assistant, and operation history entry points. +- [x] 4.2 Trigger or inspect a platform-mediated lifecycle operation and verify platform-owned job or operation-history evidence without requiring direct run access from browser or plugin pages. +- [x] 4.3 Verify log and artifact entry points are represented by logical IDs, platform routes, log refs, artifact refs, or safe metadata only. +- [x] 4.4 Record route-level assertion results in machine-readable evidence, including URL, required markers, and plugin/server operation proof. + +## 5. Safety and Fallback Scanning + +- [x] 5.1 Add a shared fallback scanner that fails on local/demo/fallback workspace indicators on all accepted routes. +- [x] 5.2 Add a shared visible-content forbidden-fragment scanner for `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, and plugin-owned transport details. +- [x] 5.3 Ensure scanner failures report the route, matched fragment class, and safe nearby evidence without printing raw credentials. +- [x] 5.4 Confirm sensitive values remain hidden from browser-visible output while safe redacted references such as `secret://...` or `env://...` are allowed when expected. + +## 6. Documentation and Verification + +- [x] 6.1 Document the browser acceptance command and expected evidence output in the appropriate local debug or frontend development documentation. +- [x] 6.2 Run `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance ` and record the exact final command after implementation. +- [x] 6.3 Run `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/local-debug-smoke.sh` or document why the acceptance command already ran the same smoke prerequisite. +- [x] 6.4 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. +- [x] 6.5 Run relevant touched-root checks, including `cd plugins && npm run typecheck && npm run test && npm run validate:manifest`, `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1`, and `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` when implementation touches those roots or local debug orchestration. +- [x] 6.6 Run `scripts/check-structure.sh` and record evidence. +- [x] 6.7 Run `openspec validate implement-browser-acceptance-suite --strict` and record evidence. +- [x] 6.8 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` and `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` after implementation evidence exists, then stop without implementing `polish-platform-interaction-design` unless explicitly asked. + +## Evidence + +- Implemented `platform_web/acceptance/browser-acceptance.mjs`, `scripts/browser-acceptance.sh`, `platform_web` package script `acceptance:browser`, and documentation in `docs/local-debug-workspace.md` plus `platform_web/README.md`. +- `node --check platform_web/acceptance/browser-acceptance.mjs` passed. +- `bash -n scripts/browser-acceptance.sh` passed. +- `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh` passed after clearing a stale listener from an earlier interrupted run. +- Browser acceptance evidence: `/private/tmp/browser-local-debug-acceptance/browser-acceptance/browser-acceptance-evidence.json`, checked at `2026-07-08T05:13:43.494Z`. +- Acceptance command ran `scripts/local-debug-smoke.sh` as a prerequisite and wrote seed evidence under `/private/tmp/browser-local-debug-acceptance/smoke`. +- Browser routes verified: 首页, 服务器管理, 插件市场, 用户管理, AI 提供商管理, 服务器详情, and 服务器详情 / 插件控制. +- Operation proof verified platform API accepted `process.start` for `server-local-debug`, platform-owned jobs endpoint returned `server-lifecycle:server-local-debug:start:e93f12eb71c03646`, and browser operation history exposed platform task records without direct run access. +- `cd platform_web && npm run typecheck && npm test && npm run build` passed: 11 test files and 49 tests passed, Vite production build succeeded. +- `cd plugins && npm run typecheck && npm run test` passed: 1 test file and 11 tests passed. +- `cd plugins && npm run validate:manifest` passed after an escalated rerun because sandboxed `tsx` IPC failed with `listen EPERM`. +- `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` passed. +- `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` passed after an escalated rerun because sandboxed `httptest` localhost binding failed with `listen tcp6 [::1]:0: bind: operation not permitted`. +- `scripts/check-structure.sh` passed. +- `openspec validate implement-browser-acceptance-suite --strict` passed. OpenSpec emitted PostHog DNS flush warnings after validation, but the command exited 0 and reported `Change 'implement-browser-acceptance-suite' is valid`. diff --git a/openspec/changes/implement-config-write-and-file-dispatch/.openspec.yaml b/openspec/changes/implement-config-write-and-file-dispatch/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/implement-config-write-and-file-dispatch/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/implement-config-write-and-file-dispatch/design.md b/openspec/changes/implement-config-write-and-file-dispatch/design.md new file mode 100644 index 0000000..aa44c17 --- /dev/null +++ b/openspec/changes/implement-config-write-and-file-dispatch/design.md @@ -0,0 +1,59 @@ +## Context + +The frontend already has a useful diff review UX, but it dispatches config writes by calling `POST /jobs` with `capability=config.write`. That bypasses platform-owned config validation, stale version checks, and scoped file semantics. + +This change creates a platform service boundary for config/file dispatch. Later run worker and plugin bridge changes can execute the queued jobs using the same safe envelopes. + +## Goals / Non-Goals + +**Goals:** + +- Add config diff preview and approval routes. +- Validate expected config version, bounded content size, logical config keys, and server ACLs. +- Queue scoped run jobs for approved config/file operations. +- Update frontend to call approval routes rather than generic job creation. +- Keep all write operations reviewable and auditable. + +**Non-Goals:** + +- No real process execution or local file mutation in this change. +- No unrestricted file manager or raw path API. +- No plugin page bridge execution beyond shared dispatch contracts. +- No external storage backend implementation. + +## Decisions + +### Decision 1: Platform service owns write approval + +Config write approval is a service method, not a frontend-generated generic job. It validates server access, config version, diff payload, and logical file key before creating a queued job. + +### Decision 2: File operations use logical keys and refs + +Requests identify server-scoped config/file targets by logical keys or artifact/input refs. Raw absolute paths, home directories, sockets, and credentials are rejected. + +### Decision 3: Diff preview can be pure platform computation + +The backend can compute a textual diff from current config and proposed content without dispatching work. Approval is a separate explicit call. + +### Decision 4: Frontend keeps second confirmation + +ServerDetailPage and AI suggestion flows must keep an explicit confirmation after showing the diff. Approval dispatch happens only after that confirmation. + +## Risks / Trade-offs + +- [Risk] Queued jobs may not execute until the real run worker change lands. Mitigation: this change verifies dispatch and state, not local mutation. +- [Risk] Diff preview duplicates frontend diff code. Mitigation: frontend diff remains display-oriented; backend diff validates dispatch input. +- [Risk] Logical file keys may be too narrow. Mitigation: keep schema extensible and add cases through future OpenSpec changes. + +## Migration Plan + +1. Add platform contracts and validators for config diff/write and file dispatch. +2. Add API handlers and route docs. +3. Extend run protocol payload validation for scoped config/file jobs. +4. Update ServerDetailPage config and AI write flows. +5. Add backend/frontend/run tests and walkthrough. + +## Open Questions + +- Whether approved config writes should become a first-class operation resource rather than a job-only response. +- Whether future restart/update/delete workflows should share the same approval envelope. diff --git a/openspec/changes/implement-config-write-and-file-dispatch/proposal.md b/openspec/changes/implement-config-write-and-file-dispatch/proposal.md new file mode 100644 index 0000000..6cb700c --- /dev/null +++ b/openspec/changes/implement-config-write-and-file-dispatch/proposal.md @@ -0,0 +1,28 @@ +## Why + +ServerDetailPage currently previews config diffs locally and then creates a generic `config.write` job directly from the browser. The route catalog still lists config diff review and file operation dispatch as future work. Operators need a platform-mediated, auditable flow that validates config versions and dispatches scoped run jobs without leaking host paths or credentials. + +## What Changes + +- Add backend config diff preview and config write approval workflows. +- Add scoped file operation dispatch contracts for safe file read/write jobs. +- Move config write dispatch out of generic frontend job creation and into platform-owned service methods. +- Keep explicit user confirmation before any write job is dispatched. +- Update frontend config and AI suggestion write paths to use approved platform routes. + +## Capabilities + +### New Capabilities + +- `config-write-and-file-dispatch`: Safe config diff review, approval, and scoped file operation dispatch. + +### Modified Capabilities + +- `server-management-workflows`: Server detail config writes use platform lifecycle/file dispatch rules instead of direct generic job creation. + +## Impact + +- Affects `platform/` domain, DTO, validators, service, API handlers, route/protocol docs, and tests. +- Affects `run/` protocol validation for scoped config/file job payloads. +- Affects `platform_web/` ServerDetailPage config and AI suggestion apply flows. +- Does not add unrestricted file browsing, raw path exposure, billing, cloud sales, or direct plugin-to-run access. diff --git a/openspec/changes/implement-config-write-and-file-dispatch/specs/config-write-and-file-dispatch/spec.md b/openspec/changes/implement-config-write-and-file-dispatch/specs/config-write-and-file-dispatch/spec.md new file mode 100644 index 0000000..b04ad44 --- /dev/null +++ b/openspec/changes/implement-config-write-and-file-dispatch/specs/config-write-and-file-dispatch/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Config diff preview is platform mediated +The platform SHALL provide a config diff preview route that compares current server config with proposed content without dispatching a write. + +#### Scenario: Preview accepted +- **WHEN** an authorized operator submits proposed config content with the current config version +- **THEN** the platform MUST return a bounded diff and MUST NOT create a run job + +#### Scenario: Preview rejects stale config +- **WHEN** proposed config content references a stale config version +- **THEN** the platform MUST reject the preview and MUST NOT dispatch work + +### Requirement: Config write approval dispatches scoped job +The platform SHALL dispatch config writes only after an explicit approval request passes validation. + +#### Scenario: Approved config write queued +- **WHEN** an authorized operator approves a reviewed config diff +- **THEN** the platform MUST queue a bounded `config.write` job for the server instance and return job metadata + +#### Scenario: Config write hides unsafe internals +- **WHEN** the platform dispatches a config write job +- **THEN** the request and response MUST NOT expose raw host paths, run credentials, direct sockets, or raw secret values + +### Requirement: File operation dispatch is scoped +The platform SHALL provide scoped file operation dispatch for server/plugin workflows using logical file keys or artifact refs. + +#### Scenario: Scoped file read dispatched +- **WHEN** an authorized caller requests a declared logical file read +- **THEN** the platform MUST queue a bounded file read job with scoped target metadata + +#### Scenario: Unsafe file target rejected +- **WHEN** a request includes an absolute path, parent traversal, raw credential, direct socket, or host-local secret path +- **THEN** the platform MUST reject the request before creating a job + +### Requirement: Frontend config writes use approval APIs +The frontend SHALL use platform config preview and approval APIs for config writes. + +#### Scenario: User previews and approves config write +- **WHEN** a user edits config, previews the diff, and confirms approval +- **THEN** ServerDetailPage MUST call the approval API and render the returned platform job state + +#### Scenario: Frontend avoids generic write job creation +- **WHEN** a config write is initiated from manual edit or AI suggestion +- **THEN** the frontend MUST NOT create a generic `config.write` job directly through `POST /jobs` diff --git a/openspec/changes/implement-config-write-and-file-dispatch/tasks.md b/openspec/changes/implement-config-write-and-file-dispatch/tasks.md new file mode 100644 index 0000000..51c90c0 --- /dev/null +++ b/openspec/changes/implement-config-write-and-file-dispatch/tasks.md @@ -0,0 +1,58 @@ +## 1. Config Diff Review Contracts + +- [x] 1.1 Add domain contracts for config diff review, proposed content, approval status, and dispatch metadata. +- [x] 1.2 Add DTO contracts for config diff preview, approval, rejection, and dispatch responses. +- [x] 1.3 Add validators for bounded config size, allowed file keys, expected config version, and diff content safety. +- [x] 1.4 Add service methods for previewing and approving config writes without exposing host paths. + +## 2. File Operation Dispatch Contracts + +- [x] 2.1 Add domain/DTO contracts for scoped file read/write requests. +- [x] 2.2 Map file operations to platform job capabilities such as `config.write`, `files.read`, and `files.write`. +- [x] 2.3 Enforce plugin/server permissions and role-scoped server access before dispatch. +- [x] 2.4 Ensure dispatch payloads use logical file keys or artifact/input refs, not raw host paths. + +## 3. Backend API Surface + +- [x] 3.1 Implement config diff preview route for a server instance. +- [x] 3.2 Implement config write approval route that queues a bounded run job. +- [x] 3.3 Implement file operation dispatch route for scoped plugin/platform file jobs. +- [x] 3.4 Update route/protocol documentation to mark config and file dispatch implemented. + +## 4. Frontend Integration + +- [x] 4.1 Update ServerDetailPage config write flow to call config diff preview API. +- [x] 4.2 Update confirmation flow to call config write approval API instead of creating a generic job directly. +- [x] 4.3 Keep explicit second confirmation before dispatching any config write. +- [x] 4.4 Remove local-only config mutation after job dispatch; show pending platform job state instead. + +## 5. Run Integration Prep + +- [x] 5.1 Extend run protocol job payloads to carry scoped config/file input refs. +- [x] 5.2 Add run-side validation for allowed logical paths and bounded write payloads. +- [x] 5.3 Add tests proving raw host paths and credentials are rejected. + +## 6. Verification + +- [x] 6.1 Add platform tests for preview, approval, stale config version, unauthorized server access, and unsafe paths. +- [x] 6.2 Add frontend tests for diff preview, approval, failure, and no local mutation on dispatch. +- [x] 6.3 Run platform, run, and platform_web test/build commands and record evidence. +- [x] 6.4 Run browser walkthrough for config diff and write approval. +- [x] 6.5 Run `scripts/check-structure.sh` and record evidence. +- [x] 6.6 Run `openspec validate implement-config-write-and-file-dispatch --strict` and record evidence. + +## Evidence + +- 2026-07-06: `cd platform && go test ./domain ./dto ./validator ./service ./api ./model` passed after adding config diff/write contracts, validators, service methods, and API handlers. +- 2026-07-06: `cd run && go test ./protocol` passed after adding scoped job target/input refs and run protocol validation tests for raw host paths and raw credential refs. +- 2026-07-06: Updated `platform/api/routes.md`, `platform/protocol/server-lifecycle.md`, `run/protocol/job.md`, and `platform_web/api/contracts.md` to document implemented config diff/approval and scoped file dispatch routes/protocol payloads. +- 2026-07-06: `cd platform && go test ./service ./api -run 'TestCoreServiceConfigWriteAndFileDispatchAreScoped|TestConfigWriteAndFileDispatchAPIAreScoped'` passed, covering preview, approval, stale config version, unauthorized access, unsafe keys, and scoped file dispatch. +- 2026-07-06: `cd platform_web && npm run typecheck` passed after adding config diff/approval/file dispatch API types and client methods. +- 2026-07-06: `cd platform_web && npm test -- --run api/client.test.ts pages/ServerDetailPage.test.tsx` passed, covering preview/approval client requests, preview failure surfacing, platform diff mapping, no generic `config.write` job creation, and no local config mutation after approval dispatch. +- 2026-07-06: `cd platform && go test ./...` passed. +- 2026-07-06: `cd run && go test ./...` passed. +- 2026-07-06: `cd platform_web && npm test` passed with 11 files / 40 tests. +- 2026-07-06: `cd platform_web && npm run build` passed. +- 2026-07-06: Browser walkthrough passed using local platform `127.0.0.1:18090`, Vite `127.0.0.1:5177`, and headless Chrome: logged in, opened `#/servers/server-walkthrough`, edited config, previewed the platform diff, confirmed approval dispatch, saw the returned `config.write` job badge, and verified no `/Users/`, `unix://`, bearer token, raw key, password, or billing fragments were visible. +- 2026-07-06: `scripts/check-structure.sh` passed. +- 2026-07-06: `openspec validate implement-config-write-and-file-dispatch --strict` reported `Change 'implement-config-write-and-file-dispatch' is valid`; PostHog telemetry flush failed due restricted DNS and did not affect validation. diff --git a/openspec/changes/implement-durable-platform-storage/.openspec.yaml b/openspec/changes/implement-durable-platform-storage/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/implement-durable-platform-storage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/implement-durable-platform-storage/design.md b/openspec/changes/implement-durable-platform-storage/design.md new file mode 100644 index 0000000..6ff8d9a --- /dev/null +++ b/openspec/changes/implement-durable-platform-storage/design.md @@ -0,0 +1,62 @@ +## Overview + +The platform needs two storage shapes: + +- **Metadata store** for users, AI providers, plugins, server instances, run endpoints, jobs, artifacts, log stream metadata, and audit events. +- **Log body store** for high-volume append/query log entries. + +The first can be MySQL or another transactional database. The second should not be a row-per-line table for large installations. This change implements a local durable metadata store and a segmented log body store using only the Go standard library, with interfaces that can later gain MySQL/PostgreSQL/ClickHouse/Loki adapters. + +## Metadata Storage + +Current repository interfaces stay unchanged. A new file-backed store wraps the existing in-memory store and persists a snapshot after successful create/update operations. It is suitable for local and single-node deployments, tests, and development environments where no external database is available. + +Configuration: + +- `PLATFORM_STORAGE_BACKEND=memory|file` +- `PLATFORM_DATA_DIR=` +- `PLATFORM_METADATA_PATH=` + +Default startup uses file-backed storage under `.platform-data/metadata.json`, so data survives restarts. Tests can continue using `repo.NewMemoryStore()`. + +The file store is not positioned as a multi-writer clustered database. A future MySQL adapter should implement the same `repo.Store` interfaces and keep database table models in `platform/model`. + +## Log Body Storage + +`CoreService` currently stores log bodies in memory maps. This change introduces a `LogBodyStore` service boundary: + +- `AppendBatch(streamID, batch)` for validated, contiguous batch appends. +- `GetBatch(streamID, firstSeq)` for duplicate/conflict detection. +- `Query(streamID, afterSeq, limit)` for bounded cursor reads. + +The local durable implementation writes JSONL segment files: + +- Directory: `//` +- Segment naming: `segment-00000000000000000001.jsonl` using the first sequence in that segment. +- Each line is one `LogEntry`, keeping append and recovery simple. +- A small in-memory index is rebuilt on startup from segment files. + +This keeps control, jobs, and log upload channel semantics unchanged. It also avoids turning MySQL into a log body sink. For large production deployments, the same boundary can route log bodies to ClickHouse/Loki/OpenSearch/object storage and keep MySQL for metadata, stream state, retention policy, and query indexes. + +## Security And Boundaries + +- Storage paths are platform-owned configuration; they are never returned to plugins or frontend responses. +- Log query APIs still return bounded entries only. +- Run session tokens and bearer sessions remain service-side only. +- No raw database credentials are exposed through DTOs. + +## Failure Modes + +- File store creation fails fast on invalid or unwritable paths. +- Snapshot writes use temp-file then rename to avoid partial metadata files. +- Log segment writes return service errors instead of acknowledging batches that were not durably written. +- Existing in-memory tests remain valid; new tests cover restart/reload behavior for file metadata and log body stores. + +## Validation + +- Unit tests for file-backed metadata persistence across store reloads. +- Unit tests for segmented log store append, duplicate lookup, cursor query, and reload. +- API/service tests for default router admin persistence and log ingest behavior. +- `go test ./...` in `platform`. +- `scripts/check-structure.sh`. +- `openspec validate implement-durable-platform-storage --strict`. diff --git a/openspec/changes/implement-durable-platform-storage/proposal.md b/openspec/changes/implement-durable-platform-storage/proposal.md new file mode 100644 index 0000000..9a2d5d3 --- /dev/null +++ b/openspec/changes/implement-durable-platform-storage/proposal.md @@ -0,0 +1,26 @@ +## Why + +The platform currently uses in-memory repositories and in-memory log buffers, so users, sessions, jobs, server state, artifacts, and logs disappear when the platform process restarts. Hundreds or thousands of game servers also make row-per-log-line relational storage a poor default: platform metadata needs a durable database, while log bodies need append-friendly segmented storage with bounded cursor reads. + +## What Changes + +- Add configurable durable platform storage so the default local platform process no longer depends on volatile in-memory repositories. +- Add a local file-backed metadata store for immediate durable operation without external database credentials. +- Add a segmented log body store that persists log entries by stream and segment on disk, keeping log query semantics and duplicate detection intact. +- Update platform startup configuration to choose memory or durable local storage with explicit data/log paths. +- Document that MySQL/PostgreSQL-style databases are appropriate for platform metadata and log indexes, while high-volume log bodies should use segmented object/file storage or a purpose-built log backend such as ClickHouse/Loki/OpenSearch in later changes. + +## Capabilities + +### New Capabilities +- `durable-platform-storage`: Platform metadata and log bodies survive process restarts through configurable durable storage backends. + +### Modified Capabilities +- None. + +## Impact + +- Affected backend areas: `platform/config`, `platform/repo`, `platform/service`, `platform/api`, `platform/cmd/platform`, `platform/protocol`. +- No frontend page changes. +- No external paid services, real database credentials, or network downloads are required for this implementation. +- Future MySQL or analytics log backends can be added behind the new storage boundaries without exposing raw host paths, credentials, or direct sockets to plugins or the frontend. diff --git a/openspec/changes/implement-durable-platform-storage/specs/durable-platform-storage/spec.md b/openspec/changes/implement-durable-platform-storage/specs/durable-platform-storage/spec.md new file mode 100644 index 0000000..4ee61a2 --- /dev/null +++ b/openspec/changes/implement-durable-platform-storage/specs/durable-platform-storage/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Durable Metadata Store + +The platform SHALL support a configurable durable metadata store so platform resources survive process restarts without relying on in-memory maps. + +#### Scenario: File-backed platform metadata survives restart + +- **GIVEN** the platform is configured with the file storage backend and a metadata file path +- **WHEN** users, server instances, jobs, artifacts, log streams, or other platform resources are created or updated +- **THEN** the metadata SHALL be persisted to disk +- **AND** recreating the store from the same file SHALL restore those resources. + +#### Scenario: Memory backend remains available for tests + +- **GIVEN** tests or development code explicitly request the memory backend +- **WHEN** the platform creates a store +- **THEN** it SHALL use the existing in-memory repository behavior without filesystem persistence. + +#### Scenario: Startup seeds local admin once + +- **GIVEN** durable metadata already contains the local platform administrator +- **WHEN** the platform starts again +- **THEN** startup seeding SHALL be idempotent and SHALL NOT overwrite the existing user. + +### Requirement: Segmented Log Body Storage + +The platform SHALL store high-volume log bodies in an append-friendly segmented log store instead of relying on in-memory maps or a row-per-log-line metadata database. + +#### Scenario: Log ingest persists entries durably + +- **GIVEN** a valid contiguous log batch for an existing stream +- **WHEN** the platform accepts the batch +- **THEN** the log body entries SHALL be written to the configured log body store before the stream latest sequence is advanced. + +#### Scenario: Duplicate batch detection survives reload + +- **GIVEN** a log batch has already been accepted and the platform restarts +- **WHEN** the same batch is submitted again +- **THEN** the platform SHALL return a duplicate acknowledgement when the first sequence, last sequence, and checksum match. + +#### Scenario: Cursor query stays bounded + +- **GIVEN** a log stream has many persisted entries +- **WHEN** a client queries after a sequence with a limit +- **THEN** the platform SHALL return at most the requested bounded number of entries ordered by sequence +- **AND** SHALL include next and latest sequence metadata. + +### Requirement: Storage Backend Boundaries + +The platform SHALL separate metadata storage from log body storage and SHALL NOT expose storage paths, database credentials, run sockets, or raw credentials through API responses. + +#### Scenario: Storage details remain platform-owned + +- **GIVEN** plugins or frontend clients request platform resources, logs, artifacts, or bridge actions +- **WHEN** responses are generated +- **THEN** they SHALL include only bounded DTO data and SHALL NOT include filesystem paths, database DSNs, direct storage backend URLs, run session tokens, or bearer tokens. diff --git a/openspec/changes/implement-durable-platform-storage/tasks.md b/openspec/changes/implement-durable-platform-storage/tasks.md new file mode 100644 index 0000000..e92110b --- /dev/null +++ b/openspec/changes/implement-durable-platform-storage/tasks.md @@ -0,0 +1,25 @@ +## 1. OpenSpec Artifacts + +- [x] 1.1 Create proposal, design, spec, and tasks for durable metadata and log storage. +- [x] 1.2 Validate the change with `openspec validate implement-durable-platform-storage --strict`. + +## 2. Durable Metadata Store + +- [x] 2.1 Add platform storage configuration for backend, metadata path, data directory, and log directory. +- [x] 2.2 Implement a file-backed metadata store that persists repository snapshots and reloads them. +- [x] 2.3 Wire platform startup to use the configured durable store by default while keeping memory store available. +- [x] 2.4 Add tests proving metadata persistence and default admin idempotency across store reloads. + +## 3. Segmented Log Body Store + +- [x] 3.1 Add a `LogBodyStore` service boundary with append, duplicate lookup, and cursor query operations. +- [x] 3.2 Implement an in-memory log body store for tests and a file-segment log body store for durable startup. +- [x] 3.3 Refactor log ingest/query service code to use `LogBodyStore` and persist before advancing stream metadata. +- [x] 3.4 Add tests for duplicate acknowledgement and cursor query after log store reload. + +## 4. Documentation And Verification + +- [x] 4.1 Document storage backend choices and log storage guidance in platform protocol/API docs. +- [x] 4.2 Run `cd platform && go test ./...`. +- [x] 4.3 Run `scripts/check-structure.sh`. +- [x] 4.4 Run `openspec validate implement-durable-platform-storage --strict`. diff --git a/openspec/changes/implement-local-debug-workspace/.openspec.yaml b/openspec/changes/implement-local-debug-workspace/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/implement-local-debug-workspace/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/implement-local-debug-workspace/design.md b/openspec/changes/implement-local-debug-workspace/design.md new file mode 100644 index 0000000..7ace601 --- /dev/null +++ b/openspec/changes/implement-local-debug-workspace/design.md @@ -0,0 +1,66 @@ +## Context + +The platform now has real local capabilities across `platform/`, `run/`, `platform_web/`, and `plugins/`: platform can serve API-backed metadata, run can register and execute lifecycle jobs, platform_web can operate against platform APIs, and the local proof plugin can drive server lifecycle through platform-mediated contracts. The remaining local-development gap is repeatability. Contributors currently need to reconstruct proof commands, temporary data roots, setup payloads, frontend proxy settings, browser walkthrough steps, and cleanup procedures from scattered task evidence. + +This change creates a first-party local debug workspace. It should make the real stack easy to boot, reset, inspect, and verify without introducing production deployment scope or bypassing platform mediation. + +## Goals / Non-Goals + +**Goals:** +- Provide one documented and/or scripted local workflow for platform, run worker, platform_web, and the dev game plugin path. +- Use explicit local ports, environment variables, data roots, spool roots, log roots, and reset commands. +- Include smoke checks for platform health, run registration/heartbeat, plugin registration/installation, server lifecycle, logs/artifact references, and API-backed frontend behavior. +- Include browser walkthrough requirements for 首页、服务器管理、插件市场、用户管理、AI 提供商管理 and one plugin lifecycle path. +- Keep all plugin/browser operations platform-mediated and prove no raw host paths, credentials, direct sockets, run tokens, raw AI keys, or plugin-owned transport details are visible. + +**Non-Goals:** +- Do not add cloud deployment, billing, host sales, agent-provider flows, or unrelated SaaS marketplace behavior. +- Do not add Docker-only requirements; local workflow may coexist with Docker but must be runnable with repository commands. +- Do not replace the existing magical-girl crystal-moonlight platform_web style. +- Do not make plugins connect directly to run or expose run sockets/tokens to platform_web. +- Do not implement the later automated browser acceptance suite here; this change may define a manual/semiautomated walkthrough that a later change can automate. + +## Decisions + +1. Use repository-owned local scripts/docs instead of only OpenSpec task notes. + + The workflow must survive after the implementation chat. A script plus a short markdown guide is preferable to evidence-only commands because contributors need a stable entry point. The script should print the exact platform, run, and frontend commands and write logs to predictable local files. + + Alternative considered: keep commands solely in `tasks.md`. That satisfies the change once but does not improve day-to-day local debugging. + +2. Keep local data under a disposable workspace root. + + Platform metadata, platform log bodies, run workspace files, and run spool files should live under an explicit root such as `.local-debug/` or `/private/tmp/browser-local-debug-workspace`. Reset must remove only that root and should never delete broad user directories. + + Alternative considered: reuse `.platform-data` and `.run-workspace` defaults. That is convenient but makes reset behavior less auditable and can mix unrelated local experiments. + +3. Use the existing API-backed platform and Vite proxy shape. + + The frontend should run with `PLATFORM_API_PROXY=http://127.0.0.1:` and `VITE_PLATFORM_API_BASE_URL=/api/v1`, proving browser calls go through platform-owned APIs. Browser/plugin pages must not receive direct run URLs or run credentials. + + Alternative considered: have platform_web point directly at a run worker or plugin dev server. That violates the architecture boundaries and is explicitly out of scope. + +4. Seed only safe local fixtures. + + The local workflow may create a dev plugin, one or more server instances, and lifecycle action templates under the scoped run workspace. Fixtures must use logical IDs and harmless commands. They must not require real game binaries, raw credentials, shell launchers, direct sockets, absolute host paths in API payloads, or raw AI keys. + + Alternative considered: require a real game server installation. That would make the debug workflow too heavy and environment-specific for this stream step. + +## Risks / Trade-offs + +- Port collisions -> Provide configurable env vars and print the resolved ports before starting services. +- Background process cleanup is brittle -> Prefer explicit log/PID files and a stop/reset command over hidden shell process management. +- Smoke setup can drift from APIs -> Implement smoke using the same public/local API routes and plugin manifest validation used elsewhere. +- Browser walkthrough remains manual -> Record exact pages, expected API-backed signals, and forbidden-fragment scan requirements so the later acceptance-suite change can automate it. +- Reset can become dangerous -> Scope cleanup to the local debug root and document what is removed before removing it. + +## Migration Plan + +1. Add local debug documentation and scripts or command wrappers under the appropriate repository location. +2. Add or update tests/smoke checks for generated commands, safe reset scope, and API-backed local fixture setup. +3. Run the documented local stack and browser/API walkthrough. +4. Record evidence in `tasks.md`, run structure checks and strict OpenSpec validation, and advance the stream pointer. + +## Open Questions + +- None currently. If implementation reveals an existing script location convention, follow it rather than inventing a parallel tool layout. diff --git a/openspec/changes/implement-local-debug-workspace/proposal.md b/openspec/changes/implement-local-debug-workspace/proposal.md new file mode 100644 index 0000000..832cc64 --- /dev/null +++ b/openspec/changes/implement-local-debug-workspace/proposal.md @@ -0,0 +1,26 @@ +## Why + +The architecture stream now has real platform, run, platform_web, and plugin lifecycle proof, but reproducing that stack still requires ad hoc commands, temporary paths, manual fixture setup, and scattered evidence. A first-party local debug workspace is needed so contributors can boot the same API-backed workflow repeatedly, inspect logs clearly, reset state safely, and prove the console is not falling back to demo-only data. + +## What Changes + +- Add a local debug workflow that starts platform, run worker, platform_web, and one local game management plugin path with documented ports, environment variables, data roots, and log locations. +- Add reset and cleanup steps for local platform metadata, log body storage, run workspace state, run spool state, and frontend dev-server state. +- Add smoke verification that proves platform health, run registration/heartbeat, plugin registration/installation, server instance lifecycle, log/artifact references, and browser/API-backed console navigation. +- Add safety checks proving browser/plugin page surfaces do not expose host paths, raw credentials, run session tokens, direct sockets, raw AI provider keys, or plugin-owned transport details. +- Add concrete commands and browser walkthrough requirements so future implementation chats can verify the local debug workflow without inventing a new proof path. +- No breaking changes are expected; this change standardizes local orchestration and verification around existing roots. + +## Capabilities + +### New Capabilities +- `local-debug-workspace`: Defines the local developer workflow for running platform, run, platform_web, and one game management plugin together with clear logs, reset steps, API-backed smoke checks, and browser walkthrough evidence. + +### Modified Capabilities +- None. + +## Impact + +- Affected roots: `platform/`, `run/`, `platform_web/`, `plugins/`, `scripts/`, and documentation. +- Expected implementation areas: local workflow scripts or docs, fixture/setup helpers, reset commands, smoke verification commands, browser walkthrough checklist, and OpenSpec task evidence. +- Validation impact: requires platform/run/plugin/frontend command checks where relevant, `scripts/check-structure.sh`, `openspec validate implement-local-debug-workspace --strict`, and a browser walkthrough for the API-backed console path. diff --git a/openspec/changes/implement-local-debug-workspace/specs/local-debug-workspace/spec.md b/openspec/changes/implement-local-debug-workspace/specs/local-debug-workspace/spec.md new file mode 100644 index 0000000..bdccada --- /dev/null +++ b/openspec/changes/implement-local-debug-workspace/specs/local-debug-workspace/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: Local debug workspace starts the real stack +The repository SHALL provide a local debug workflow that starts platform, run worker, platform_web, and one local game management plugin path using explicit local configuration. + +#### Scenario: Developer starts local debug services +- **WHEN** a developer follows the local debug workflow +- **THEN** the workflow MUST provide concrete commands or scripts for platform, run worker, and platform_web with explicit local ports, data directories, log directories, run workspace root, run spool root, frontend API proxy, and Vite API base URL + +#### Scenario: Local debug uses API-backed frontend +- **WHEN** platform_web is started by the local debug workflow +- **THEN** it MUST use platform-owned API routes through the configured proxy and MUST NOT require platform_web to connect directly to run or plugin-owned transports + +### Requirement: Local debug workspace can be reset safely +The repository SHALL provide reset or cleanup steps that remove only local debug workspace state and leave unrelated user files, repository source files, and non-debug service data untouched. + +#### Scenario: Developer resets local debug state +- **WHEN** a developer runs the documented reset path +- **THEN** platform metadata, platform log bodies, run workspace files, run spool files, local fixture state, and local service logs for the debug workspace MUST be removed or reinitialized only within the documented local debug root + +#### Scenario: Reset scope is auditable +- **WHEN** a contributor inspects the reset command or script +- **THEN** the command MUST show the exact local debug paths it removes and MUST NOT remove broad directories such as the repository root, home directory, `/Users`, `/private`, `/tmp`, or unrelated service data + +### Requirement: Local debug workflow seeds a safe game plugin lifecycle fixture +The local debug workflow SHALL seed or document a safe local game plugin fixture that can create and manage at least one server instance through platform-mediated lifecycle APIs. + +#### Scenario: Developer prepares lifecycle fixture +- **WHEN** the local debug setup creates plugin/server lifecycle data +- **THEN** it MUST register or reuse a local game plugin manifest, install the plugin through platform data, create at least one logical server instance, and use scoped run workspace lifecycle templates with harmless commands + +#### Scenario: Fixture preserves safety boundaries +- **WHEN** plugin lifecycle data is visible through platform APIs or platform_web +- **THEN** responses MUST NOT expose raw host paths, raw credentials, run session tokens, direct sockets, raw AI provider keys, shell launchers, or plugin-owned transport details + +### Requirement: Local debug workflow includes smoke verification +The local debug workflow SHALL include concrete smoke commands that prove the stack is healthy and API-backed before browser acceptance is claimed. + +#### Scenario: Smoke commands verify backend state +- **WHEN** smoke verification runs +- **THEN** it MUST check platform health, run endpoint registration or heartbeat, plugin registration/installation, server instance lifecycle state, queued or completed lifecycle jobs, and safe log/artifact references through platform APIs + +#### Scenario: Smoke commands reject demo-only fallback +- **WHEN** smoke verification inspects frontend or API state +- **THEN** it MUST prove platform_web is using the configured platform API and MUST flag local/demo fallback data as a failed smoke condition + +### Requirement: Browser walkthrough verifies first-party areas and safety +The local debug workflow SHALL include a browser walkthrough that verifies the API-backed console across required first-party areas and one plugin lifecycle path. + +#### Scenario: Browser walkthrough opens first-party areas +- **WHEN** the browser walkthrough runs +- **THEN** it MUST open 首页、服务器管理、插件市场、用户管理、AI 提供商管理 with an API-backed user session and confirm the pages are not local fallback views + +#### Scenario: Browser walkthrough verifies plugin lifecycle path +- **WHEN** the browser walkthrough operates a local debug server instance +- **THEN** it MUST use platform_web to inspect plugin/server details, trigger or verify a platform-mediated lifecycle action, observe operation history or job state, and confirm sibling/log/artifact references remain safe + +#### Scenario: Browser walkthrough scans visible sensitive fragments +- **WHEN** the browser walkthrough inspects visible page content +- **THEN** it MUST fail if `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, or plugin-owned transport details are visible + +### Requirement: Local debug workflow is documented and verified +The change SHALL include documentation, tests or smoke checks, and final verification commands proving the local debug workflow is reproducible. + +#### Scenario: Verification commands run +- **WHEN** the change is complete +- **THEN** the documented test/build/smoke commands, `scripts/check-structure.sh`, and `openspec validate implement-local-debug-workspace --strict` MUST pass, and browser walkthrough evidence MUST be recorded if platform_web pages are touched or verified diff --git a/openspec/changes/implement-local-debug-workspace/tasks.md b/openspec/changes/implement-local-debug-workspace/tasks.md new file mode 100644 index 0000000..97326a5 --- /dev/null +++ b/openspec/changes/implement-local-debug-workspace/tasks.md @@ -0,0 +1,83 @@ +## 1. Local Debug Workflow Definition + +- [x] 1.1 Add or update repository documentation for the local debug workspace, including startup, ports, env vars, data roots, log files, reset, smoke verification, and browser walkthrough. +- [x] 1.2 Add scripts or command wrappers for starting platform, run worker, and platform_web with explicit local debug configuration. +- [x] 1.3 Add a reset/cleanup path that removes only the documented local debug root and prints or documents exactly what it deletes. +- [x] 1.4 Ensure the workflow does not require Docker-only infrastructure, external cloud services, real game binaries, raw credentials, raw AI keys, direct sockets, or browser/plugin direct access to run. + +## 2. Safe Plugin and Server Fixture + +- [x] 2.1 Add or document setup for one local game management plugin fixture using the existing dev plugin manifest or a safe local proof plugin. +- [x] 2.2 Add setup steps that create or reuse at least one server instance through platform-owned data/API paths and scoped run workspace lifecycle templates. +- [x] 2.3 Ensure fixture commands are harmless and bounded, and that API/platform_web responses expose only logical IDs, platform routes, job refs, log refs, artifact refs, and safe metadata. +- [x] 2.4 Add tests or smoke checks that reject raw host paths, raw credentials, run session tokens, direct sockets, raw AI provider keys, shell launchers, and plugin-owned transport details in fixture outputs. + +## 3. Smoke Verification Commands + +- [x] 3.1 Add concrete smoke commands for platform health, run endpoint registration/heartbeat, plugin registration/installation, server instance lifecycle state, lifecycle jobs, and log/artifact references. +- [x] 3.2 Add smoke verification that platform_web is configured with `PLATFORM_API_PROXY` and `VITE_PLATFORM_API_BASE_URL=/api/v1`, and that demo/local fallback data is treated as a failure. +- [x] 3.3 Run the documented backend smoke commands and record evidence. +- [x] 3.4 Run relevant unit/build checks for touched roots, such as `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1`, `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1`, `cd plugins && npm run typecheck && npm run test && npm run validate:manifest`, and `cd platform_web && npm run typecheck && npm test && npm run build` as applicable, then record evidence. + +## 4. Browser Walkthrough + +- [x] 4.1 Start the documented local debug stack and log the exact platform, run worker, and platform_web commands used. +- [x] 4.2 In a browser with an API-backed user session, open 首页、服务器管理、插件市场、用户管理、AI 提供商管理 and confirm no page is using local fallback data. +- [x] 4.3 Use the browser to inspect a local debug plugin/server lifecycle path, including server detail, plugin detail or marketplace data, operation history, and log/artifact references. +- [x] 4.4 Scan visible browser content for forbidden fragments: `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, and plugin-owned transport details. +- [x] 4.5 Record browser walkthrough evidence in this tasks file only after the walkthrough has actually run. + +## 5. Final Verification and Stream Handoff + +- [x] 5.1 Record implementation evidence in this tasks file only after each command, smoke check, or browser walkthrough has actually run. +- [x] 5.2 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.3 Run `openspec validate implement-local-debug-workspace --strict` and record evidence. +- [x] 5.4 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` to mark `implement-local-debug-workspace` complete only after evidence exists and move the next queue item to active. +- [x] 5.5 Update `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` with the next implementation/generator handoff after this change closes. + +## Evidence + +- Added local debug documentation and repository pointers: + - `docs/local-debug-workspace.md` documents startup, ports, env vars, data roots, log files, reset scope, smoke verification, browser walkthrough, account credentials, and manual commands. + - `README.md` points contributors to `scripts/local-debug-start.sh`, `scripts/local-debug-smoke.sh`, `LOCAL_DEBUG_SELF_START=true`, and the local debug guide. + - `plugins/docs/local-development.md` documents the dev plugin fixture and safe API-backed proof path. +- Added local debug scripts: + - `scripts/local-debug-env.sh` centralizes `LOCAL_DEBUG_*`, platform, run, frontend, and forbidden-fragment settings. + - `scripts/local-debug-start.sh` starts platform, run worker, and platform_web with explicit logs, PIDs, ports, data roots, run workspace root, run spool root, Vite proxy, and fallback disabled. + - `scripts/local-debug-stop.sh` stops only tracked local-debug PIDs. + - `scripts/local-debug-reset.sh` refuses unexpected roots and deletes only `/.local-debug`, `/private/tmp/browser-local-debug-*`, or `/tmp/browser-local-debug-*`. + - `scripts/local-debug-smoke.sh` verifies platform health, API login, dev plugin manifest validation, plugin registration, run heartbeat, server lifecycle workflow creation, jobs, log streams, artifacts, marketplace refs, frontend proxy env, fallback disabled, and forbidden-fragment absence. +- Script syntax check passed: + - `bash -n scripts/local-debug-env.sh scripts/local-debug-start.sh scripts/local-debug-stop.sh scripts/local-debug-reset.sh scripts/local-debug-smoke.sh` +- Frontend verification passed: + - `cd platform_web && npm run typecheck` + - `cd platform_web && npm test` -> 11 test files, 49 tests passed. + - `cd platform_web && npm run build` -> Vite production build completed. +- Plugin verification passed: + - `cd plugins && npm run typecheck` + - `cd plugins && npm run test` -> 1 test file, 11 tests passed. + - `cd plugins && npm run validate:manifest` initially failed in the sandbox because `tsx` could not create an IPC pipe (`listen EPERM .../tsx-501/...pipe`); rerunning with elevated sandbox permissions passed and validated `examples/dev-game-plugin/manifest.json`. +- Backend verification passed: + - `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` passed for platform API, config, domain, DTO, model, repo, service, and validator packages. + - `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` initially failed in the sandbox because `httptest` could not bind local listeners; rerunning with elevated sandbox permissions passed for run API, config, protocol, runtime, and spool packages. +- Self-start local debug smoke passed: + - `LOCAL_DEBUG_PLATFORM_PORT=18187 LOCAL_DEBUG_WEB_PORT=5181 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-final LOCAL_DEBUG_SELF_START=true scripts/local-debug-smoke.sh` + - Evidence directory: `/private/tmp/browser-local-debug-final/smoke` + - Output confirmed platform health, dev plugin manifest validation, plugin API registration, run endpoint heartbeat, server lifecycle workflow creation, job/log/artifact/marketplace reference checks, and forbidden-fragment rejection. +- Browser walkthrough stack and seed smoke passed: + - Reset: `LOCAL_DEBUG_PLATFORM_PORT=18188 LOCAL_DEBUG_WEB_PORT=5182 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-walkthrough scripts/local-debug-reset.sh` + - Stack command: `/bin/zsh -lc 'LOCAL_DEBUG_PLATFORM_PORT=18188 LOCAL_DEBUG_WEB_PORT=5182 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-walkthrough scripts/local-debug-reset.sh; LOCAL_DEBUG_PLATFORM_PORT=18188 LOCAL_DEBUG_WEB_PORT=5182 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-walkthrough scripts/local-debug-start.sh; sleep 1200'` + - Seed smoke: `LOCAL_DEBUG_PLATFORM_PORT=18188 LOCAL_DEBUG_WEB_PORT=5182 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-walkthrough scripts/local-debug-smoke.sh` + - Evidence directory: `/private/tmp/browser-local-debug-walkthrough/smoke` +- Browser walkthrough evidence: + - Logged in at `http://127.0.0.1:5182` with `operator.local@example.test` / `operator-local`; login landed on `#/home` with API-backed platform data. + - Opened 首页 `#/home`: showed `平台概览`, `数据已加载`, `game.example:1 个实例`, and `运行节点 1`; no fallback/demo text and no forbidden fragments. + - Opened 服务器管理 `#/servers`: showed `Local Debug Example Server` / `server-local-debug`; no fallback/demo text and no forbidden fragments. + - Opened 插件市场 `#/plugins`: showed `game.example`, `artifact://manifests/game.example/0.1.0`, `process.install`, `process.start`, `process.stop`, `server.instances.read`, `jobs.dispatch`, `logs.query`, and `artifacts.open`; no fallback/demo text and no forbidden fragments. + - Opened 用户管理 `#/users`: showed `operator.local@example.test`, `账号 API 已连接`, and local admin metadata; no fallback/demo text and no forbidden fragments. + - Opened AI 提供商管理 `#/aiProviders`: showed API-backed provider rows with redacted secret refs (`secret://providers/openai`, `env://OLLAMA_API_KEY`); no fallback/demo text and no forbidden fragments. + - Opened server detail `#/servers/server-local-debug`: showed `Local Debug Example Server`, `game.example@0.1.0`, `run-local-debug`, lifecycle buttons, `日志`, `配置`, `插件控制`, `AI 助手`, and `操作历史`; no fallback/demo text and no forbidden fragments. + - The browser DOM snapshot helper failed with `incrementalAriaSnapshot is not a function`, so the walkthrough used read-only page evaluation to inspect visible text, buttons, URLs, and forbidden fragments. +- Final verification commands: + - `scripts/check-structure.sh` passed after implementation evidence was recorded. + - `openspec validate implement-local-debug-workspace --strict` passed after implementation evidence and stream handoff were updated; PostHog telemetry DNS errors, if emitted after success, do not affect the validation result. diff --git a/openspec/changes/implement-log-ingest-pipeline/.openspec.yaml b/openspec/changes/implement-log-ingest-pipeline/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/implement-log-ingest-pipeline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/implement-log-ingest-pipeline/design.md b/openspec/changes/implement-log-ingest-pipeline/design.md new file mode 100644 index 0000000..76aa406 --- /dev/null +++ b/openspec/changes/implement-log-ingest-pipeline/design.md @@ -0,0 +1,81 @@ +## Context + +Run control and job lifecycle routes are implemented, but logs still exist only as metadata records. The architecture requires logs to be treated as durable historical data: run writes batches to a local spool before upload, platform acknowledges accepted sequence ranges, and artifacts must not block control, job, or log traffic. + +This change implements the first HTTP JSON log ingest path and an in-repository run spool abstraction. It keeps platform storage in memory and updates existing `LogStream` metadata because durable database/log backend selection is a later architecture decision. + +## Goals / Non-Goals + +**Goals:** + +- Define typed log ingest protocol payloads in `run/protocol` and matching platform DTO/domain contracts. +- Add platform log ingest API routes for batch upload and bounded stream cursor query. +- Validate run session continuity, stream identity, sequence ranges, checksums, and batch size. +- Track accepted log entries and latest acknowledged sequence in platform service state and existing `LogStream.LatestSeq`. +- Add a run-side local spool abstraction that persists unacknowledged batches to disk and removes acknowledged ranges only after platform ack. +- Extend `run/api.PlatformClient` with a typed log batch ingest method. +- Add tests for platform ack/query behavior, duplicate/out-of-order rejection, run spool retry retention, and client request/response behavior. + +**Non-Goals:** + +- No external log storage backends such as Loki, ClickHouse, OpenSearch, or Elasticsearch. +- No browser live tail, log websocket, AI log analysis windows, or frontend behavior. +- No compression codec implementation beyond typed metadata and checksum validation for the JSON payload. +- No artifact transfer, game client bridge, billing, cloud host sales, or direct plugin-to-run access. +- No raw host paths, raw credentials, direct sockets, or artifact chunks inside log payloads. + +## Decisions + +### Decision 1: HTTP JSON batch ingest first + +The initial ingest route uses `POST /api/v1/run/logs/batches` with typed JSON batches. This keeps the path testable, bounded, and independent from control, jobs, and artifacts. + +Alternative considered: streaming logs over the control or job channel. Rejected because logs are high-volume historical data and must not block heartbeat, claim/ack/result, or artifact traffic. + +### Decision 2: Platform validates contiguous sequence ranges + +Each batch carries `streamKey`, `firstSeq`, `lastSeq`, checksum, and entries. The platform accepts the next contiguous range, treats already-acknowledged duplicate batches as idempotent acks, and rejects sequence gaps or conflicting duplicates. + +Alternative considered: accepting any sequence order and sorting later. Rejected because retry/ack semantics need deterministic spool cleanup and missing ranges must be visible immediately. + +### Decision 3: Log body storage is in-memory for now + +The service stores accepted log entries in memory keyed by stream ID and updates existing `LogStream.LatestSeq`. This matches the current repository scope and lets later storage adapters replace the implementation behind service methods. + +Alternative considered: adding a local compressed segment storage backend now. Rejected because this change needs API semantics and run spool behavior first; backend choice remains open. + +### Decision 4: Run spool stores batches as JSON segment files + +The run-side spool writes one JSON file per unacknowledged batch in a caller-provided directory. Tests can inspect retry behavior without a daemon loop, and future uploaders can reuse the same abstraction. + +Alternative considered: purely in-memory spool. Rejected because the architecture requires local retention across temporary platform unavailability and restart. + +### Decision 5: Client stays transport-only + +`run/api.PlatformClient` will encode and decode log ingest requests and responses. Collector loops, file tailing, backpressure scheduling, and artifact priority throttling remain future runtime work. + +Alternative considered: implementing a background log uploader now. Rejected because that would expand scope beyond protocol, spool, and ack semantics. + +## Risks / Trade-offs + +- [Risk] In-memory platform log storage disappears on restart. Mitigation: keep storage behind `service.Core` and document this as early development behavior. +- [Risk] JSON spool files are not optimized for very large log volumes. Mitigation: enforce bounded batch sizes now; later changes can swap segment encoding without changing ack semantics. +- [Risk] Checksums only cover entries in this change. Mitigation: keep checksum metadata explicit and add compressed segment checksums when compression/chunking is implemented. +- [Risk] No background uploader means no automatic retry loop. Mitigation: tests cover retained batches and client upload behavior; scheduling remains a later runtime concern. + +## Migration Plan + +1. Add log protocol, DTO, domain, validation, and service contracts. +2. Add platform API handlers and tests for ingest and query. +3. Add run local spool implementation and tests. +4. Add run client method and tests. +5. Update protocol/route docs. +6. Verify with platform tests, run tests, structure check, and strict OpenSpec validation. + +Rollback before dependent changes is removal of the log ingest route/client/spool additions and this OpenSpec change. After artifact/server workflow changes depend on logs, rollback must use a new OpenSpec change. + +## Open Questions + +- Which production log body backend should be implemented first: local compressed segments, Loki, ClickHouse, OpenSearch, or Elasticsearch? +- What maximum batch size and compression settings should production use? +- How should browser live tail subscribe to stored logs without weakening durable ingest guarantees? diff --git a/openspec/changes/implement-log-ingest-pipeline/proposal.md b/openspec/changes/implement-log-ingest-pipeline/proposal.md new file mode 100644 index 0000000..31a0fd8 --- /dev/null +++ b/openspec/changes/implement-log-ingest-pipeline/proposal.md @@ -0,0 +1,29 @@ +## Why + +The run job channel can now execute lifecycle work, but server and process logs still have no durable ingest path. This change adds the first log pipeline so run can spool logs locally, upload bounded batches, and receive sequence acknowledgements without mixing log traffic into control, job, or artifact channels. + +## What Changes + +- Add typed run log ingest protocol payloads for log entries, batch ingest requests, batch acknowledgements, and stream cursors. +- Add platform API routes that accept durable log batches, validate stream identity and sequence continuity, acknowledge accepted ranges, and expose bounded query by stream cursor. +- Extend platform service behavior to append log batches to existing log stream metadata, track latest acknowledged sequence, and reject duplicate or out-of-order batches. +- Add a run-side local spool/WAL abstraction that stores unacknowledged batches and removes only acknowledged sequence ranges. +- Extend the run-side platform client with typed log batch ingest calls. +- Add platform service/API tests and run spool/client tests covering retry, acknowledgement, duplicate/out-of-order rejection, and query behavior. + +## Capabilities + +### New Capabilities + +- `log-ingest-pipeline`: Durable run-to-platform log batch ingest, acknowledgement, local spool retention, and stream cursor query workflow. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform/` and `run/` only. +- Adds Go protocol/DTO/domain/service/API/spool code and tests for log ingest. +- Updates run/platform protocol and route documentation. +- Does not implement artifact transfer, browser live tail, external log backends, AI log analysis, frontend pages, billing, cloud host sales, or direct plugin/run access. diff --git a/openspec/changes/implement-log-ingest-pipeline/specs/log-ingest-pipeline/spec.md b/openspec/changes/implement-log-ingest-pipeline/specs/log-ingest-pipeline/spec.md new file mode 100644 index 0000000..66ec5ff --- /dev/null +++ b/openspec/changes/implement-log-ingest-pipeline/specs/log-ingest-pipeline/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: Log ingest payloads are typed and bounded +The system SHALL define typed log ingest payloads for log entries, batch ingest requests, batch acknowledgements, and stream cursor queries without carrying artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data. + +#### Scenario: Log payloads are used +- **WHEN** run or platform code sends log ingest data +- **THEN** it MUST use named protocol/DTO types from dedicated protocol or DTO packages + +#### Scenario: Log batch stays bounded +- **WHEN** run uploads a log batch +- **THEN** the request MUST include run endpoint ID, session token, stream identity, sequence range, checksum, compression metadata, and bounded entries only + +### Requirement: Platform accepts durable log batches +The platform SHALL expose a log batch ingest endpoint that validates run session continuity, stream metadata, checksum, and sequence continuity before acknowledging accepted ranges. + +#### Scenario: Contiguous batch succeeds +- **WHEN** run uploads a valid batch whose first sequence follows the platform's latest acknowledged sequence for that stream +- **THEN** platform MUST store the entries, update latest acknowledged sequence, and return an accepted acknowledgement for the range + +#### Scenario: Duplicate acknowledged batch is retried +- **WHEN** run uploads a batch whose range is already fully acknowledged and checksum matches the stored range +- **THEN** platform MUST return an accepted idempotent acknowledgement without duplicating entries + +#### Scenario: Out-of-order batch is submitted +- **WHEN** run uploads a batch with a sequence gap or conflicting duplicate data +- **THEN** platform MUST return a JSON validation error and MUST NOT advance the acknowledged sequence + +### Requirement: Platform exposes bounded log stream query +The platform SHALL expose a bounded log query endpoint that returns entries for one stream after a cursor sequence and includes the next cursor. + +#### Scenario: Query returns entries after cursor +- **WHEN** a caller queries a stream after an acknowledged sequence +- **THEN** platform MUST return ordered entries after that cursor up to the requested limit and include the next cursor + +#### Scenario: Query target is missing +- **WHEN** a caller queries a missing stream +- **THEN** platform MUST return a JSON not found error + +### Requirement: Run spool retains unacknowledged batches +The run-side log spool SHALL persist unacknowledged batches locally and remove them only after platform acknowledgement covers their sequence range. + +#### Scenario: Platform upload fails +- **WHEN** a batch remains unacknowledged after an upload failure +- **THEN** the spool MUST retain the batch for retry + +#### Scenario: Platform acknowledges batch +- **WHEN** platform returns an acknowledgement covering a batch range +- **THEN** the spool MUST mark that range acknowledged and remove the batch from pending retry listing + +### Requirement: Run client uploads log batches +The run-side platform client SHALL provide a typed log batch ingest method that calls the platform log endpoint and decodes typed acknowledgement responses. + +#### Scenario: Run uploads log batch through client +- **WHEN** run code calls the log ingest client method +- **THEN** the client MUST send a JSON `POST` to `/api/v1/run/logs/batches` and decode the acknowledgement response + +#### Scenario: Platform rejects log batch +- **WHEN** the platform log ingest endpoint returns a non-success status +- **THEN** the run client MUST return an error and MUST NOT treat the batch as acknowledged + +### Requirement: Log ingest is documented separately from other channels +The run/platform route and protocol documentation SHALL identify implemented log ingest routes and explicitly keep control, job, artifact, and game client bridge transport separate. + +#### Scenario: Contributor inspects log docs +- **WHEN** a contributor opens run or platform protocol docs +- **THEN** the docs MUST show log batch ingest and query as implemented while artifact and game client channels remain separate + +### Requirement: Log ingest pipeline is verified +The change SHALL include platform service/API tests, run spool tests, run client tests, and retry/ack/query coverage. + +#### Scenario: Verification commands run +- **WHEN** the change is complete +- **THEN** `go test ./...` from `platform/`, `go test ./...` from `run/`, `scripts/check-structure.sh`, and `openspec validate implement-log-ingest-pipeline --strict` MUST pass diff --git a/openspec/changes/implement-log-ingest-pipeline/tasks.md b/openspec/changes/implement-log-ingest-pipeline/tasks.md new file mode 100644 index 0000000..9594591 --- /dev/null +++ b/openspec/changes/implement-log-ingest-pipeline/tasks.md @@ -0,0 +1,35 @@ +## 1. Log Contracts + +- [x] 1.1 Add typed run log ingest protocol payloads in `run/protocol` for entries, batch ingest, acknowledgements, and stream cursors. +- [x] 1.2 Add matching platform DTO/domain contracts and conversion helpers for log batch ingest and query. +- [x] 1.3 Add validation rules for bounded log batches, stream identity, sequence ranges, checksum, and query limits. + +## 2. Platform Log Ingest + +- [x] 2.1 Extend platform service behavior to ingest contiguous batches, acknowledge duplicates, reject out-of-order/conflicting batches, update `LogStream.LatestSeq`, and query entries after a cursor. +- [x] 2.2 Implement platform log ingest/query HTTP routes using named DTOs and service methods. +- [x] 2.3 Add platform service/API tests for accepted batches, duplicate ack, out-of-order rejection, missing stream, and cursor query. + +## 3. Run Log Spool And Client + +- [x] 3.1 Implement a run-side local spool abstraction that writes pending batches to disk, lists them for retry, and removes acknowledged ranges. +- [x] 3.2 Extend `run/api.PlatformClient` with a typed log batch ingest method. +- [x] 3.3 Add run spool/client tests for retry retention, acknowledgement cleanup, request path, JSON payload, response decoding, and platform error handling. + +## 4. Documentation + +- [x] 4.1 Update run and platform protocol/route documentation to mark log batch ingest/query implemented and keep control/job/artifact/game-client channels separate. + +## 5. Verification + +- [x] 5.1 Run `go test ./...` from `platform/` and record evidence. +- [x] 5.2 Run `go test ./...` from `run/` and record evidence. +- [x] 5.3 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.4 Run `openspec validate implement-log-ingest-pipeline --strict` and record evidence. + +## Evidence + +- 2026-07-03: `go test ./...` from `platform/` passed. +- 2026-07-03: `go test ./...` from `run/` passed. +- 2026-07-03: `scripts/check-structure.sh` passed with `structure check passed`. +- 2026-07-03: `openspec validate implement-log-ingest-pipeline --strict` passed with `Change 'implement-log-ingest-pipeline' is valid`. diff --git a/openspec/changes/implement-platform-api-surface/.openspec.yaml b/openspec/changes/implement-platform-api-surface/.openspec.yaml new file mode 100644 index 0000000..8e26fbe --- /dev/null +++ b/openspec/changes/implement-platform-api-surface/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/implement-platform-api-surface/design.md b/openspec/changes/implement-platform-api-surface/design.md new file mode 100644 index 0000000..f29ec59 --- /dev/null +++ b/openspec/changes/implement-platform-api-surface/design.md @@ -0,0 +1,79 @@ +## Context + +`platform/` already contains typed domain resources, DTO contracts, model projections, validators, repositories, and `service.Core` workflows for the first platform resources. The current executable only exposes `/healthz`, so frontend, run, and plugin changes cannot yet rely on HTTP behavior for users, game plugins, server instances, AI providers, run endpoints, jobs, artifacts, log streams, or audit events. + +This change stays inside `platform/` and implements the first HTTP adapter layer over the existing core service. It must preserve the repository structure rules: handlers belong in `api/`, request/response DTOs in `dto/`, domain rules in `domain/` and `validator/`, and storage concerns in `repo/` or future persistence packages. + +## Goals / Non-Goals + +**Goals:** + +- Expose create, list, and detail HTTP routes for core platform resources. +- Keep handlers as adapters that decode named DTOs, call `service.Core`, and encode named DTO responses. +- Return deterministic JSON error responses for malformed JSON, validation failures, duplicate IDs, missing resources, and unexpected failures. +- Preserve AI provider redaction by returning `apiKeyRef` only and never raw API key material. +- Make router construction injectable for tests and future persistence while retaining an in-memory default for local development. +- Update `platform/api/routes.md` to reflect implemented routes. + +**Non-Goals:** + +- No authentication, session, RBAC, or authorization engine. +- No SQL database, migrations, or external persistence dependency. +- No frontend or run-side implementation. +- No run job claim/ack/result protocol implementation beyond platform-side job resource creation and query. +- No plugin page bridge implementation, AI invocation endpoint, file content transfer, log body ingest, or artifact chunk transfer. +- No billing, cloud host sales, agent-provider/cloud-provider workflows, or unrelated SaaS marketplace features. + +## Decisions + +### Decision 1: Use `net/http` ServeMux with explicit method dispatch + +The platform will keep using the Go standard library. Route wiring will use `http.ServeMux` path patterns, `PathValue` for detail routes, and explicit method dispatch inside resource handlers so unsupported methods can return the named JSON error DTO. + +Alternative considered: adding a third-party router. Rejected because the API surface is still small and the current module has no external runtime dependencies. + +### Decision 2: Router accepts `service.Core` + +`api.NewRouter()` will build the current in-memory service for local execution, while `api.NewRouterWithCore(core service.Core)` will allow tests and later persistence changes to provide a service implementation. + +Alternative considered: constructing repositories directly inside every handler. Rejected because it hides storage choices in transport code and bypasses the service layer that already owns cross-resource invariants. + +### Decision 3: DTO package owns request conversion and API envelopes + +Create-request DTOs will expose `ToDomain()` helpers. Response DTOs and list/error envelopes will remain named structs under `platform/dto` so handlers do not define request/response shapes inline. + +Alternative considered: constructing ad hoc response maps in handlers. Rejected because API contracts must remain discoverable and testable. + +### Decision 4: Handlers map service errors to stable HTTP errors + +Handlers will translate `validator.ValidationError` to `400`, malformed JSON to `400`, `repo.ErrNotFound` to `404`, `repo.ErrDuplicate` to `409`, and unexpected errors to `500`. All errors will use `dto.ErrorResponse`. + +Alternative considered: returning plain-text `http.Error`. Rejected because clients need predictable JSON responses and AGENTS.md requires named error DTOs. + +### Decision 5: Implement metadata routes only for logs and artifacts + +This change implements log stream metadata and artifact metadata resources. Chunk upload/download, durable log ingest, tail transport, and storage adapters remain future changes because they affect run communication channels and transfer backpressure. + +Alternative considered: implementing chunk and ingest endpoints now. Rejected because the delivery stream has separate changes for run channels, logs, and artifacts. + +## Risks / Trade-offs + +- [Risk] In-memory default storage loses data on restart. Mitigation: document it as local development wiring and keep router injection ready for future persistence. +- [Risk] Create/list/get routes are narrower than the full route catalog. Mitigation: document deferred lifecycle, chunk, ingest, and plugin bridge behavior explicitly in `platform/api/routes.md`. +- [Risk] Query filter values are string-based and rely on domain enum strings. Mitigation: keep filters narrow and let create/update validation remain in the service and validator layers. + +## Migration Plan + +1. Add DTO conversion helpers, list envelopes, and error DTOs. +2. Add API router wiring and handlers over `service.Core`. +3. Update route catalog documentation. +4. Add focused handler tests for route behavior, validation/error mapping, and AI provider redaction. +5. Verify with `go test ./...` from `platform/`, `scripts/check-structure.sh`, and strict OpenSpec validation. + +Rollback before dependent changes is removal of the API handler additions and this OpenSpec change. After frontend, run, or plugin changes consume these routes, rollback must be handled through a new OpenSpec change. + +## Open Questions + +- Which authentication/session mechanism will wrap these routes first? +- Which persistent repository implementation should replace the in-memory default? +- Which API pagination and sorting contract should be introduced once lists can grow beyond development-scale data? diff --git a/openspec/changes/implement-platform-api-surface/proposal.md b/openspec/changes/implement-platform-api-surface/proposal.md new file mode 100644 index 0000000..9b88913 --- /dev/null +++ b/openspec/changes/implement-platform-api-surface/proposal.md @@ -0,0 +1,30 @@ +## Why + +The platform backend has typed core resources and service workflows, but clients still cannot exercise them through HTTP. This change adds the first platform API surface so later frontend, run, and plugin work can depend on stable handler behavior instead of calling services directly. + +## What Changes + +- Add HTTP route handlers for core platform resources using the existing domain, DTO, validator, repository, and service packages. +- Support create, list, and detail workflows for users, AI providers, game management plugins, server instances, run endpoints, jobs, artifacts, log streams, and audit events. +- Return JSON error responses for malformed requests, validation failures, duplicates, and missing resources. +- Preserve AI provider redaction by returning only API key references and never raw provider keys. +- Wire the platform router to an in-memory service instance for local development while keeping handlers injectable for tests and future persistence. +- Update route catalog documentation to reflect the implemented API paths. + +## Capabilities + +### New Capabilities + +- `platform-api-surface`: HTTP API handlers, route wiring, request decoding, response encoding, and error behavior for core platform resources. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform/` only. +- Adds platform API handler code and focused handler tests. +- Extends DTO helpers for request-to-domain conversion and JSON error response contracts. +- Uses only Go standard library HTTP routing and the existing in-memory core service. +- Does not add authentication, authorization, SQL persistence, frontend behavior, run executor behavior, billing, cloud host sales, or direct plugin/run access. diff --git a/openspec/changes/implement-platform-api-surface/specs/platform-api-surface/spec.md b/openspec/changes/implement-platform-api-surface/specs/platform-api-surface/spec.md new file mode 100644 index 0000000..5713020 --- /dev/null +++ b/openspec/changes/implement-platform-api-surface/specs/platform-api-surface/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### Requirement: Core resource HTTP routes are implemented +The platform SHALL expose HTTP JSON routes for create, list, and detail workflows for users, AI providers, game management plugins, server instances, run endpoints, jobs, artifacts, log streams, and audit events. + +#### Scenario: Resource is created through API +- **WHEN** a valid create request is posted to a core resource collection route +- **THEN** the platform MUST persist the resource through `service.Core` and return `201` with the corresponding named response DTO + +#### Scenario: Resource list is requested +- **WHEN** a client sends `GET` to a core resource collection route +- **THEN** the platform MUST return `200` with a named list response DTO containing resources from `service.Core` + +#### Scenario: Resource detail is requested +- **WHEN** a client sends `GET` to a core resource detail route with an existing resource ID +- **THEN** the platform MUST return `200` with the corresponding named response DTO + +### Requirement: API handlers use centralized DTO and service contracts +The platform SHALL keep API request, response, list, and error contracts in `platform/dto` and SHALL call `service.Core` for resource workflows. + +#### Scenario: Handler decodes request body +- **WHEN** an API handler accepts a request body +- **THEN** it MUST decode into a named DTO type from `platform/dto` and convert that DTO to a named domain type before calling `service.Core` + +#### Scenario: Handler returns response body +- **WHEN** an API handler returns a success or error response +- **THEN** it MUST encode a named DTO response type and MUST NOT define response structs inside handler functions + +### Requirement: API errors are stable JSON responses +The platform SHALL return named JSON error DTOs for malformed requests, validation failures, duplicate resources, missing resources, unsupported methods, and unexpected failures. + +#### Scenario: Invalid JSON is submitted +- **WHEN** a client posts malformed JSON to a core resource route +- **THEN** the platform MUST return `400` with a JSON error response + +#### Scenario: Validation fails +- **WHEN** a create request violates validator or service invariants +- **THEN** the platform MUST return `400` with a JSON error response and MUST NOT persist the resource + +#### Scenario: Duplicate resource is submitted +- **WHEN** a create request uses an ID that already exists +- **THEN** the platform MUST return `409` with a JSON error response + +#### Scenario: Missing resource is requested +- **WHEN** a client requests a resource ID that does not exist +- **THEN** the platform MUST return `404` with a JSON error response + +### Requirement: AI provider API preserves credential redaction +The AI provider API SHALL return redacted provider response DTOs that include secret references only and never raw API keys. + +#### Scenario: AI provider is created through API +- **WHEN** a valid AI provider create request is posted +- **THEN** the platform MUST return an `AIProviderResponse` containing `apiKeyRef` and MUST NOT include raw API key fields + +#### Scenario: Raw AI key is submitted as key reference +- **WHEN** an AI provider create request includes raw key material in `apiKeyRef` +- **THEN** the platform MUST reject the request with `400` and MUST NOT persist the provider + +### Requirement: Router is injectable and local-development ready +The platform SHALL provide router construction that accepts a core service for tests and future persistence, and a default router that uses the in-memory core service for local development. + +#### Scenario: Local platform process starts +- **WHEN** `cmd/platform` creates the default router +- **THEN** the router MUST expose `/healthz` and all implemented core API routes backed by an in-memory `service.Core` + +#### Scenario: Tests provide a service +- **WHEN** tests call router construction with an explicit `service.Core` +- **THEN** handlers MUST use that service instance for all route operations + +### Requirement: Route catalog matches implemented API surface +The platform route catalog SHALL identify implemented core API routes and clearly distinguish deferred run transport, log ingest, artifact chunk, plugin bridge, and AI invocation behavior. + +#### Scenario: Contributor inspects API catalog +- **WHEN** a contributor opens `platform/api/routes.md` +- **THEN** the file MUST list the implemented create, list, and detail routes and MUST identify deferred behavior as not implemented by this change + +### Requirement: API handler tests verify surface behavior +The platform SHALL include API tests covering successful create/list/detail workflows, JSON error mapping, dependency validation, duplicate handling, missing resources, and AI provider redaction. + +#### Scenario: Platform API tests run +- **WHEN** `go test ./...` is executed inside `platform/` +- **THEN** tests MUST verify the implemented HTTP API behavior without external services or a database diff --git a/openspec/changes/implement-platform-api-surface/tasks.md b/openspec/changes/implement-platform-api-surface/tasks.md new file mode 100644 index 0000000..c056a6d --- /dev/null +++ b/openspec/changes/implement-platform-api-surface/tasks.md @@ -0,0 +1,30 @@ +## 1. DTO And Router Contracts + +- [x] 1.1 Add named DTO list/error response contracts and request-to-domain conversion helpers for core resource create requests. +- [x] 1.2 Add injectable platform router construction that wires health and core API routes through `service.Core` with an in-memory default. + +## 2. Core API Handlers + +- [x] 2.1 Implement users, AI providers, game plugins, server instances, and run endpoints create/list/detail handlers. +- [x] 2.2 Implement jobs, artifacts, log streams, and audit events create/list/detail handlers. +- [x] 2.3 Implement shared JSON decode, encode, method, and error mapping behavior using named DTO responses. +- [x] 2.4 Update `platform/api/routes.md` to identify implemented routes and deferred transport/bridge behavior. + +## 3. API Tests + +- [x] 3.1 Add handler tests for successful create/list/detail workflows and query filters. +- [x] 3.2 Add handler tests for malformed JSON, validation errors, duplicates, missing resources, dependency failures, and AI provider redaction. + +## 4. Verification + +- [x] 4.1 Run `go test ./...` from `platform/` and record evidence. +- [x] 4.2 Run `scripts/check-structure.sh` and record evidence. +- [x] 4.3 Run `openspec validate implement-platform-api-surface --strict` and record evidence. + +## Evidence + +- `go test ./api`: passed. +- `go test ./dto`: passed. +- `go test ./...` from `platform/`: passed. +- `scripts/check-structure.sh`: passed. +- `openspec validate implement-platform-api-surface --strict`: passed. diff --git a/openspec/changes/implement-platform-core-domain/.openspec.yaml b/openspec/changes/implement-platform-core-domain/.openspec.yaml new file mode 100644 index 0000000..8e26fbe --- /dev/null +++ b/openspec/changes/implement-platform-core-domain/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/implement-platform-core-domain/design.md b/openspec/changes/implement-platform-core-domain/design.md new file mode 100644 index 0000000..a535b10 --- /dev/null +++ b/openspec/changes/implement-platform-core-domain/design.md @@ -0,0 +1,79 @@ +## Context + +`platform/` currently contains the development baseline: a Go module, config loader, health route, route catalog, and markdown contracts for platform resources. The bootstrap architecture requires fixed backend directories for domain types, DTOs, database models, repositories, services, validators, protocol contracts, routes, and shared helpers. Later changes will implement HTTP handlers, run registration, job channels, logs, artifacts, plugin registry, and frontend workflows; those changes need stable core platform types first. + +This change stays inside `platform/` and converts the markdown resource contracts into Go packages with unit-tested in-memory behavior. It does not introduce a database driver or full API handler surface. + +## Goals / Non-Goals + +**Goals:** + +- Define typed Go domain resources for users, game management plugins, server instances, AI providers, run endpoints, jobs, artifacts, log streams, and audit events. +- Define DTO and database-model contracts in dedicated packages so future handlers and persistence work do not invent structs locally. +- Add repository interfaces and an in-memory implementation for deterministic unit tests and early service composition. +- Add services that enforce core invariants for plugin installation metadata, server creation, AI provider redaction, job idempotency, artifacts, logs, and audit. +- Add validation helpers with precise errors for required IDs, enum values, relationships, capability compatibility, redaction, sequence cursors, and bounded summaries. +- Update route catalog documentation with resource contract routes, while leaving handler implementation for a later API-surface change. + +**Non-Goals:** + +- No authentication, sessions, role authorization engine, or password storage. +- No SQL database, migrations, ORM, or external persistence dependency. +- No full HTTP CRUD handlers beyond existing health behavior. +- No run control/job/log/artifact transport implementation. +- No plugin manifest registry implementation or plugin page bridge implementation. +- No raw AI key exposure, direct plugin-to-run access, billing, cloud host sales, or unrelated marketplace behavior. + +## Decisions + +### Decision 1: Domain package owns business vocabulary + +`platform/domain` will define resource structs, enum-like string types, lifecycle constants, filter structs, and copy helpers. Services, repositories, DTOs, and models will reference this vocabulary instead of redefining resource shapes. + +Alternative considered: defining separate shapes independently in every package. Rejected because this would recreate the drift the architecture bootstrap is trying to avoid. + +### Decision 2: DTO and model packages are explicit projections + +DTO structs will represent API request/response boundaries and must not include raw AI provider secrets. Model structs will represent future database tables with JSON/database tags plus `TableName()` methods. Conversion functions will make differences explicit. + +Alternative considered: reusing domain structs directly as API and database structs. Rejected because API redaction and database mapping concerns need independent contracts. + +### Decision 3: Repository interfaces live with the in-memory implementation + +`platform/repo` will define `Store` and typed repository interfaces, plus an in-memory `MemoryStore`. The store will deep-copy resources on read/write and enforce duplicate IDs. This gives services a realistic boundary without committing to SQL in this change. + +Alternative considered: package-level maps in services. Rejected because it hides persistence contracts inside orchestration logic and makes future database replacement harder. + +### Decision 4: Services own cross-resource invariants + +Validators will check local resource validity. Services will enforce cross-resource rules such as “server instances require an installed plugin” and “run endpoint capabilities must satisfy plugin requirements.” Job creation will use an idempotency key to return an existing job for duplicate requests. + +Alternative considered: repositories enforcing all invariants. Rejected because repositories should guard storage integrity while service use cases should own platform behavior. + +### Decision 5: No database or HTTP framework dependency yet + +This change uses only the Go standard library. SQL, migrations, and API handler frameworks are deferred until changes that explicitly implement persistence and API surface behavior. + +Alternative considered: adding SQLite or an HTTP framework now. Rejected because it would widen scope beyond the core domain foundation and complicate verification before handlers exist. + +## Risks / Trade-offs + +- [Risk] In-memory repositories can drift from future SQL behavior. Mitigation: keep interfaces small, copy-on-read/write, and test behavior that future implementations must preserve. +- [Risk] Domain structs may need fields added by later run/log/plugin changes. Mitigation: include the bootstrap resource fields now and allow additive changes through future OpenSpec deltas. +- [Risk] DTO/model projections add boilerplate. Mitigation: keep conversion helpers straightforward and limited to core resources. +- [Risk] Services may look broad before API handlers exist. Mitigation: expose focused methods only for current core workflows and leave transport-specific behavior to later changes. + +## Migration Plan + +1. Add platform domain, DTO, model, validator, repository, and service code behind new unit tests. +2. Keep existing health route behavior unchanged. +3. Update route and resource contract documentation to reference the implemented core resources. +4. Verify with platform unit tests, `scripts/check-structure.sh`, and strict OpenSpec validation. + +Rollback before dependent changes is file removal for the new platform packages and this OpenSpec change. After later API or persistence changes depend on these packages, rollback must follow a new OpenSpec change. + +## Open Questions + +- Which persistent database implementation should replace `MemoryStore` first? +- Which authentication and authorization model should own user/session behavior? +- Which route handlers from the core route catalog should be implemented first in `implement-platform-api-surface`? diff --git a/openspec/changes/implement-platform-core-domain/proposal.md b/openspec/changes/implement-platform-core-domain/proposal.md new file mode 100644 index 0000000..db5bee1 --- /dev/null +++ b/openspec/changes/implement-platform-core-domain/proposal.md @@ -0,0 +1,28 @@ +## Why + +The platform backend currently has architecture contracts and a health endpoint, but the core platform resources are only described in markdown. This change turns those contracts into typed, validated Go domain foundations so later API, run, frontend, and plugin work can depend on stable platform behavior. + +## What Changes + +- Add typed platform domain resources for users, game management plugins, server instances, AI providers, run endpoints, jobs, artifacts, log streams, and audit events. +- Add DTO and model contracts for those resources in their required directories. +- Add repository interfaces plus an in-memory repository implementation suitable for unit tests and early service wiring. +- Add service interfaces and implementations for core create/list/get workflows and lifecycle-safe state changes. +- Add validators for identity, enum values, plugin-to-server relationships, run capability compatibility, AI provider redaction constraints, job idempotency, artifact metadata, log stream cursors, and audit summaries. +- Extend route catalog documentation with the core resource contract surface, without implementing full HTTP handlers in this change. + +## Capabilities + +### New Capabilities +- `platform-core-domain`: Typed backend domain, DTO, model, repository, service, validation, and route-contract foundations for core platform resources. + +### Modified Capabilities +- None. + +## Impact + +- Affects `platform/` only. +- Adds Go packages under `platform/domain`, `platform/dto`, `platform/model`, `platform/repo`, `platform/service`, and `platform/validator`. +- Updates `platform/api/routes.md` and platform markdown contracts where needed to reflect the implemented route contract surface. +- Adds focused platform unit tests for validators, repository behavior, service behavior, and model mappings. +- Does not add billing, cloud host sales, plugin-to-run direct access, raw AI key exposure, or full API handler behavior. diff --git a/openspec/changes/implement-platform-core-domain/specs/platform-core-domain/spec.md b/openspec/changes/implement-platform-core-domain/specs/platform-core-domain/spec.md new file mode 100644 index 0000000..1b90628 --- /dev/null +++ b/openspec/changes/implement-platform-core-domain/specs/platform-core-domain/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Core platform resources are typed +The platform SHALL define typed domain resources for users, AI providers, game management plugins, server instances, run endpoints, jobs, artifacts, log streams, and audit events in the platform domain package. + +#### Scenario: Domain resource definitions are available +- **WHEN** platform services, repositories, DTOs, or models need a core platform resource +- **THEN** they MUST reference named domain resource types instead of defining business structs inside handlers or functions + +#### Scenario: Lifecycle values are centralized +- **WHEN** code validates resource status, state, result, or provider kind values +- **THEN** it MUST use centralized domain constants for the allowed values + +### Requirement: API and database contracts are separated from business logic +The platform SHALL provide named DTO and model structs for core resources in dedicated packages, and model structs SHALL expose explicit table names and database tags. + +#### Scenario: API response contract is needed +- **WHEN** a later API handler returns a core resource +- **THEN** the response shape MUST be available as a named DTO and MUST NOT be declared inside the handler + +#### Scenario: Database model contract is needed +- **WHEN** a future migration or repository references a core resource table +- **THEN** the table mapping MUST be available as a named model with tags and an explicit table name function + +### Requirement: AI provider contracts redact secrets +The platform SHALL store AI provider secret references but MUST NOT expose raw provider API keys through domain responses, DTO responses, services, or plugin-facing contracts. + +#### Scenario: AI provider is returned by service +- **WHEN** an AI provider is created or fetched through the core service layer +- **THEN** the returned provider MUST include an API key reference only and MUST NOT include raw key material + +#### Scenario: AI provider validation runs +- **WHEN** an AI provider uses a direct or relay endpoint +- **THEN** validation MUST require a key reference and redaction policy while rejecting raw secret values in API contract fields + +### Requirement: Core validators enforce resource invariants +The platform SHALL validate required IDs, display names, enum values, bounded lists, server/plugin/run relationships, job idempotency keys, artifact checksums, log cursors, and audit summaries before services persist resources. + +#### Scenario: Invalid core resource is submitted +- **WHEN** a resource has a missing ID, invalid enum value, missing required relationship, unsupported capability, or unbounded summary +- **THEN** validation MUST return a clear error and the service MUST NOT persist the resource + +#### Scenario: Server creation is requested +- **WHEN** a server instance is created from a game management plugin +- **THEN** validation MUST require an installed plugin, a non-deleted server state, a run endpoint, and capability compatibility + +### Requirement: Repository contracts support deterministic core storage +The platform SHALL expose repository interfaces for core resources and an in-memory implementation that supports create, get, list, update, and idempotent job lookup behavior. + +#### Scenario: Duplicate resource is created +- **WHEN** a repository create operation receives an ID that already exists +- **THEN** it MUST return a duplicate error and MUST NOT replace the existing resource + +#### Scenario: Stored resource is read and mutated by caller +- **WHEN** a caller mutates a value returned by the in-memory repository +- **THEN** the stored resource MUST remain unchanged unless an explicit update operation succeeds + +### Requirement: Core services enforce cross-resource workflows +The platform SHALL provide services that compose repositories and validators for core user, plugin, server instance, AI provider, run endpoint, job, artifact, log stream, and audit workflows. + +#### Scenario: Server instance is created from an installed plugin +- **WHEN** a service request names an installed game management plugin and an online or degraded run endpoint with all required capabilities +- **THEN** the service MUST persist a server instance linked to that plugin and run endpoint + +#### Scenario: Server instance creation uses invalid dependencies +- **WHEN** a service request names a disabled or invalid plugin, missing plugin, missing run endpoint, disabled run endpoint, or run endpoint without required capabilities +- **THEN** the service MUST reject the request and MUST NOT persist the server instance + +#### Scenario: Duplicate job request is submitted +- **WHEN** a job create request repeats an existing run endpoint and idempotency key pair +- **THEN** the service MUST return the existing job instead of creating a second job + +### Requirement: Route catalog exposes core resource contract groups +The platform SHALL document route groups for core resources before full API handlers are implemented. + +#### Scenario: Contributor inspects platform API contracts +- **WHEN** a contributor opens the platform route catalog +- **THEN** it MUST list core resource route groups and the DTO contracts those future handlers will use + +### Requirement: Platform core unit tests verify the domain foundation +The platform SHALL include unit tests covering validation, in-memory repository behavior, service invariants, DTO redaction, and model table mappings. + +#### Scenario: Platform tests run +- **WHEN** `go test ./...` is executed inside `platform/` +- **THEN** the tests MUST verify core domain behavior without external services or a database diff --git a/openspec/changes/implement-platform-core-domain/tasks.md b/openspec/changes/implement-platform-core-domain/tasks.md new file mode 100644 index 0000000..e64eadd --- /dev/null +++ b/openspec/changes/implement-platform-core-domain/tasks.md @@ -0,0 +1,34 @@ +## 1. Domain Contracts + +- [x] 1.1 Implement typed domain resources, enum constants, filters, and copy helpers for users, AI providers, game plugins, server instances, run endpoints, jobs, artifacts, log streams, and audit events. +- [x] 1.2 Implement named DTO request/response contracts with AI provider redaction helpers for core resources. +- [x] 1.3 Implement database model contracts with JSON/database tags, table-name mappings, and domain conversion helpers for core resources. + +## 2. Validation And Storage + +- [x] 2.1 Implement validator rules and tests for IDs, enum values, AI redaction constraints, plugin/server/run compatibility, job idempotency, artifacts, logs, and audit summaries. +- [x] 2.2 Implement repository interfaces and an in-memory repository with duplicate detection, copy-on-read/write behavior, list/get/update methods, and idempotent job lookup. + +## 3. Services And Contracts + +- [x] 3.1 Implement service interfaces and core service methods for create/list/get workflows across users, AI providers, game plugins, run endpoints, server instances, jobs, artifacts, log streams, and audit events. +- [x] 3.2 Enforce service-level cross-resource invariants for server creation, AI provider redaction, disabled resources, run capability compatibility, and duplicate job idempotency. +- [x] 3.3 Update platform route/resource contract documentation to reference the implemented DTO/domain contracts without adding full HTTP handlers. + +## 4. Verification + +- [x] 4.1 Run platform unit tests with `go test ./...` from `platform/` and record evidence. +- [x] 4.2 Run `scripts/check-structure.sh` and record evidence. +- [x] 4.3 Run `openspec validate implement-platform-core-domain --strict` and record evidence. + +## Evidence + +- `go test ./domain`: passed. +- `go test ./dto`: passed. +- `go test ./model`: passed. +- `go test ./validator`: passed. +- `go test ./repo`: passed. +- `go test ./service`: passed. +- `go test ./...` from `platform/`: passed. +- `scripts/check-structure.sh`: passed. +- `openspec validate implement-platform-core-domain --strict`: passed. diff --git a/openspec/changes/implement-platform-mediated-ai-invocation/.openspec.yaml b/openspec/changes/implement-platform-mediated-ai-invocation/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/implement-platform-mediated-ai-invocation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/implement-platform-mediated-ai-invocation/design.md b/openspec/changes/implement-platform-mediated-ai-invocation/design.md new file mode 100644 index 0000000..b001a90 --- /dev/null +++ b/openspec/changes/implement-platform-mediated-ai-invocation/design.md @@ -0,0 +1,67 @@ +## Context + +AI provider credentials and base URLs belong to `platform/`, while plugin pages may request AI assistance only through platform-mediated capabilities. AI-suggested config changes must be reviewable before any run-side write job is dispatched. This change introduces the invocation boundary and keeps provider clients mockable so implementation and tests do not require real keys or external services. + +## Goals / Non-Goals + +**Goals:** + +- Add AI invocation request/response contracts with plugin, server, purpose, model preference, input, and review context. +- Enforce allowed purposes from plugin manifest metadata and platform policy. +- Route invocation through platform-owned provider configuration and a provider client interface. +- Return bounded recommendations, safe text, structured diff suggestions, and usage metadata. +- Ensure config-writing suggestions remain reviewable and are not automatically dispatched to run. +- Add tests for validation, provider selection, mock invocation, purpose denial, redaction, and bridge integration. + +**Non-Goals:** + +- No real external provider network calls in tests or default local mode. +- No raw API key exposure to plugins, platform_web, run, logs, job payloads, or API responses. +- No automatic config write dispatch from AI output. +- No provider billing, agent-provider marketplace, cloud host sales, or unrelated SaaS workflows. + +## Decisions + +### Decision 1: Provider client is an interface with mock default for tests + +The platform service owns provider selection and calls a narrow provider client interface. Tests and local verification use a deterministic fake provider client, while live provider clients can be added later behind the same interface. + +Alternative considered: implement live OpenAI/Anthropic calls immediately. Rejected because this request must not require real keys/accounts or external paid services. + +### Decision 2: AI purposes are mandatory + +Every invocation request includes a purpose such as config recommendation, troubleshooting, log summary, or plugin assistant. Platform validation checks that the plugin and requested context allow that purpose before provider selection. + +Alternative considered: infer purpose from prompt text. Rejected because permission checks need explicit reviewable inputs. + +### Decision 3: Config outputs are recommendations, not writes + +For config-related requests, responses may include a proposed diff or recommendation object. The caller must still use config preview/approval APIs before any run-side write occurs. + +Alternative considered: let AI invocation directly queue config write jobs. Rejected because AI-suggested changes must be reviewable before dispatch. + +### Decision 4: Redaction happens before persistence and response + +Request metadata, prompts, provider errors, and responses are scanned for unsafe credential-like content before logging or returning to browser/plugin callers. + +Alternative considered: rely on caller discipline and avoid scanning. Rejected because provider and prompt output can accidentally include sensitive-looking material. + +## Risks / Trade-offs + +- [Risk] Mock provider behavior can hide live provider quirks. Mitigation: keep provider interface small and add live integration in a separate opt-in change. +- [Risk] Purpose checks may reject useful flows. Mitigation: add new purposes through explicit manifest and OpenSpec updates. +- [Risk] AI output can be over-trusted by operators. Mitigation: config changes return reviewable diffs and never auto-dispatch. + +## Migration Plan + +1. Add platform invocation contracts, validators, provider client interface, service, routes, and tests. +2. Add frontend API client and plugin bridge request plumbing. +3. Add plugin SDK/example helpers and tests. +4. Update docs and run full verification. + +Rollback removes invocation routes/provider interface integrations and this change's artifacts before plugin workflows depend on them. + +## Open Questions + +- Which live provider client should be implemented first after mock-mediated invocation passes? +- Which audit event schema should capture AI recommendation review and operator approval? diff --git a/openspec/changes/implement-platform-mediated-ai-invocation/proposal.md b/openspec/changes/implement-platform-mediated-ai-invocation/proposal.md new file mode 100644 index 0000000..1e0aa1f --- /dev/null +++ b/openspec/changes/implement-platform-mediated-ai-invocation/proposal.md @@ -0,0 +1,28 @@ +## Why + +AI provider management can store safe provider metadata, and plugin bridge contracts can request AI assistance by purpose. The missing piece is the platform-mediated invocation path: plugins and pages need AI help for reviewable recommendations without ever receiving raw provider keys, base URL credentials, or unmanaged model access. + +## What Changes + +- Add platform AI invocation domain, DTO, validator, service, and API behavior for purpose-scoped requests. +- Route requests through platform-owned provider configuration and mockable provider clients, with no real-key requirement for tests. +- Return bounded AI recommendations, usage metadata, and reviewable config diff suggestions instead of direct run-side writes. +- Add plugin bridge/SDK and frontend client integration for `ai.invoke` requests without exposing provider credentials. +- Add tests proving purpose enforcement, provider redaction, unsafe prompt/payload rejection, mock provider behavior, and no raw key exposure. + +## Capabilities + +### New Capabilities + +- `platform-mediated-ai-invocation`: Platform-owned AI invocation for plugin and console workflows with purpose validation, credential isolation, bounded outputs, and reviewable recommendations. + +### Modified Capabilities + +- Builds on `ai-provider-management` and plugin bridge capabilities without adding raw provider access to plugins or platform_web. + +## Impact + +- Affects `platform/` AI invocation contracts, services, validators, APIs, and tests. +- Affects `platform_web/` API contracts/client and plugin bridge host behavior for AI requests. +- Affects `plugins/` SDK/example AI request helpers and tests. +- Does not require real provider keys/accounts, external paid services, live network calls in tests, billing, cloud host sales, or direct config writes. diff --git a/openspec/changes/implement-platform-mediated-ai-invocation/specs/platform-mediated-ai-invocation/spec.md b/openspec/changes/implement-platform-mediated-ai-invocation/specs/platform-mediated-ai-invocation/spec.md new file mode 100644 index 0000000..9df94e0 --- /dev/null +++ b/openspec/changes/implement-platform-mediated-ai-invocation/specs/platform-mediated-ai-invocation/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: AI invocation is platform-mediated + +The platform SHALL expose AI invocation only through platform-owned APIs and services that use stored provider metadata and never expose raw provider credentials to plugins, platform_web, run, or API responses. + +#### Scenario: Plugin invokes allowed AI purpose +- **WHEN** a plugin page submits an `ai.invoke` request with an allowed purpose and bounded input +- **THEN** the platform MUST validate the purpose, select an enabled provider through platform-owned configuration, invoke a provider client, and return a redacted AI response + +#### Scenario: Raw provider credential is never returned +- **WHEN** any AI invocation succeeds or fails +- **THEN** the response MUST NOT include raw API keys, provider bearer tokens, provider base URL secrets, platform auth storage, run credentials, direct sockets, or raw host paths + +### Requirement: AI purposes and payloads are validated + +The platform SHALL validate invocation purpose, plugin permissions, server scope, model preference, input size, context references, and unsafe credential-like content before invoking a provider client. + +#### Scenario: Undeclared purpose is denied +- **WHEN** a plugin requests an AI purpose not declared by its manifest metadata or current bridge page permissions +- **THEN** the platform MUST deny the request before provider invocation + +#### Scenario: Unsafe payload is rejected +- **WHEN** an invocation payload includes raw key-like content, absolute host paths, direct sockets, or unbounded input +- **THEN** the platform MUST reject the request with a safe validation error + +### Requirement: Provider invocation is mockable and bounded + +The platform SHALL invoke AI through a provider client interface that supports deterministic tests without real external accounts or paid services. + +#### Scenario: Mock provider returns recommendation +- **WHEN** tests or local mode use the mock provider client +- **THEN** invocation MUST return deterministic safe content, usage metadata, and optional structured recommendations without network access + +#### Scenario: Provider failure is redacted +- **WHEN** the provider client returns an error +- **THEN** the platform MUST return a safe error response without provider credentials or raw transport details + +### Requirement: Config suggestions remain reviewable + +AI-generated configuration changes SHALL be returned as recommendations or diff previews and SHALL NOT directly dispatch run-side config write jobs. + +#### Scenario: AI suggests config edit +- **WHEN** an invocation purpose requests config assistance +- **THEN** the response MAY include a proposed diff or recommendation, but the platform MUST require the separate config preview/approval workflow before dispatching a write job + +### Requirement: Frontend and plugin SDK use mediated AI contracts + +The frontend and plugin SDK SHALL use typed AI bridge/API contracts and SHALL NOT expose provider keys or raw provider configuration to plugin code. + +#### Scenario: Plugin SDK builds AI request +- **WHEN** plugin code builds an AI invocation request +- **THEN** it MUST include purpose, request ID, scoped input, and context references while excluding raw provider credentials + +#### Scenario: Browser walkthrough verifies AI request safety +- **WHEN** AI invocation UI behavior is claimed complete +- **THEN** a browser walkthrough MUST verify an AI-assisted workflow renders redacted results and does not expose raw credential markers + +### Requirement: Platform-mediated AI invocation is verified + +The change SHALL include backend tests, frontend tests/build, plugin tests/typecheck, browser walkthrough evidence, structure validation, and strict OpenSpec validation. + +#### Scenario: Verification commands pass +- **WHEN** the change is complete +- **THEN** platform tests, platform_web tests/typecheck/build, plugin tests/typecheck, `scripts/check-structure.sh`, and `openspec validate implement-platform-mediated-ai-invocation --strict` MUST pass diff --git a/openspec/changes/implement-platform-mediated-ai-invocation/tasks.md b/openspec/changes/implement-platform-mediated-ai-invocation/tasks.md new file mode 100644 index 0000000..04e40e8 --- /dev/null +++ b/openspec/changes/implement-platform-mediated-ai-invocation/tasks.md @@ -0,0 +1,41 @@ +## 1. Platform AI Invocation Contracts + +- [x] 1.1 Add domain and DTO contracts for AI invocation requests, context refs, purposes, recommendations, usage metadata, and safe errors. +- [x] 1.2 Add validators for purpose authorization, provider IDs, model preferences, bounded input/output, context refs, and unsafe credential/path/socket content. +- [x] 1.3 Add a platform provider client interface and deterministic mock provider implementation for tests/local verification. + +## 2. Platform AI Invocation Service And API + +- [x] 2.1 Add service methods that authorize purpose-scoped invocation, select enabled providers, call the provider client, redact outputs, and return typed responses. +- [x] 2.2 Implement AI invocation route using named DTOs and service methods. +- [x] 2.3 Ensure config-related AI responses produce reviewable recommendations/diffs and never dispatch run-side writes directly. +- [x] 2.4 Update platform route/protocol documentation for mediated AI invocation and live-provider deferral. +- [x] 2.5 Add platform tests for allowed invocation, undeclared purpose denial, unsafe payload rejection, provider failure redaction, config recommendation reviewability, and no raw key exposure. + +## 3. Frontend And Plugin Integration + +- [x] 3.1 Add centralized `platform_web/api` AI invocation types and client methods. +- [x] 3.2 Integrate AI invocation into plugin bridge host execution flow for `ai.invoke` responses. +- [x] 3.3 Add plugin SDK/example helpers for AI invocation request builders and safe response parsing. +- [x] 3.4 Add frontend and plugin tests for mediated AI requests, denied purposes, redacted results, and no direct provider config exposure. + +## 4. Verification + +- [x] 4.1 Run `cd platform && go test ./...` and record evidence. +- [x] 4.2 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. +- [x] 4.3 Run `cd plugins && npm run typecheck && npm test` and record evidence. +- [x] 4.4 Run browser walkthrough for mediated AI invocation and record evidence. +- [x] 4.5 Run `scripts/check-structure.sh` and record evidence. +- [x] 4.6 Run `openspec validate implement-platform-mediated-ai-invocation --strict` and record evidence. + +## Evidence + +- 2026-07-06: `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -run TestAIInvocationAPIIsMediatedAndSafe -count=1` passed for mediated invocation, purpose denial, unsafe prompt rejection, config suggestion reviewability/no job dispatch, bridge `ai.invoke`, and no forbidden response fragments. +- 2026-07-06: `cd platform_web && npm run typecheck` and `cd platform_web && npm test -- --run api/client.test.ts utils/pluginBridgeHost.test.ts` passed for AI invocation API types/client and bridge `ai.invoke` dispatcher behavior. +- 2026-07-06: `cd plugins && npm run typecheck` and `cd plugins && npm test -- --run tests/manifest-validation.test.ts` passed for SDK AI invocation request/response helpers and no provider config exposure. +- 2026-07-06: `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./...` passed. +- 2026-07-06: `cd platform_web && npm run typecheck`, `cd platform_web && npm test`, and `cd platform_web && npm run build` passed. +- 2026-07-06: `cd plugins && npm run typecheck` and `cd plugins && npm test` passed. +- 2026-07-06: Browser walkthrough passed using a local mock platform API plus headless Chrome: logged in, opened `#/servers/server-ai-walkthrough`, switched to `插件控制`, clicked `AI 调用`, verified `AI 建议已返回`, and confirmed no forbidden credential/path/provider fragments were rendered. +- 2026-07-06: `scripts/check-structure.sh` passed. +- 2026-07-06: `openspec validate implement-platform-mediated-ai-invocation --strict` passed (`Change 'implement-platform-mediated-ai-invocation' is valid`; PostHog DNS flush warnings were non-fatal telemetry failures). diff --git a/openspec/changes/implement-platform-observability-and-config-read/.openspec.yaml b/openspec/changes/implement-platform-observability-and-config-read/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/implement-platform-observability-and-config-read/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/implement-platform-observability-and-config-read/design.md b/openspec/changes/implement-platform-observability-and-config-read/design.md new file mode 100644 index 0000000..bde1470 --- /dev/null +++ b/openspec/changes/implement-platform-observability-and-config-read/design.md @@ -0,0 +1,62 @@ +## Context + +The current frontend calls `/metrics/platform`, `/metrics/server-instances`, and `/server-instances/{id}/config`, but the backend router does not implement them. HomePage falls back to partial data or error states, and ServerDetailPage uses a hardcoded `server.properties` sample when config read fails. + +This change is deliberately read-only. It creates the observability/config read surface needed by later config write, AI suggestion, and run execution changes without introducing file mutation or process orchestration. + +## Goals / Non-Goals + +**Goals:** + +- Implement platform resource usage and per-server metrics API routes. +- Implement safe server config read route with role-scoped access. +- Keep response contracts bounded and free of secrets, host paths, direct sockets, and raw run credentials. +- Update frontend pages to consume real API data and demote local samples to explicit fallback. +- Add service/API/frontend tests and browser walkthrough evidence. + +**Non-Goals:** + +- No config write, file write, or diff approval routes. +- No external metrics collector or long-term metrics storage backend. +- No browser log tail, artifact transfer UI, or AI log analysis. +- No run worker changes beyond existing data sources. + +## Decisions + +### Decision 1: Keep metrics as platform-owned read models + +The service will expose platform and server metrics through platform DTOs. Initial values may be derived from existing run endpoint/server/job metadata or stored in the in-memory repository, but callers see stable read contracts. + +Alternative considered: let the frontend compute all metrics locally from server lists. Rejected because the console already has API client methods and future run workers need a platform-owned metrics surface. + +### Decision 2: Server config read returns logical content only + +The config read response returns server instance ID, config version, content, format/key metadata, and timestamps. It must not include host filesystem paths or run-local socket details. + +Alternative considered: return a host path for browser editing. Rejected because run paths must not leak to platform_web or plugins. + +### Decision 3: Role-scoped access applies to config and server metrics + +Platform administrators can read all metrics/config. Server owners and administrators can read only their server instances. This reuses the existing bearer session and ACL behavior. + +### Decision 4: Frontend fallback remains visibly non-production + +Local sample config can remain only as an explicit fallback state for API-unavailable development/demo flows. Production rendering must prefer API data and show errors/empty states honestly. + +## Risks / Trade-offs + +- [Risk] Derived metrics can look less live than future run telemetry. Mitigation: expose source/timestamp fields and keep later real-time collectors as a separate change. +- [Risk] Config content may be stale relative to actual files until run worker integration exists. Mitigation: include config version and source metadata. +- [Risk] Server detail can still show fallback config if backend is unavailable. Mitigation: label fallback clearly and add tests that API success suppresses fallback. + +## Migration Plan + +1. Add metrics/config domain, DTO, validators, and service methods in `platform/`. +2. Add API handlers/routes and route documentation updates. +3. Update `platform_web` API types/views to render API metrics/config. +4. Add tests and browser walkthrough. + +## Open Questions + +- Whether future metrics persistence should be a repository table or a projection from run heartbeats/logs. +- Whether config format should start as plain text only or include structured sections after file dispatch is implemented. diff --git a/openspec/changes/implement-platform-observability-and-config-read/proposal.md b/openspec/changes/implement-platform-observability-and-config-read/proposal.md new file mode 100644 index 0000000..0c504c3 --- /dev/null +++ b/openspec/changes/implement-platform-observability-and-config-read/proposal.md @@ -0,0 +1,27 @@ +## Why + +The console already calls platform metrics and server config read APIs, but those routes are not implemented. Operators see empty/error states or local sample config even when real server, job, and run endpoint data exists. This change closes the read-only observability gap before write and execution work builds on it. + +## What Changes + +- Add backend contracts and routes for platform resource usage, per-server metrics, and server config reads. +- Enforce existing role-scoped server access for server config and server metrics. +- Return bounded, safe metadata without host paths, raw credentials, run sockets, or AI keys. +- Update HomePage and ServerDetailPage to prefer API data and keep any local samples as explicit dev/demo fallback only. +- Add tests and browser walkthrough coverage for 首页 and server detail config views. + +## Capabilities + +### New Capabilities + +- `platform-observability-and-config-read`: API-backed platform/server metrics and safe server config read workflows. + +### Modified Capabilities + +- `platform-web-console-shell`: Removes production reliance on local observability/config samples for existing console pages. + +## Impact + +- Affects `platform/` domain, DTO, validators, service, API handlers, route docs, and tests. +- Affects `platform_web/` API types/client usage, HomePage, ServerDetailPage, tests, and browser walkthrough. +- Does not add config writes, file dispatch, log tail transport, external metrics backends, billing, cloud host sales, raw host paths, raw credentials, or direct run access. diff --git a/openspec/changes/implement-platform-observability-and-config-read/specs/platform-observability-and-config-read/spec.md b/openspec/changes/implement-platform-observability-and-config-read/specs/platform-observability-and-config-read/spec.md new file mode 100644 index 0000000..0f17b06 --- /dev/null +++ b/openspec/changes/implement-platform-observability-and-config-read/specs/platform-observability-and-config-read/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Platform exposes resource usage metrics +The platform SHALL expose a bounded platform resource usage endpoint for the management console. + +#### Scenario: Platform metrics loaded +- **WHEN** an authorized platform administrator requests platform resource usage +- **THEN** the platform MUST return CPU, memory, disk, source, and timestamp metadata in a named DTO response + +#### Scenario: Platform metrics remain safe +- **WHEN** the platform returns resource usage data +- **THEN** the response MUST NOT include host paths, raw credentials, direct sockets, storage backend credentials, or raw AI provider keys + +### Requirement: Platform exposes per-server metrics +The platform SHALL expose bounded per-server metrics for server management and overview pages. + +#### Scenario: Server metrics listed +- **WHEN** an authorized user requests server metrics +- **THEN** the platform MUST return only metrics for server instances visible to that user + +#### Scenario: Pending metrics are bounded +- **WHEN** a server does not have current metrics +- **THEN** the platform MUST return a bounded missing/pending representation rather than unsafe fallback internals + +### Requirement: Server config read is safe and role scoped +The platform SHALL expose a server config read endpoint that returns logical config content for an authorized server instance. + +#### Scenario: Owner reads server config +- **WHEN** a server owner requests config for their server instance +- **THEN** the platform MUST return config content, config version, server instance ID, and bounded metadata + +#### Scenario: Unauthorized config read rejected +- **WHEN** a user without access requests server config +- **THEN** the platform MUST reject the request and MUST NOT return config content + +#### Scenario: Config response hides run internals +- **WHEN** the platform returns server config +- **THEN** the response MUST NOT expose run credentials, raw host paths, direct sockets, or raw secret values + +### Requirement: Console uses API-backed observability and config reads +The frontend SHALL prefer API-backed platform metrics, server metrics, and server config content over hardcoded production data. + +#### Scenario: API config suppresses fallback +- **WHEN** the server config API returns content +- **THEN** ServerDetailPage MUST render that content and MUST NOT display the local sample config label + +#### Scenario: Metrics render from API +- **WHEN** metrics APIs return data +- **THEN** HomePage and server cards MUST render API metric values with safe loading/error states + +#### Scenario: Fallback is explicit +- **WHEN** a development fallback is used because an API is unavailable +- **THEN** the UI MUST label it as local/demo fallback and MUST NOT present it as persisted platform data diff --git a/openspec/changes/implement-platform-observability-and-config-read/tasks.md b/openspec/changes/implement-platform-observability-and-config-read/tasks.md new file mode 100644 index 0000000..e0bfd5a --- /dev/null +++ b/openspec/changes/implement-platform-observability-and-config-read/tasks.md @@ -0,0 +1,51 @@ +## 1. Platform Metrics Contracts + +- [x] 1.1 Add domain contracts for platform resource usage snapshots and per-server metrics. +- [x] 1.2 Add DTO request/response contracts for `/metrics/platform` and `/metrics/server-instances`. +- [x] 1.3 Add repository/service interfaces for storing or deriving platform and server metrics. +- [x] 1.4 Add validators for metric ranges, timestamps, server IDs, and bounded list responses. + +## 2. Server Config Read Contracts + +- [x] 2.1 Add domain and DTO contracts for server config read responses. +- [x] 2.2 Add service method for reading server config metadata/content by server instance. +- [x] 2.3 Enforce role-scoped access for config reads using existing server ACL rules. +- [x] 2.4 Ensure config read responses never expose host paths, raw credentials, or direct run sockets. + +## 3. Backend API Surface + +- [x] 3.1 Implement `GET /api/v1/metrics/platform`. +- [x] 3.2 Implement `GET /api/v1/metrics/server-instances`. +- [x] 3.3 Implement `GET /api/v1/server-instances/{id}/config`. +- [x] 3.4 Update `platform/api/routes.md` and protocol docs to mark these routes implemented. + +## 4. Frontend Integration + +- [x] 4.1 Update HomePage to render platform metrics from API data instead of empty fallback states. +- [x] 4.2 Update ServerDetailPage config section to show API config content when available. +- [x] 4.3 Remove or clearly isolate hardcoded config fallback from production flow. +- [x] 4.4 Add UI tests for metrics/config loading, errors, and no-secret rendering. + +## 5. Verification + +- [x] 5.1 Add platform service/API tests for metrics and config read access control. +- [x] 5.2 Run `cd platform && go test ./...` and record evidence. +- [x] 5.3 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. +- [x] 5.4 Run browser walkthrough for 首页 and server detail config view. +- [x] 5.5 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.6 Run `openspec validate implement-platform-observability-and-config-read --strict` and record evidence. + +## Evidence + +- 2026-07-06: Added platform domain/DTO/service/validator/API implementation for platform metrics, server metrics, and safe server config reads. +- 2026-07-06: Added platform service/API tests for role-scoped metrics and config reads, unauthorized access denial, and no host path/raw credential/socket fragments. +- 2026-07-06: Updated frontend API contracts/client tests for platform metrics, server metrics, and server config responses; existing HomePage and ServerDetailPage API flows consume these methods with explicit local fallback labeling. +- 2026-07-06: `cd platform && go test ./domain ./dto ./validator ./service ./api` passed. +- 2026-07-06: `cd platform_web && npm test -- --run api/client.test.ts pages/ConsolePages.test.tsx` passed with 2 files / 9 tests. +- 2026-07-06: `cd platform && go test ./...` passed across api, cmd/platform, config, domain, dto, model, repo, service, and validator packages. +- 2026-07-06: `cd platform_web && npm run typecheck` passed. +- 2026-07-06: `cd platform_web && npm test` passed with 9 files / 31 tests. +- 2026-07-06: `cd platform_web && npm run build` passed and produced Vite production assets. +- 2026-07-06: Headless Chrome walkthrough against `http://127.0.0.1:5175/` passed: seeded platform API data, verified 首页 rendered API platform metrics/resource usage and server distribution, opened `#/servers/server-walkthrough`, verified config tab showed `配置版本 v1` with API textarea content `server.name=Walkthrough SCUM`, and confirmed rendered text/textarea excluded `/Users/`, `unix://`, `Bearer `, `sk-`, and `password=`. +- 2026-07-06: `scripts/check-structure.sh` passed with `structure check passed`. +- 2026-07-06: `openspec validate implement-platform-observability-and-config-read --strict` passed with `Change 'implement-platform-observability-and-config-read' is valid`. diff --git a/openspec/changes/implement-platform-web-console-shell/.openspec.yaml b/openspec/changes/implement-platform-web-console-shell/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/implement-platform-web-console-shell/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/implement-platform-web-console-shell/design.md b/openspec/changes/implement-platform-web-console-shell/design.md new file mode 100644 index 0000000..0b76d22 --- /dev/null +++ b/openspec/changes/implement-platform-web-console-shell/design.md @@ -0,0 +1,71 @@ +## Context + +`platform_web/` already contains the required first-party navigation entries and a working AI provider management page. The other first-party areas still use thin metric-only placeholders, and the shell owns only hash replacement without a reusable page model, status surface, or acceptance-focused layout. + +This change keeps implementation inside `platform_web/` and prepares the frontend for later server-management workflows. It uses existing Vite, React, TypeScript, local contracts, and fallback data patterns; it does not add backend behavior or cross-root imports. + +## Goals / Non-Goals + +**Goals:** + +- Make the console shell route-aware, accessible, and stable across refresh/hash navigation. +- Give every required first-party area a useful operational page surface rather than a bare metric placeholder. +- Keep API contracts and page contracts in dedicated frontend directories. +- Add frontend client methods for currently available platform resources used by shell pages. +- Add tests covering route/page behavior and page-level rendering. +- Validate the UI with build/test commands and browser walkthrough evidence. + +**Non-Goals:** + +- Do not implement full server create/start/stop workflows; that is the next queued change. +- Do not add billing, cloud host sales, provider marketplace, or unrelated SaaS behavior. +- Do not expose raw AI keys, run credentials, host paths, storage backend credentials, or direct run sockets. +- Do not introduce a router dependency unless existing hash navigation becomes insufficient. +- Do not change backend platform APIs in this change. + +## Decisions + +### Decision 1: Keep hash routing for the shell baseline + +The app will continue to use hash-based navigation, but route resolution will be centralized in `routes/` and `stores/`. This keeps the console deployable as a static frontend while preserving direct links and refresh behavior. + +Alternative considered: add React Router now. Rejected because the current routing needs are simple and adding a dependency before workflow pages exist would increase surface area without improving the backlog item. + +### Decision 2: Use local page view contracts for first-party pages + +Each page will consume typed view contracts from `contracts/` and local-safe seed data until backend workflow APIs exist. Page components remain focused on rendering and local interactions, not hidden shared business types. + +Alternative considered: place page-specific types inside page components. Rejected because repository rules require frontend page roots to keep view contracts in fixed directories. + +### Decision 3: Extend the frontend API client only for existing backend endpoints + +The frontend client will add typed methods for health, game plugins, server instances, and bridge authorization where backend endpoints already exist. Pages may fall back to local data if APIs are unavailable. + +Alternative considered: create mock-only page data without API client expansion. Rejected because later workflow pages need a stable client surface and tests should exercise named contracts. + +### Decision 4: Design the shell for dense operational scanning + +The shell will prioritize compact navigation, status chips, tables/lists, and action strips over marketing-style presentation. The visual system should remain consistent with existing AI provider management styling. + +Alternative considered: add a landing-page style dashboard. Rejected because this is an operational game server management console, not a product marketing site. + +## Risks / Trade-offs + +- [Risk] Pages can imply workflows that are not implemented yet. Mitigation: label actions as planning/local status and avoid dispatching run jobs. +- [Risk] Local fallback data can drift from backend contracts. Mitigation: keep frontend API types explicit and covered by tests. +- [Risk] Hash routing can become limiting later. Mitigation: centralize route parsing so a future router can replace the implementation without changing page contracts. +- [Risk] A richer shell may introduce responsive layout regressions. Mitigation: run build/tests and a browser walkthrough at desktop and mobile widths. + +## Migration Plan + +1. Add console shell view contracts, route helpers, API types/client methods, and tests. +2. Expand first-party pages with operational sections and local-safe fallback data. +3. Update shell styling for desktop and mobile responsive navigation/content. +4. Run frontend tests/build/typecheck, structure validation, strict OpenSpec validation, and browser walkthrough. + +Rollback is contained to `platform_web/` and this OpenSpec change: remove the new page contracts/client methods/page rendering changes and return to the previous placeholder shell. + +## Open Questions + +- Whether a later workflow change should replace hash routing with a full router after server management forms and nested pages are introduced. +- Whether dashboard summaries should eventually come from a dedicated backend summary endpoint or compose existing list endpoints. diff --git a/openspec/changes/implement-platform-web-console-shell/proposal.md b/openspec/changes/implement-platform-web-console-shell/proposal.md new file mode 100644 index 0000000..f0c4ea8 --- /dev/null +++ b/openspec/changes/implement-platform-web-console-shell/proposal.md @@ -0,0 +1,27 @@ +## Why + +The management frontend has the required first-party navigation and a deeper AI provider page, but the console shell still behaves like a static page switcher with placeholder pages. This change turns `platform_web/` into a durable management console foundation for the next server-management workflows. + +## What Changes + +- Add a route-aware console shell with stable navigation state, page metadata, and accessible active-page behavior. +- Add richer first-party page surfaces for 首页、服务器管理、插件市场、用户管理、AI 提供商管理 using local-safe data until backend workflow APIs are introduced. +- Add frontend API types/client methods needed by shell pages for game plugins, server instances, and health/status summaries without exposing raw credentials or run internals. +- Add page/view contracts, schemas, and utilities that keep page components free of hidden shared business types. +- Add route/page tests, frontend build coverage, and a browser walkthrough for the console shell. + +## Capabilities + +### New Capabilities + +- `platform-web-console-shell`: Defines the first-party frontend console shell, navigation behavior, page contracts, API client surface, and visual acceptance expectations for the management console. + +### Modified Capabilities + +- None. + +## Impact + +- `platform_web/`: app shell, route definitions, page registry, page contracts, API types/client methods, schemas/utilities, first-party pages, tests, and styling. +- OpenSpec artifacts and validation for the new `platform-web-console-shell` capability. +- No backend behavior changes are required in this change; pages may use existing API endpoints with local fallback data. diff --git a/openspec/changes/implement-platform-web-console-shell/specs/platform-web-console-shell/spec.md b/openspec/changes/implement-platform-web-console-shell/specs/platform-web-console-shell/spec.md new file mode 100644 index 0000000..726ba08 --- /dev/null +++ b/openspec/changes/implement-platform-web-console-shell/specs/platform-web-console-shell/spec.md @@ -0,0 +1,91 @@ +## ADDED Requirements + +### Requirement: Console shell supports first-party route navigation + +The frontend SHALL provide a route-aware console shell for 首页、服务器管理、插件市场、用户管理、AI 提供商管理 with stable active-page state and refresh-safe URL hash handling. + +#### Scenario: User opens a first-party route hash + +- **WHEN** the browser opens the app with a hash for a known first-party page +- **THEN** the shell renders that page and marks the corresponding navigation item as current + +#### Scenario: User selects navigation item + +- **WHEN** the user selects a first-party navigation item +- **THEN** the shell updates the active page and URL hash without a full page reload + +#### Scenario: User opens unknown route hash + +- **WHEN** the browser opens the app with an unknown hash +- **THEN** the shell falls back to 首页 without rendering an invalid page + +### Requirement: First-party pages provide operational surfaces + +The frontend SHALL render useful operational surfaces for every required first-party area using typed local view contracts and safe fallback data. + +#### Scenario: Home page renders platform overview + +- **WHEN** 首页 is active +- **THEN** the page shows platform health, server/plugin/user/provider summary sections, and next-action context without exposing secrets + +#### Scenario: Server management page renders instance overview + +- **WHEN** 服务器管理 is active +- **THEN** the page shows server instance status, run endpoint context, lifecycle action affordances, and pending job/log indicators without dispatching unimplemented workflows + +#### Scenario: Plugin marketplace page renders installed plugin catalog + +- **WHEN** 插件市场 is active +- **THEN** the page shows installed plugin metadata, permissions, bridge readiness, and validation status without commerce or cloud-host sales features + +#### Scenario: User management page renders user and role overview + +- **WHEN** 用户管理 is active +- **THEN** the page shows user status, role assignment context, and access-review indicators using frontend-owned contracts + +### Requirement: Frontend API client exposes shell resource contracts + +The frontend SHALL define API types and client methods for shell pages using existing platform API endpoints and without casual cross-root imports. + +#### Scenario: Client lists game plugins + +- **WHEN** shell code requests game plugin data +- **THEN** the API client uses a named frontend contract for `/api/v1/game-plugins` + +#### Scenario: Client lists server instances + +- **WHEN** shell code requests server instance data +- **THEN** the API client uses a named frontend contract for `/api/v1/server-instances` + +#### Scenario: Client authorizes plugin bridge action + +- **WHEN** shell code requests bridge authorization +- **THEN** the API client uses a named frontend contract for `/api/v1/plugin-bridge/authorize` and does not include raw credentials + +### Requirement: Console shell is testable and visually accepted + +The frontend SHALL include route/page tests, pass TypeScript/build validation, and complete a browser walkthrough before the change is complete. + +#### Scenario: Automated tests cover required pages + +- **WHEN** frontend tests run +- **THEN** they verify required navigation entries and representative content for each first-party page + +#### Scenario: Browser walkthrough validates shell usability + +- **WHEN** the browser walkthrough runs against the built or dev frontend +- **THEN** desktop and mobile views show coherent navigation, readable content, and no obvious overlap or blank page + +### Requirement: Console shell preserves security boundaries + +The frontend SHALL not expose raw AI keys, run credentials, host paths, direct sockets, or storage backend credentials in shell pages, API contracts, or fallback data. + +#### Scenario: Page content renders provider credentials + +- **WHEN** AI provider or plugin data includes credential references +- **THEN** the frontend shows only safe references such as `secret://`, `vault://`, or `env://` and never raw key material + +#### Scenario: Plugin bridge context is rendered + +- **WHEN** plugin bridge readiness appears in shell pages +- **THEN** the frontend shows permissions and action readiness without exposing raw run or platform auth internals diff --git a/openspec/changes/implement-platform-web-console-shell/tasks.md b/openspec/changes/implement-platform-web-console-shell/tasks.md new file mode 100644 index 0000000..7e28ee3 --- /dev/null +++ b/openspec/changes/implement-platform-web-console-shell/tasks.md @@ -0,0 +1,40 @@ +## 1. Shell Routing and Contracts + +- [x] 1.1 Add route helper contracts for resolving known hashes, fallback routes, and page metadata. +- [x] 1.2 Add shell view contracts for dashboard summaries, server instances, plugins, users, and provider status. +- [x] 1.3 Extend frontend API types and client methods for game plugins, server instances, health, and plugin bridge authorization. + +## 2. First-Party Page Implementation + +- [x] 2.1 Expand 首页 into an operational overview with health, server, plugin, user, and AI provider summary sections. +- [x] 2.2 Expand 服务器管理 into an instance/run/job/log overview without dispatching unimplemented lifecycle workflows. +- [x] 2.3 Expand 插件市场 into an installed plugin catalog with permissions, bridge readiness, and validation state. +- [x] 2.4 Expand 用户管理 into a user/role/access-review overview using frontend-owned contracts. +- [x] 2.5 Keep AI 提供商管理 compatible with the updated shell and shared page contracts. + +## 3. Styling and Responsive Shell + +- [x] 3.1 Update shell/page styling for dense operational layouts, responsive navigation, and accessible active states. +- [x] 3.2 Ensure page content avoids raw secret, host path, direct socket, and run credential display. + +## 4. Tests and Browser Walkthrough + +- [x] 4.1 Add route helper and API client tests for shell resource contracts. +- [x] 4.2 Add page rendering tests for each required first-party page. +- [x] 4.3 Run `cd platform_web && npm test`. +- [x] 4.4 Run `cd platform_web && npm run build`. +- [x] 4.5 Run browser walkthrough for desktop and mobile shell views. + +## 5. Verification + +- [x] 5.1 Run `scripts/check-structure.sh`. +- [x] 5.2 Run `openspec validate implement-platform-web-console-shell --strict`. +- [x] 5.3 Record verification evidence in this task file before marking verification tasks complete. + +## Evidence + +- `cd platform_web && npm test`: passed on 2026-07-03; Vitest reported 6 files and 14 tests passing. +- `cd platform_web && npm run build`: passed on 2026-07-03; TypeScript no-emit and Vite production build completed. +- Browser walkthrough: passed on 2026-07-03 using local Chrome CDP against `http://127.0.0.1:5174/`; checked 首页、服务器管理、插件市场、用户管理、AI 提供商管理 at desktop 1440x1000 and mobile 390x844 with no missing target content or page-level horizontal overflow. Screenshots were written under `/tmp/platform-web-console-shell-walkthrough/`. +- `scripts/check-structure.sh`: passed on 2026-07-03 with `structure check passed`. +- `openspec validate implement-platform-web-console-shell --strict`: passed on 2026-07-03 with `Change 'implement-platform-web-console-shell' is valid`. diff --git a/openspec/changes/implement-plugin-bridge-and-sdk/.openspec.yaml b/openspec/changes/implement-plugin-bridge-and-sdk/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/implement-plugin-bridge-and-sdk/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/implement-plugin-bridge-and-sdk/design.md b/openspec/changes/implement-plugin-bridge-and-sdk/design.md new file mode 100644 index 0000000..2cc9527 --- /dev/null +++ b/openspec/changes/implement-plugin-bridge-and-sdk/design.md @@ -0,0 +1,83 @@ +## Context + +The registry change added a validated game plugin manifest and marketplace-ready plugin metadata, but plugin pages still have only documentation-level bridge notes. The next backlog item needs a concrete contract that lets plugin UI request platform abilities without learning platform credentials, run connection details, host paths, storage backends, or AI provider keys. + +The implementation spans three roots: + +- `plugins/` owns the author-facing TypeScript SDK, manifest bridge declarations, and example plugin usage. +- `platform_web/` owns the browser host-side bridge contracts and utility checks used by future plugin pages. +- `platform/` owns the authoritative permission/session validation before privileged bridge requests are accepted. + +## Goals / Non-Goals + +**Goals:** + +- Define a typed bridge request/response contract for plugin pages. +- Enforce bridge actions against manifest-declared plugin permissions and AI purposes. +- Provide SDK helpers for plugin authors to check permissions and build safe bridge requests. +- Add platform validators/services/API routes for bridge session and action authorization. +- Keep plugin pages mediated by platform APIs instead of direct run, socket, host path, credential, artifact storage, or AI provider access. +- Add tests proving forbidden bridge actions fail and allowed scoped actions pass. + +**Non-Goals:** + +- Do not implement plugin iframe loading or a complete marketplace UI in this change. +- Do not execute plugin lifecycle actions or server workflows end to end. +- Do not expose raw AI provider keys, run credentials, direct sockets, raw host paths, or storage backend endpoints. +- Do not add billing, cloud host sales, provider marketplace, or unrelated SaaS marketplace behavior. +- Do not introduce cross-root runtime imports; mirrored contracts remain explicit at ownership boundaries. + +## Decisions + +### Decision 1: Bridge permissions are evaluated from manifest metadata + +The platform will authorize bridge actions from the installed plugin metadata produced by manifest registration. Requests include plugin ID, route key, optional server instance ID, action name, and purpose-specific payload metadata. The platform checks that the requested action maps to declared plugin permissions and AI purposes before returning an allowed decision. + +Alternative considered: trust the browser SDK to decide permission outcomes. Rejected because plugin UI code is not a security boundary and can be modified by authors or users. + +### Decision 2: Use narrow bridge action names instead of generic RPC + +The bridge contract will model first-party action names such as `server.instances.read`, `jobs.dispatch`, `logs.query`, `artifacts.open`, `files.request`, and `ai.invoke`. Each action maps to specific permission requirements. + +Alternative considered: expose a generic `api.request` bridge that proxies arbitrary platform paths. Rejected because arbitrary request forwarding makes permission reviews harder and risks exposing unrelated platform APIs to plugin pages. + +### Decision 3: SDK helpers create typed envelopes, not direct transport + +The SDK will provide types, permission helpers, request builders, and runtime guards. It will not own network transport or platform authentication. `platform_web/` host code can later use the same envelopes to communicate with embedded plugin pages. + +Alternative considered: ship a full SDK client that calls platform APIs directly from plugin page code. Rejected because plugin pages must remain behind the platform host bridge and must not receive raw auth storage. + +### Decision 4: Duplicate root-owned contract shapes deliberately + +`plugins/`, `platform_web/`, and `platform/` will each own local contract definitions that mirror the bridge surface they need. Tests and OpenSpec requirements keep the shapes aligned until a future generated contract package exists. + +Alternative considered: import TypeScript SDK types directly into the frontend or backend. Rejected because the repository rules require explicit contract packages or copied/generated contracts instead of casual cross-root imports. + +### Decision 5: AI bridge requests carry purposes, not provider configuration + +`ai.invoke` bridge requests will include an AI purpose and bounded input metadata. Platform validation confirms the purpose is allowed by the plugin manifest. Provider base URLs, API keys, model routing, and raw responses remain controlled by platform services. + +Alternative considered: let plugin pages choose provider IDs or submit provider credentials. Rejected because AI provider keys and routing policy belong to `platform/`. + +## Risks / Trade-offs + +- [Risk] Mirrored TypeScript and Go action constants can drift. Mitigation: add focused tests and keep the action/permission matrix small until generated contracts are introduced. +- [Risk] Early bridge action names may be too coarse for later workflows. Mitigation: keep request payloads metadata-only where possible and add new actions through explicit OpenSpec changes. +- [Risk] Browser host work may need iframe lifecycle decisions later. Mitigation: this change establishes only host-side contracts/utilities, leaving page loading to the console-shell change. +- [Risk] Permission checks can become duplicated between platform and host utilities. Mitigation: platform remains authoritative; host checks are UX preflight only. + +## Migration Plan + +1. Add the bridge contract and permission matrix in OpenSpec. +2. Extend plugin manifest schema/types and the development example with bridge page requirements. +3. Add SDK helpers and tests for typed bridge envelopes and local permission checks. +4. Add platform bridge DTO/domain/validator/service/API route with tests for authorization decisions. +5. Add platform_web bridge host types/utilities and tests for safe context construction. +6. Validate with plugin tests, frontend tests, platform tests, `scripts/check-structure.sh`, and strict OpenSpec validation. + +Rollback is straightforward before downstream UI depends on it: remove the bridge API route, SDK helpers, frontend host utilities, manifest bridge fields, and this change's OpenSpec artifacts. + +## Open Questions + +- Whether future plugin page loading should use iframe `postMessage`, module federation, static asset hosting, or another sandbox strategy. +- Whether a later generated contract package should replace copied bridge action constants across roots. diff --git a/openspec/changes/implement-plugin-bridge-and-sdk/proposal.md b/openspec/changes/implement-plugin-bridge-and-sdk/proposal.md new file mode 100644 index 0000000..fd4fe1e --- /dev/null +++ b/openspec/changes/implement-plugin-bridge-and-sdk/proposal.md @@ -0,0 +1,28 @@ +## Why + +Validated plugin manifests can now register game management plugins, but plugin pages still lack a safe runtime bridge and authors lack a typed SDK for calling platform-mediated abilities. This change establishes the browser-side plugin boundary needed before the plugin marketplace and server workflows can host real plugin UI. + +## What Changes + +- Add a plugin page bridge contract that exposes only scoped platform abilities to plugin UI code. +- Add a TypeScript plugin SDK with bridge message types, permission checks, request/response helpers, and safe error handling. +- Add platform API support for validating plugin bridge sessions and evaluating requested actions against manifest permissions. +- Update the example development plugin to declare bridge requirements and exercise the SDK without direct run, host path, socket, credential, or raw AI key access. +- Add focused tests for bridge permission decisions, SDK type/runtime behavior, and platform validators. + +## Capabilities + +### New Capabilities + +- `plugin-bridge-and-sdk`: Defines the platform-mediated plugin page bridge, SDK contract, permission enforcement, and safe capability surface for game management plugin pages. + +### Modified Capabilities + +- None. + +## Impact + +- `plugins/`: SDK source, bridge contracts, manifest/schema additions, example plugin declarations, and TypeScript tests. +- `platform/`: bridge session/action DTOs, domain types, validators, service logic, API route, and Go tests. +- `platform_web/`: bridge host contracts/utilities and tests that can later be used by plugin marketplace pages. +- OpenSpec artifacts and validation for the new `plugin-bridge-and-sdk` capability. diff --git a/openspec/changes/implement-plugin-bridge-and-sdk/specs/plugin-bridge-and-sdk/spec.md b/openspec/changes/implement-plugin-bridge-and-sdk/specs/plugin-bridge-and-sdk/spec.md new file mode 100644 index 0000000..91cccc5 --- /dev/null +++ b/openspec/changes/implement-plugin-bridge-and-sdk/specs/plugin-bridge-and-sdk/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: Plugin bridge exposes only platform-mediated actions + +The system SHALL define a plugin page bridge with narrow action names for server context reads, job dispatch, log queries, artifact references, scoped file requests, and platform-mediated AI invocation. + +#### Scenario: Plugin page requests allowed bridge action + +- **WHEN** a plugin page requests a bridge action declared by the bridge contract and permitted by its manifest metadata +- **THEN** the platform authorizes the request without exposing run credentials, raw host paths, direct sockets, storage backend credentials, platform auth storage, or AI provider keys + +#### Scenario: Plugin page requests unsupported bridge action + +- **WHEN** a plugin page requests an action outside the bridge contract +- **THEN** the platform rejects the request with a validation error before dispatching any run, file, log, artifact, or AI work + +### Requirement: Bridge permissions are enforced from manifest metadata + +The system SHALL evaluate each bridge action against the installed plugin's declared permissions, page permissions, and AI purposes before allowing the action. + +#### Scenario: Missing permission denies bridge action + +- **WHEN** a plugin page requests `files.request` without the required file permission in its manifest or page permissions +- **THEN** the platform returns a denied bridge authorization decision and does not create a file job + +#### Scenario: Allowed AI purpose authorizes AI request + +- **WHEN** a plugin page requests `ai.invoke` with an AI purpose declared by the plugin manifest and the plugin has `ai.invoke` permission +- **THEN** the platform returns an allowed bridge authorization decision without exposing provider base URLs or API keys + +#### Scenario: Undeclared AI purpose denies AI request + +- **WHEN** a plugin page requests `ai.invoke` with a purpose not declared by the plugin manifest +- **THEN** the platform rejects or denies the request before invoking any AI provider + +### Requirement: Plugin SDK provides typed bridge helpers + +The plugin SDK SHALL provide TypeScript types and helpers for bridge context, bridge action names, bridge request envelopes, bridge responses, permission checks, and safe errors. + +#### Scenario: SDK builds typed bridge request + +- **WHEN** plugin author code builds a request for a supported bridge action through SDK helpers +- **THEN** the request envelope includes plugin ID, route key, action, request ID, and scoped payload fields that can be validated by the platform host + +#### Scenario: SDK detects missing local permission + +- **WHEN** plugin author code checks a bridge context for a missing permission +- **THEN** the SDK helper returns a negative result without performing transport or privileged work + +### Requirement: Browser host creates safe bridge context + +The frontend host SHALL construct plugin bridge context from installed plugin metadata, current route, selected server instance, safe theme tokens, and effective permissions only. + +#### Scenario: Host context omits secrets + +- **WHEN** the browser host creates bridge context for a plugin page +- **THEN** the context excludes raw AI keys, platform auth storage, run credentials, direct sockets, raw host paths, and storage backend credentials + +#### Scenario: Host filters page permissions + +- **WHEN** a plugin page declares route-specific permissions +- **THEN** the host context contains only permissions allowed by both the plugin manifest and the current page declaration + +### Requirement: Bridge implementation respects root ownership boundaries + +The system SHALL keep plugin SDK, frontend host utilities, and platform authorization logic in their owning roots without casual cross-root imports. + +#### Scenario: Contracts are copied through explicit root files + +- **WHEN** bridge action or permission contracts are needed in multiple roots +- **THEN** each root owns an explicit local contract file or generated/copy artifact instead of importing implementation code from another root + +#### Scenario: Structure validation passes after bridge implementation + +- **WHEN** the bridge and SDK implementation is complete +- **THEN** repository structure validation passes without placing implementation code outside `plugins/`, `platform_web/`, or `platform/` diff --git a/openspec/changes/implement-plugin-bridge-and-sdk/tasks.md b/openspec/changes/implement-plugin-bridge-and-sdk/tasks.md new file mode 100644 index 0000000..e5acacd --- /dev/null +++ b/openspec/changes/implement-plugin-bridge-and-sdk/tasks.md @@ -0,0 +1,38 @@ +## 1. Plugin SDK and Manifest Contract + +- [x] 1.1 Extend plugin manifest schema, SDK types, and the development example with bridge action/page declarations. +- [x] 1.2 Add SDK bridge action, request envelope, response, safe error, and permission helper types. +- [x] 1.3 Add plugin SDK tests for request builders, local permission checks, and forbidden transport assumptions. + +## 2. Platform Bridge Authorization + +- [x] 2.1 Add platform domain and DTO types for plugin bridge sessions, actions, requests, and authorization decisions. +- [x] 2.2 Add platform validators that map bridge actions to required permissions and AI purposes. +- [x] 2.3 Add platform service logic and API route for bridge action authorization. +- [x] 2.4 Add Go tests for allowed actions, missing permissions, unsupported actions, and undeclared AI purposes. + +## 3. Frontend Host Bridge Utilities + +- [x] 3.1 Add frontend bridge host contract/types for safe plugin page context. +- [x] 3.2 Add frontend utilities that filter page permissions against manifest permissions and exclude secret-bearing fields. +- [x] 3.3 Add frontend tests for safe context creation and page permission filtering. + +## 4. Verification + +- [x] 4.1 Run plugin SDK/schema tests. +- [x] 4.2 Run platform bridge authorization tests. +- [x] 4.3 Run platform_web bridge utility tests. +- [x] 4.4 Run `scripts/check-structure.sh`. +- [x] 4.5 Run `openspec validate implement-plugin-bridge-and-sdk --strict`. +- [x] 4.6 Record verification evidence in this task file and only then mark verification tasks complete. + +## Evidence + +- `cd platform && go test ./...`: passed. +- `cd plugins && npm test`: passed, 1 file / 7 tests. +- `cd plugins && npm run typecheck`: passed. +- `cd plugins && npm run validate:manifest`: passed for `examples/dev-game-plugin/manifest.json`. +- `cd platform_web && npm test`: passed, 4 files / 7 tests. +- `cd platform_web && npm run typecheck`: passed. +- `scripts/check-structure.sh`: passed. +- `openspec validate implement-plugin-bridge-and-sdk --strict`: passed. diff --git a/openspec/changes/implement-plugin-marketplace-api-driven-ui/.openspec.yaml b/openspec/changes/implement-plugin-marketplace-api-driven-ui/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/implement-plugin-marketplace-api-driven-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/implement-plugin-marketplace-api-driven-ui/design.md b/openspec/changes/implement-plugin-marketplace-api-driven-ui/design.md new file mode 100644 index 0000000..39d0bfb --- /dev/null +++ b/openspec/changes/implement-plugin-marketplace-api-driven-ui/design.md @@ -0,0 +1,60 @@ +## Context + +The platform can validate and register game plugin manifests, and the frontend already has the first-party Plugin Marketplace area. The remaining gap is that the page is not yet an authoritative view over platform registry data. This change makes the marketplace page API-driven while keeping commerce concepts out of scope. + +## Goals / Non-Goals + +**Goals:** + +- Expose marketplace list and detail data from platform registry metadata. +- Let operators filter installed game management plugins by status, server type, keyword, and capability. +- Allow safe install/enable/disable state changes without exposing implementation internals. +- Centralize frontend API types and client methods outside page-local hidden types. +- Add tests and browser walkthrough evidence for loading, errors, filtering, install/state actions, and no-secret rendering. + +**Non-Goals:** + +- No billing, pricing, cloud host sales, provider marketplace, ratings, reviews, checkout, or subscription behavior. +- No plugin page execution, bridge transport, run-side action execution, or AI invocation implementation. +- No raw host paths, direct run sockets, raw credentials, or raw AI provider keys in responses. +- No new visual system that replaces the platform_web theme. + +## Decisions + +### Decision 1: Marketplace data is a view over installed registry metadata + +Marketplace APIs will project installed plugin metadata into list/detail DTOs. The response includes identity, version, server type, status, description, pages, capabilities, permissions, and AI purposes, but not commerce data or backend internals. + +Alternative considered: create a separate catalog model with publish/store metadata. Rejected because this repository is a game server management platform, and current needs are covered by installed registry data. + +### Decision 2: Install and state actions remain metadata-only + +Install/enable/disable actions update platform plugin registry state and return the updated marketplace DTO. They do not download external packages or execute run jobs in this change. + +Alternative considered: trigger package download and runtime deployment from the marketplace page. Rejected because package acquisition and run execution require separate explicit changes. + +### Decision 3: Frontend fallback is isolated to development + +The page may keep a clearly isolated local fallback for standalone frontend development, but production flow prefers API data and shows API errors. Tests assert fallback does not leak into successful API flows. + +Alternative considered: remove all fallback state immediately. Rejected because local frontend demos still need useful data when the backend is absent. + +## Risks / Trade-offs + +- [Risk] Marketplace APIs duplicate some game plugin list/detail behavior. Mitigation: implement them as service projections over the same registry metadata. +- [Risk] Install state can be confused with package acquisition. Mitigation: name docs and DTO fields around installed/active registry state only. +- [Risk] Frontend state can drift from backend after actions. Mitigation: action methods return updated DTOs and tests cover refresh/update behavior. + +## Migration Plan + +1. Add platform marketplace DTOs, validators, service projection, routes, and docs. +2. Update frontend API contracts and marketplace page to consume API data. +3. Add backend and frontend tests. +4. Run browser walkthrough, structure check, and strict OpenSpec validation. + +Rollback before dependent changes is removal of marketplace projection routes/page API integration and this change's artifacts. + +## Open Questions + +- Whether future plugin package publication should use signed artifacts or an internal admin upload flow. +- Whether marketplace sorting should later incorporate operational health or compatibility scores. diff --git a/openspec/changes/implement-plugin-marketplace-api-driven-ui/proposal.md b/openspec/changes/implement-plugin-marketplace-api-driven-ui/proposal.md new file mode 100644 index 0000000..27f6bc8 --- /dev/null +++ b/openspec/changes/implement-plugin-marketplace-api-driven-ui/proposal.md @@ -0,0 +1,27 @@ +## Why + +The plugin registry and bridge contracts exist, but the plugin marketplace page still needs to be driven by platform API data instead of hardcoded catalog state. Operators need to browse installed game management plugins, inspect manifest-backed capabilities, and start install/enable workflows without exposing host paths, run sockets, credentials, or raw AI provider keys. + +## What Changes + +- Add marketplace-focused API responses derived from installed game plugin registry metadata. +- Add platform service and handler behavior for listing marketplace plugins, viewing detail, and changing install/enable state through safe metadata workflows. +- Replace production hardcoded marketplace data in `platform_web` with API client calls, typed contracts, loading/error states, filters, and plugin detail/install actions. +- Preserve the magical-girl crystal-moonlight console visual direction while keeping the marketplace operational and game-management focused. +- Add backend, frontend, browser walkthrough, structure, and strict OpenSpec verification. + +## Capabilities + +### New Capabilities + +- `plugin-marketplace-api-driven-ui`: Platform and frontend workflows for rendering the plugin marketplace from registry APIs and managing installed plugin state safely. + +### Modified Capabilities + +- Builds on `plugin-registry-and-manifest-validation` and `plugin-bridge-and-sdk`; it does not change their archived contracts directly. + +## Impact + +- Affects `platform/` game plugin DTOs, service, validators, API routes, and docs. +- Affects `platform_web/` API types/client methods, plugin marketplace page/components, and tests. +- Does not add billing, cloud host sales, provider marketplaces, unrelated SaaS marketplace behavior, direct plugin-run access, or raw key exposure. diff --git a/openspec/changes/implement-plugin-marketplace-api-driven-ui/specs/plugin-marketplace-api-driven-ui/spec.md b/openspec/changes/implement-plugin-marketplace-api-driven-ui/specs/plugin-marketplace-api-driven-ui/spec.md new file mode 100644 index 0000000..3fa151d --- /dev/null +++ b/openspec/changes/implement-plugin-marketplace-api-driven-ui/specs/plugin-marketplace-api-driven-ui/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Marketplace APIs expose registry-backed plugin catalog data + +The platform SHALL expose marketplace list and detail APIs derived from installed game plugin registry metadata. + +#### Scenario: Marketplace list returns installed plugin data +- **WHEN** a client requests marketplace plugins with optional status, server type, capability, or keyword filters +- **THEN** the platform MUST return bounded plugin summaries with identity, version, display metadata, server type, install status, capabilities, pages, permissions, and AI purposes + +#### Scenario: Marketplace detail returns safe plugin metadata +- **WHEN** a client requests one marketplace plugin detail +- **THEN** the platform MUST return manifest-backed metadata and MUST NOT expose raw host paths, direct run sockets, raw credentials, platform auth storage, storage backend credentials, or raw AI provider keys + +### Requirement: Marketplace state actions are platform-mediated + +The platform SHALL provide safe marketplace actions for changing installed plugin state without external package download or run execution. + +#### Scenario: Plugin is enabled or disabled +- **WHEN** an operator enables or disables a marketplace plugin +- **THEN** the platform MUST validate the plugin ID, update registry state, and return the redacted marketplace plugin detail + +#### Scenario: Unknown plugin action is rejected +- **WHEN** an operator submits a state action for a missing plugin ID or unsupported action +- **THEN** the platform MUST return a stable JSON error and MUST NOT change other plugin state + +### Requirement: Marketplace frontend uses centralized API contracts + +The frontend SHALL keep marketplace API types and client methods in `platform_web/api` and SHALL use them from the plugin marketplace page. + +#### Scenario: Page loads marketplace data +- **WHEN** the Plugin Marketplace page renders with a reachable backend +- **THEN** it MUST fetch plugin summaries from the marketplace API and render loading, error, empty, and populated states + +#### Scenario: Page filters and opens detail +- **WHEN** an operator filters or selects a plugin +- **THEN** the page MUST use API-backed data to update the list/detail view without relying on hidden page-local DTO contracts + +### Requirement: Marketplace UI preserves safety and theme constraints + +The Plugin Marketplace page SHALL preserve the platform_web magical-girl crystal-moonlight operations console style and avoid unsafe or unrelated marketplace concepts. + +#### Scenario: UI renders plugin cards and actions +- **WHEN** marketplace data is displayed +- **THEN** the UI MUST show game plugin metadata, capability tags, status controls, and install/enable actions without billing, cloud host sales, provider marketplace, raw secrets, or generic SaaS storefront behavior + +#### Scenario: Browser walkthrough verifies no secret rendering +- **WHEN** frontend marketplace behavior is claimed complete +- **THEN** a browser walkthrough MUST verify the page renders API-backed plugin data and visible text excludes raw credential markers + +### Requirement: Marketplace implementation is verified + +The change SHALL include backend tests, frontend tests/build, browser walkthrough evidence, structure validation, and strict OpenSpec validation. + +#### Scenario: Verification commands pass +- **WHEN** the change is complete +- **THEN** platform tests, platform_web tests/typecheck/build, `scripts/check-structure.sh`, and `openspec validate implement-plugin-marketplace-api-driven-ui --strict` MUST pass diff --git a/openspec/changes/implement-plugin-marketplace-api-driven-ui/tasks.md b/openspec/changes/implement-plugin-marketplace-api-driven-ui/tasks.md new file mode 100644 index 0000000..24636fe --- /dev/null +++ b/openspec/changes/implement-plugin-marketplace-api-driven-ui/tasks.md @@ -0,0 +1,42 @@ +## 1. Platform Marketplace Contracts + +- [x] 1.1 Add marketplace plugin summary/detail DTOs and domain projection contracts from registered plugin metadata. +- [x] 1.2 Add validators for marketplace filters, plugin IDs, bounded list responses, supported state actions, and response safety. +- [x] 1.3 Add service methods for marketplace list, detail, install-state projection, and enable/disable actions. + +## 2. Platform Marketplace API + +- [x] 2.1 Implement marketplace list and detail routes under the platform API surface. +- [x] 2.2 Implement safe install/enable/disable state action routes without package download or run execution. +- [x] 2.3 Update platform route/protocol documentation for marketplace APIs and deferred package/runtime behavior. +- [x] 2.4 Add platform service/API tests for filters, detail, state actions, missing plugins, unsupported actions, and no-secret responses. + +## 3. Frontend Marketplace API Integration + +- [x] 3.1 Add centralized `platform_web/api` marketplace types and `PlatformApiClient` methods. +- [x] 3.2 Update Plugin Marketplace page to load API data, support filters/search/detail, and render loading/error/empty/populated states. +- [x] 3.3 Wire install/enable/disable controls to API actions and update page state from API responses. +- [x] 3.4 Isolate any local fallback data to standalone development and keep production API flow authoritative. +- [x] 3.5 Add frontend tests for loading, errors, filters, detail selection, state actions, and no raw key/path rendering. + +## 4. Verification + +- [x] 4.1 Run `cd platform && go test ./...` and record evidence. +- [x] 4.2 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. +- [x] 4.3 Run browser walkthrough for the Plugin Marketplace API-driven page and record evidence. +- [x] 4.4 Run `scripts/check-structure.sh` and record evidence. +- [x] 4.5 Run `openspec validate implement-plugin-marketplace-api-driven-ui --strict` and record evidence. + +## Evidence + +- 2026-07-06: `cd platform && go test ./domain ./dto ./validator ./service ./api` passed after adding marketplace contracts, validators, service methods, and routes. +- 2026-07-06: `cd platform && go test ./service ./api -run 'TestCoreServiceMarketplacePluginsAreFilteredSafeAndStateful|TestPluginMarketplaceAPIListsDetailsAndChangesStateSafely|TestGamePluginManifestRegistryAPI'` passed, covering filters, detail, install/enable/disable state actions, missing plugins, unsupported actions, unsafe filters, and no-secret API response assertions. +- 2026-07-06: `cd platform_web && npm run typecheck` passed after adding marketplace API types/client methods and the API-driven Plugins page. +- 2026-07-06: `cd platform_web && npm test -- --run api/client.test.ts pages/PluginsPage.test.tsx pages/ConsolePages.test.tsx` passed, covering marketplace client URLs/actions, loading/error/API-backed detail rendering, standalone fallback labeling, state controls, and no raw key/path fragments. +- 2026-07-06: `cd platform && go test ./...` passed. +- 2026-07-06: `cd platform_web && npm test` passed with 10 files / 36 tests. +- 2026-07-06: `cd platform_web && npm run typecheck` passed. +- 2026-07-06: `cd platform_web && npm run build` passed. +- 2026-07-06: Browser walkthrough against `http://127.0.0.1:5176/#/plugins` and platform API `127.0.0.1:18089` rendered API-backed `Example Server`, `logs.query`, `平台 API`, successfully applied the disable state action, and verified `/Users/`, `unix://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, and `billing` were absent from visible text. +- 2026-07-06: `scripts/check-structure.sh` passed. +- 2026-07-06: `openspec validate implement-plugin-marketplace-api-driven-ui --strict` passed; PostHog telemetry flush logged a restricted-network DNS error after local validation succeeded. diff --git a/openspec/changes/implement-plugin-page-bridge-execution/.openspec.yaml b/openspec/changes/implement-plugin-page-bridge-execution/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/implement-plugin-page-bridge-execution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/implement-plugin-page-bridge-execution/design.md b/openspec/changes/implement-plugin-page-bridge-execution/design.md new file mode 100644 index 0000000..8510bd0 --- /dev/null +++ b/openspec/changes/implement-plugin-page-bridge-execution/design.md @@ -0,0 +1,60 @@ +## Context + +`implement-plugin-bridge-and-sdk` defines typed bridge envelopes and authorization decisions, but execution is still theoretical. This change makes plugin pages useful in the console by adding the host-side transport and backend execution adapter for allowed bridge actions. The platform remains authoritative: browser host checks improve UX, but backend validation decides whether a request can execute. + +## Goals / Non-Goals + +**Goals:** + +- Create safe plugin bridge sessions in `platform_web` from registry/page/server context. +- Dispatch plugin page bridge requests through centralized frontend API client methods. +- Add backend execution behavior for supported bridge actions by calling existing platform services instead of proxying arbitrary API paths. +- Return typed success/error envelopes to plugin pages. +- Add tests and browser walkthrough evidence for allowed actions, denied actions, and no secret exposure. + +**Non-Goals:** + +- No arbitrary HTTP proxy from plugin pages to platform APIs. +- No direct run sockets, host paths, raw credentials, auth storage, artifact storage credentials, or raw AI provider keys in plugin page context or responses. +- No iframe sandbox policy overhaul beyond what is necessary for bridge host execution. +- No package download, plugin marketplace commerce, billing, cloud host sales, or provider marketplace behavior. + +## Decisions + +### Decision 1: Backend execution uses an action switch over safe services + +Bridge execution maps each supported action to a named backend service method, such as server context reads, job dispatch, log queries, artifact open/download metadata, scoped file dispatch, or platform-mediated AI invocation. Unsupported actions fail before side effects. + +Alternative considered: accept a plugin-supplied URL/path and proxy it. Rejected because arbitrary proxying bypasses reviewable permission mapping. + +### Decision 2: Host context is short-lived and derived + +The frontend host builds session context from selected server instance, plugin page metadata, safe theme tokens, and effective permissions. It does not persist platform auth tokens or raw backend state in plugin page-visible structures. + +Alternative considered: pass the normal platform API client or auth storage into plugin pages. Rejected because plugin code is not a credential boundary. + +### Decision 3: Execution envelopes are typed and auditable + +Every bridge execution request carries request ID, plugin ID, route key, action, server instance scope, purpose metadata, and bounded payload. Backend responses include allowed/denied state, typed result, and safe error details. + +Alternative considered: reuse authorization-only DTOs for execution results. Rejected because execution needs result references and error details distinct from preflight authorization. + +## Risks / Trade-offs + +- [Risk] Supported bridge action behavior depends on other platform capabilities. Mitigation: actions whose downstream service is not available return explicit unsupported/deferred errors and tests cover the available set. +- [Risk] Browser host checks can be bypassed. Mitigation: backend validation repeats all permission and safety checks. +- [Risk] Plugin page UI can create noisy request loops. Mitigation: host utilities include request bounds and cancellation/error state tests. + +## Migration Plan + +1. Add bridge execution DTOs/domain/validators/services/routes in platform. +2. Add frontend host transport/API client/session utilities and tests. +3. Update plugin SDK/example tests to use execution envelopes. +4. Update docs and run full verification including browser walkthrough. + +Rollback removes bridge execution routes, host transport utilities, SDK example changes, and this change's artifacts before downstream plugin pages depend on it. + +## Open Questions + +- Which sandbox mechanism should eventually host third-party plugin page assets in production? +- Whether bridge execution audit events should be a separate observability change. diff --git a/openspec/changes/implement-plugin-page-bridge-execution/proposal.md b/openspec/changes/implement-plugin-page-bridge-execution/proposal.md new file mode 100644 index 0000000..e4a4f54 --- /dev/null +++ b/openspec/changes/implement-plugin-page-bridge-execution/proposal.md @@ -0,0 +1,27 @@ +## Why + +The plugin SDK and authorization contract exist, but plugin pages still cannot execute real platform-mediated bridge actions from the management console. Operators need plugin UI pages to request allowed server, job, log, artifact, file, and AI capabilities through a host bridge that keeps platform auth, run sockets, host paths, and provider credentials hidden. + +## What Changes + +- Add frontend plugin page host execution utilities for creating safe bridge sessions and dispatching bridge action requests through platform APIs. +- Add platform bridge execution routes/services that authorize each request and fan out only to existing safe platform capabilities. +- Add plugin SDK/example coverage for request envelopes and host-mediated response/error handling. +- Update plugin page documentation and tests to prove unsupported actions, missing permissions, raw paths, sockets, credentials, and raw AI keys are rejected. +- Add browser walkthrough for an embedded plugin page workflow. + +## Capabilities + +### New Capabilities + +- `plugin-page-bridge-execution`: Executes plugin page bridge requests through the platform host and backend authorization layer without exposing unsafe internals. + +### Modified Capabilities + +- Builds on `plugin-bridge-and-sdk`, `config-write-and-file-dispatch`, `platform-mediated-ai-invocation`, and artifact/log/job capabilities as they become available. + +## Impact + +- Affects `platform/`, `platform_web/`, and `plugins/`. +- Adds backend DTO/service/API behavior for bridge execution, frontend bridge host transport, SDK/example tests, and documentation. +- Does not add direct plugin-to-run access, direct platform auth sharing, raw credentials, host path exposure, billing, cloud host sales, or unrelated marketplace features. diff --git a/openspec/changes/implement-plugin-page-bridge-execution/specs/plugin-page-bridge-execution/spec.md b/openspec/changes/implement-plugin-page-bridge-execution/specs/plugin-page-bridge-execution/spec.md new file mode 100644 index 0000000..914c609 --- /dev/null +++ b/openspec/changes/implement-plugin-page-bridge-execution/specs/plugin-page-bridge-execution/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Plugin page host creates safe executable bridge sessions + +The frontend SHALL create plugin page bridge sessions from installed plugin metadata, selected route, selected server instance, safe theme tokens, and effective permissions only. + +#### Scenario: Host session omits secrets +- **WHEN** a plugin page bridge session is created +- **THEN** the session context MUST omit raw platform auth storage, raw AI keys, provider base URL secrets, run credentials, direct sockets, raw host paths, and storage backend credentials + +#### Scenario: Host session filters permissions +- **WHEN** a plugin page declares route-specific permissions +- **THEN** the host MUST include only permissions allowed by both plugin manifest metadata and the page declaration + +### Requirement: Plugin page bridge requests execute through platform APIs + +The frontend SHALL dispatch plugin page bridge action requests through centralized platform API client methods rather than direct plugin fetches to arbitrary backend paths. + +#### Scenario: Allowed request is dispatched +- **WHEN** a plugin page sends a supported action with required permissions and bounded payload +- **THEN** the host MUST submit a typed bridge execution request to the platform and return a typed bridge response to the plugin page + +#### Scenario: Unsupported request is rejected locally or by platform +- **WHEN** a plugin page sends an unsupported action or unsafe payload +- **THEN** the host or platform MUST return a safe error envelope and MUST NOT dispatch run, file, artifact, log, job, or AI work + +### Requirement: Platform authorizes and executes supported bridge actions + +The platform SHALL authorize every bridge execution request against plugin metadata and execute only supported platform-mediated actions. + +#### Scenario: Missing permission prevents execution +- **WHEN** a plugin page requests an action without the required manifest/page permission +- **THEN** the platform MUST deny the request before side effects occur + +#### Scenario: Allowed job dispatch request creates platform job +- **WHEN** a plugin page requests an allowed job dispatch action with a valid server scope +- **THEN** the platform MUST create or return a platform-mediated job reference without exposing run sockets, credentials, or host paths + +#### Scenario: Allowed file request uses scoped dispatch +- **WHEN** a plugin page requests an allowed file action +- **THEN** the platform MUST use scoped file/config dispatch semantics and MUST NOT accept raw absolute host paths + +### Requirement: Bridge execution responses are safe and typed + +The system SHALL return bridge execution responses as typed success or error envelopes with redacted result references. + +#### Scenario: Execution succeeds +- **WHEN** a supported bridge action completes or queues work +- **THEN** the response MUST include request ID, action, status, and scoped result references without raw secrets or direct storage/run internals + +#### Scenario: Execution fails +- **WHEN** validation, authorization, downstream service, or cancellation fails +- **THEN** the response MUST include a safe error code/message and MUST NOT include raw credentials, host paths, sockets, or provider keys + +### Requirement: Plugin page bridge execution is verified end to end + +The change SHALL include backend tests, frontend tests/build, plugin SDK/example tests, browser walkthrough evidence, structure validation, and strict OpenSpec validation. + +#### Scenario: Verification commands pass +- **WHEN** the change is complete +- **THEN** platform tests, platform_web tests/typecheck/build, plugin tests/typecheck, `scripts/check-structure.sh`, and `openspec validate implement-plugin-page-bridge-execution --strict` MUST pass diff --git a/openspec/changes/implement-plugin-page-bridge-execution/tasks.md b/openspec/changes/implement-plugin-page-bridge-execution/tasks.md new file mode 100644 index 0000000..0864b49 --- /dev/null +++ b/openspec/changes/implement-plugin-page-bridge-execution/tasks.md @@ -0,0 +1,46 @@ +## 1. Platform Bridge Execution Contracts + +- [x] 1.1 Add platform domain and DTO contracts for bridge execution requests, responses, result refs, and safe errors. +- [x] 1.2 Add validators for action support, required permissions, page route scope, server scope, payload bounds, AI purposes, and unsafe path/secret/socket content. +- [x] 1.3 Add service methods that authorize and execute supported bridge actions through existing platform services. + +## 2. Platform Bridge Execution API + +- [x] 2.1 Implement bridge execution route using named DTOs and service methods. +- [x] 2.2 Map supported actions to safe service calls for server context, job dispatch, logs, artifacts, scoped files, and platform-mediated AI where available. +- [x] 2.3 Update platform route/protocol documentation for plugin bridge execution and deferred unsupported actions. +- [x] 2.4 Add platform tests for allowed execution, denied permissions, unsupported actions, unsafe payloads, safe errors, and no-secret responses. + +## 3. Frontend Host Execution + +- [x] 3.1 Add centralized frontend API types/client methods for bridge execution. +- [x] 3.2 Add plugin page host session and request dispatcher utilities that construct safe context and return typed envelopes. +- [x] 3.3 Update plugin page host UI flow to use bridge execution utilities for embedded plugin actions. +- [x] 3.4 Add frontend tests for session safety, permission filtering, allowed dispatch, denied dispatch, cancellation/error states, and no raw secret rendering. + +## 4. Plugin SDK And Example + +- [x] 4.1 Extend plugin SDK helpers and example plugin page code to exercise bridge execution envelopes. +- [x] 4.2 Add plugin tests for execution request builders, safe error parsing, and forbidden direct transport assumptions. + +## 5. Verification + +- [x] 5.1 Run `cd platform && go test ./...` and record evidence. +- [x] 5.2 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. +- [x] 5.3 Run `cd plugins && npm run typecheck && npm test` and record evidence. +- [x] 5.4 Run browser walkthrough for plugin page bridge execution and record evidence. +- [x] 5.5 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.6 Run `openspec validate implement-plugin-page-bridge-execution --strict` and record evidence. + +## Evidence + +- 2026-07-06: `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -run TestPluginBridgeExecuteAPI -count=1` passed for bridge execution route, service mappings, safe errors, unsafe payload rejection, and no-secret response checks. +- 2026-07-06: `cd platform_web && npm run typecheck` passed after adding bridge execution API types/client, host dispatcher, and server detail execution panel. +- 2026-07-06: `cd platform_web && npm test -- --run utils/pluginBridgeHost.test.ts api/client.test.ts` passed, covering safe host context, permission filtering, allowed dispatch, unsafe/denied/cancelled states, and API client routing. +- 2026-07-06: `cd plugins && npm run typecheck` and `cd plugins && npm test -- --run tests/manifest-validation.test.ts` passed for SDK execution envelope helpers and no direct transport assumptions. +- 2026-07-06: `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./...` passed. +- 2026-07-06: `cd platform_web && npm run typecheck`, `cd platform_web && npm test`, and `cd platform_web && npm run build` passed. +- 2026-07-06: `cd plugins && npm run typecheck` and `cd plugins && npm test` passed. +- 2026-07-06: Browser walkthrough passed using a local mock platform API plus headless Chrome: logged in, opened `#/servers/server-bridge-walkthrough`, switched to `插件控制`, clicked `读取上下文`, and verified `服务器上下文 server-bridge-walkthrough 已返回` with no forbidden fragments rendered. +- 2026-07-06: `scripts/check-structure.sh` passed. +- 2026-07-06: `openspec validate implement-plugin-page-bridge-execution --strict` passed (`Change 'implement-plugin-page-bridge-execution' is valid`; PostHog DNS flush warnings were non-fatal telemetry failures). diff --git a/openspec/changes/implement-plugin-registry-and-manifest-validation/.openspec.yaml b/openspec/changes/implement-plugin-registry-and-manifest-validation/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/implement-plugin-registry-and-manifest-validation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/implement-plugin-registry-and-manifest-validation/design.md b/openspec/changes/implement-plugin-registry-and-manifest-validation/design.md new file mode 100644 index 0000000..b232258 --- /dev/null +++ b/openspec/changes/implement-plugin-registry-and-manifest-validation/design.md @@ -0,0 +1,68 @@ +## Context + +The platform already has a basic `GamePlugin` resource in `platform/` and an initial JSON Schema validator in `plugins/`. Those pieces are not yet enough for the plugin marketplace and server creation backlog because registration can still be assembled by hand instead of coming from a validated game management plugin manifest. + +This change covers the first registry boundary between `plugins/` and `platform/`: plugin authors validate manifest files in the plugin workspace, while the platform accepts a structured manifest registration payload, validates the same safety constraints in backend validators, stores only registry metadata, and exposes that metadata through the existing game plugin APIs. + +## Goals / Non-Goals + +**Goals:** + +- Define one concrete manifest contract for game management plugins. +- Reject unsafe manifest requests for raw run, host path, credential, socket, and raw AI key access. +- Register installed plugins from manifest metadata through platform DTO/service/API layers. +- Keep registry responses useful for plugin marketplace display without exposing raw paths, raw credentials, run internals, or AI provider keys. +- Add focused schema, validator, service, and API tests. + +**Non-Goals:** + +- Do not implement plugin page bridge runtime, plugin action execution, or SDK transport. +- Do not add billing, cloud host sales, provider marketplaces, or unrelated SaaS marketplace behavior. +- Do not make platform import TypeScript plugin validation code or plugin workspace files directly. +- Do not implement frontend plugin marketplace pages in this change. + +## Decisions + +### Decision 1: Use a copied manifest contract at the root boundary + +`plugins/` owns the JSON Schema and TypeScript validation helper. `platform/` owns named DTOs and domain types that mirror the externally submitted manifest shape. The two roots are kept aligned by tests and OpenSpec requirements rather than direct imports. + +Alternative considered: have platform read `plugins/manifests/game-plugin.manifest.schema.json` directly. Rejected because that would make the backend depend on the plugin workspace file layout and blur root ownership. + +### Decision 2: Platform registration converts manifests to existing registry metadata + +The platform will add a manifest registration service/API path that converts a validated manifest into `GamePlugin` registry metadata. The existing create/list/detail endpoints remain available for low-level metadata tests and future migration, while the new manifest endpoint is the supported installation boundary for plugin manifests. + +Alternative considered: replace `GamePluginCreateRequest` with the manifest shape. Rejected because existing server-management tests and API contracts already use the metadata resource directly. + +### Decision 3: Deny unsafe permissions by explicit allowlists and substring checks + +Plugin manifests may declare scoped permissions such as server lifecycle, file, log, artifact, and platform-mediated AI permissions. They must not declare direct run sockets, raw host paths, raw credentials, raw AI keys, provider keys, or direct run credentials. `plugins/` catches these during schema/test validation and `platform/` repeats the safety validation before registry insertion. + +Alternative considered: rely only on JSON Schema enum restrictions. Rejected because unsafe intent can appear in action paths, capability names, or future fields; backend validation still needs an explicit defense. + +### Decision 4: Registry metadata is marketplace-ready but not commerce-oriented + +Registry responses include identity, version, server type/display name, manifest/schema references, required run capabilities, permissions, pages, and AI purposes. They intentionally exclude pricing, cloud host purchase flows, and provider marketplace concepts. + +Alternative considered: add a richer marketplace catalog model now. Rejected because the repository scope is game server management, and later UI can derive its first catalog view from registry metadata. + +## Risks / Trade-offs + +- [Risk] The plugin manifest contract may evolve when plugin bridge work starts. Mitigation: keep this change focused on registry metadata and add bridge-specific fields in the next OpenSpec change. +- [Risk] Duplicating contract shapes across TypeScript and Go can drift. Mitigation: tests cover the example manifest and platform manifest registration until a generated contract package is introduced. +- [Risk] Strict allowlists can reject useful future plugin capabilities. Mitigation: add new allowed capability/permission keys through explicit OpenSpec changes. + +## Migration Plan + +1. Add plugin schema restrictions, fixtures, and validation tests while keeping the development example valid. +2. Add platform manifest DTO/domain conversion, validators, service registration, and API route tests. +3. Keep existing `POST /api/v1/game-plugins` metadata creation working for current tests. +4. Validate the change with plugin tests, platform tests, structure check, and strict OpenSpec validation. + +Rollback during this phase is straightforward: remove the manifest registration route and schema/test additions before downstream plugin bridge work depends on them. + +## Open Questions + +- Whether manifest contract generation should be added in the plugin bridge change or a later contract-generation change. +- Whether registry metadata should eventually support signed manifest artifacts before plugin publish/install workflows are implemented. diff --git a/openspec/changes/implement-plugin-registry-and-manifest-validation/proposal.md b/openspec/changes/implement-plugin-registry-and-manifest-validation/proposal.md new file mode 100644 index 0000000..3d4f0f4 --- /dev/null +++ b/openspec/changes/implement-plugin-registry-and-manifest-validation/proposal.md @@ -0,0 +1,27 @@ +## Why + +Plugin installability is now the next platform dependency: server creation, plugin marketplace display, and plugin bridge work all need a trusted registry of game management plugin manifests. This change adds the manifest validation and registry surface so only safe, well-formed plugins become available to the platform. + +## What Changes + +- Define the first plugin manifest contract for game management plugins, including identity, server type, create form schema, lifecycle actions, run capabilities, UI contribution, and scoped permissions. +- Add manifest validation in `plugins/` with schema fixtures and tests for valid and unsafe manifests. +- Add platform domain, repository, service, DTO, validator, and API handling for registering, listing, and inspecting installed plugin metadata. +- Reject or disable unsafe manifest requests such as raw host paths, direct run sockets, raw credentials, or raw AI provider keys. +- Keep plugin marketplace metadata focused on game server management plugins, not billing, cloud host sales, or unrelated SaaS marketplace features. + +## Capabilities + +### New Capabilities + +- `plugin-registry-and-manifest-validation`: Validates game management plugin manifests and exposes installed plugin registry metadata through platform APIs. + +### Modified Capabilities + +- None. This change builds on the bootstrap plugin and platform-core constraints, which have not yet been archived into `openspec/specs/`. + +## Impact + +- Affects `plugins/` manifest schemas, fixtures, SDK-adjacent types, and validation tests. +- Affects `platform/` plugin domain, DTOs, models, repositories, services, validators, routes, and API tests. +- Adds or updates verification commands for plugin schema tests, platform API tests, `scripts/check-structure.sh`, and strict OpenSpec validation. diff --git a/openspec/changes/implement-plugin-registry-and-manifest-validation/specs/plugin-registry-and-manifest-validation/spec.md b/openspec/changes/implement-plugin-registry-and-manifest-validation/specs/plugin-registry-and-manifest-validation/spec.md new file mode 100644 index 0000000..0c27d5e --- /dev/null +++ b/openspec/changes/implement-plugin-registry-and-manifest-validation/specs/plugin-registry-and-manifest-validation/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Manifest Schema Validation +The plugin workspace SHALL validate game management plugin manifests with a JSON Schema that defines identity, version, plugin kind, server type, create form schema reference, run capabilities, lifecycle actions, optional pages, AI purposes, and scoped permissions. + +#### Scenario: Development plugin manifest validates +- **WHEN** the development game plugin manifest is validated by the plugin workspace validator +- **THEN** validation MUST pass and its referenced create form schema MUST also validate + +#### Scenario: Unsafe manifest fails validation +- **WHEN** a manifest requests direct run sockets, host paths, raw credentials, raw AI keys, or direct provider key access +- **THEN** validation MUST return violations and MUST NOT treat the plugin as installable + +### Requirement: Platform Manifest Registration +The platform SHALL expose a manifest registration API that accepts a structured game plugin manifest payload and converts it into installed plugin registry metadata. + +#### Scenario: Valid manifest is registered +- **WHEN** a valid game management plugin manifest is submitted to the platform registration API +- **THEN** the platform MUST persist an installed plugin record with manifest reference, create form schema reference, server type, required run capabilities, scoped permissions, pages, and AI purposes + +#### Scenario: Duplicate plugin registration is rejected +- **WHEN** a manifest is submitted for an already registered plugin ID +- **THEN** the platform MUST return a duplicate error and MUST preserve the existing registry record + +### Requirement: Registry Query Surface +The platform SHALL expose plugin registry list and detail responses suitable for the plugin marketplace and server creation workflows. + +#### Scenario: Marketplace lists installed plugins +- **WHEN** platform clients list game plugins by status or server type +- **THEN** each response item MUST include plugin identity, version, server type/display metadata, manifest/schema references, run capabilities, scoped permissions, pages, AI purposes, and install status + +#### Scenario: Registry detail excludes unsafe internals +- **WHEN** platform clients fetch one registered plugin +- **THEN** the response MUST NOT include raw host paths, raw credentials, run connection details, or raw AI provider keys + +### Requirement: Backend Safety Validation +The platform SHALL independently validate plugin manifest safety before registry persistence, regardless of plugin workspace validation results. + +#### Scenario: Raw key request reaches platform +- **WHEN** a registration payload contains raw AI key, provider key, bearer token, or secret-like content +- **THEN** the platform MUST reject the registration with a validation error + +#### Scenario: Direct run or host path request reaches platform +- **WHEN** a registration payload contains direct run socket, direct run credential, or raw host path access requests +- **THEN** the platform MUST reject the registration with a validation error + +### Requirement: Ownership Boundary Preservation +The manifest registry implementation SHALL keep `plugins/` validation code and `platform/` backend code in their own roots and share contract shapes only through named DTO/domain/schema files. + +#### Scenario: Platform validates registration +- **WHEN** platform code handles manifest registration +- **THEN** it MUST use platform DTOs, domain types, and validators rather than importing plugin workspace implementation files diff --git a/openspec/changes/implement-plugin-registry-and-manifest-validation/tasks.md b/openspec/changes/implement-plugin-registry-and-manifest-validation/tasks.md new file mode 100644 index 0000000..9c07640 --- /dev/null +++ b/openspec/changes/implement-plugin-registry-and-manifest-validation/tasks.md @@ -0,0 +1,68 @@ +## 1. Plugin Manifest Validation + +- [x] 1.1 Extend the plugin manifest schema and SDK-adjacent types with registry metadata, lifecycle actions, pages, AI purposes, artifacts permission, and explicit safe permission/capability allowlists. +- [x] 1.2 Extend `plugins/scripts/validate-manifest.ts` to reject unsafe raw host path, direct run, raw credential, and raw AI/provider key requests beyond JSON Schema shape validation. +- [x] 1.3 Add plugin validation fixtures/tests for the valid development manifest, invalid create form schema, and unsafe manifest requests. + +## 2. Platform Registry Contracts + +- [x] 2.1 Add platform domain, DTO, model, copy, and conversion contracts for game plugin manifest registration metadata including server display metadata, pages, AI purposes, and validation violations. +- [x] 2.2 Add platform validator rules for manifest registration, allowed permissions/capabilities, unsafe string detection, duplicate-free lists, and registry response safety. +- [x] 2.3 Add service and repository behavior that registers a manifest as an installed game plugin while preserving existing metadata create/list/detail behavior. + +## 3. Platform Registry API + +- [x] 3.1 Add a manifest registration endpoint under the game plugin API surface using named DTOs and the core service. +- [x] 3.2 Extend game plugin list/detail responses with registry metadata required by marketplace and server creation workflows. +- [x] 3.3 Add platform API/service/validator tests for valid manifest registration, duplicate rejection, filtering, unsafe manifest rejection, and no raw internal/key fields in responses. + +## 4. Documentation And Handoff + +- [x] 4.1 Update platform route/protocol documentation and plugin documentation to describe manifest validation and registry registration boundaries. +- [x] 4.2 Add a fresh-chat handoff block for this change. + +## 5. Verification + +- [x] 5.1 Run `npm test` from `plugins/` and record evidence. +- [x] 5.2 Run `npm run typecheck` from `plugins/` and record evidence. +- [x] 5.3 Run `go test ./...` from `platform/` and record evidence. +- [x] 5.4 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.5 Run `openspec validate implement-plugin-registry-and-manifest-validation --strict` and record evidence. + +## Evidence + +- 2026-07-03: `npm test` from `plugins/` passed with 4 manifest/SDK tests. +- 2026-07-03: `npm run typecheck` from `plugins/` passed. +- 2026-07-03: `go test ./...` from `platform/` passed across api, config, domain, dto, model, repo, service, and validator packages. +- 2026-07-03: `scripts/check-structure.sh` passed with `structure check passed`. +- 2026-07-03: `openspec validate implement-plugin-registry-and-manifest-validation --strict` passed with `Change 'implement-plugin-registry-and-manifest-validation' is valid`. + +## Fresh-Chat Handoff + +```text +Implement OpenSpec change: implement-plugin-registry-and-manifest-validation + +Scope: +- Implement only openspec/changes/implement-plugin-registry-and-manifest-validation/. +- Preserve root ownership boundaries in AGENTS.md. +- Do not add billing, cloud host sales, agent-provider/cloud-provider workflows, or unrelated marketplace features. + +Read first: +- AGENTS.md +- openspec/changes/bootstrap-game-server-platform-architecture/proposal.md +- openspec/changes/bootstrap-game-server-platform-architecture/design.md +- openspec/changes/bootstrap-game-server-platform-architecture/specs/game-plugin-system/spec.md +- openspec/changes/bootstrap-game-server-platform-architecture/specs/game-server-platform-core/spec.md +- openspec/changes/implement-plugin-registry-and-manifest-validation/proposal.md +- openspec/changes/implement-plugin-registry-and-manifest-validation/design.md +- openspec/changes/implement-plugin-registry-and-manifest-validation/specs/plugin-registry-and-manifest-validation/spec.md +- openspec/changes/implement-plugin-registry-and-manifest-validation/tasks.md + +Required closure: +- Complete task checkboxes only after evidence exists. +- Run `npm test` and `npm run typecheck` from `plugins/`. +- Run `go test ./...` from `platform/`. +- Run `scripts/check-structure.sh`. +- Run `openspec validate implement-plugin-registry-and-manifest-validation --strict`. +- Stop after this change is closed; do not start the next backlog item unless explicitly asked. +``` diff --git a/openspec/changes/implement-real-game-plugin-lifecycle-proof/.openspec.yaml b/openspec/changes/implement-real-game-plugin-lifecycle-proof/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/implement-real-game-plugin-lifecycle-proof/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/implement-real-game-plugin-lifecycle-proof/design.md b/openspec/changes/implement-real-game-plugin-lifecycle-proof/design.md new file mode 100644 index 0000000..8a4dad5 --- /dev/null +++ b/openspec/changes/implement-real-game-plugin-lifecycle-proof/design.md @@ -0,0 +1,80 @@ +## Context + +The current architecture stream has package-level evidence for platform APIs, run channels, plugin manifests, SDK bridge envelopes, and the management frontend. The latest baseline still found a critical product gap: it could not prove that one local game management plugin can create and manage multiple real server instances through the intended platform-mediated path. + +This proof spans all four product roots. `plugins/` must declare and request lifecycle capabilities without owning transport. `platform/` must authorize plugin lifecycle requests, create server records, dispatch bounded jobs, persist state, and expose safe API responses. `run/` must execute or simulate local lifecycle jobs through its channelized executor contract and return observable results. `platform_web/` must let an authorized user install/use the plugin and inspect separate server instances without showing raw host paths, credentials, sockets, or run transport details. + +## Goals / Non-Goals + +**Goals:** + +- Prove one installed local game management plugin can create at least two independent server instances. +- Prove install/create/start/stop/status/log/artifact lifecycle operations are routed through platform APIs and run jobs, not direct browser/plugin access to run. +- Prove each instance has independent identity, lifecycle state, operation history, logs/artifacts where applicable, and browser-visible controls. +- Prove plugin manifest permissions and SDK bridge envelopes reject direct run URLs, host paths, raw credentials, raw AI keys, and undeclared lifecycle actions. +- Provide repeatable API, run, plugin, frontend, and browser verification commands. + +**Non-Goals:** + +- No billing, cloud host sales, agent-provider/cloud-provider workflows, or unrelated marketplace behavior. +- No production-grade game server hosting, cloud provisioning, external orchestrator, or remote game binary distribution. +- No direct browser-to-run or plugin-to-run transport. +- No new raw host path, raw socket, raw credential, or raw AI-key exposure. +- No broad redesign of the magical crystal-moonlight frontend style. + +## Decisions + +### Decision 1: Use one first-party local proof plugin + +The implementation will use the existing local example/dev game plugin as the proof target or evolve it into a clearly named local lifecycle proof plugin. The plugin declares lifecycle actions and platform-mediated capabilities in its manifest, and its SDK calls produce bounded platform bridge requests. + +Alternative considered: introduce several plugins for different games. Rejected because the stream needs one focused proof that the lifecycle path is real before multiplying game-specific scope. + +### Decision 2: Platform owns lifecycle authority and instance records + +The platform remains the authority for plugin installation state, server instance records, lifecycle authorization, job creation, audit events, and safe response DTOs. Plugin requests name logical server/plugin/action identifiers; platform translates them into jobs for run and stores resulting state. + +Alternative considered: allow plugin pages to call run endpoints directly for local development. Rejected because it violates the required channel boundaries and would make browser proof unsafe. + +### Decision 3: Multi-instance proof requires independent state and evidence + +The proof is not accepted unless the same installed plugin creates at least two server instances and can start/stop them independently. Evidence must include distinct IDs/names, operation history per instance, lifecycle state projection, and browser-visible separation. + +Alternative considered: create one server and assume the code generalizes. Rejected because the baseline gap is specifically multi-instance real operation. + +### Decision 4: Run proof can use bounded local lifecycle execution + +The run-side executor may use a deterministic local fixture command or safe simulated local game process when no real game binary is available, but it must still travel through the run job channel and return job ack/result/state evidence. Any fixture must be explicit and must not expose raw host paths to plugins or platform_web. + +Alternative considered: require a full real game server binary immediately. Rejected because the architecture proof is about platform-mediated lifecycle wiring and instance isolation, not a specific commercial game runtime. + +### Decision 5: Browser acceptance must be API-backed + +The browser walkthrough must use a real platform session and local stack. Local auth fallback, static seed-only data, and demo-only plugin controls cannot satisfy this proof. The walkthrough must record the stack commands, pages visited, actions taken, visible state, and unsafe-field checks. + +Alternative considered: accept unit and integration tests only. Rejected because this stream requires browser evidence for frontend-facing behavior. + +## Risks / Trade-offs + +- [Risk] Local stack setup may remain brittle. Mitigation: tasks require exact commands, health checks, and explicit blocker recording if a service cannot bind or authenticate. +- [Risk] A simulated local lifecycle fixture could be mistaken for production hosting. Mitigation: name it as a local proof fixture and require platform/run channel evidence rather than game-specific claims. +- [Risk] Plugin bridge expansion could accidentally expose transport details. Mitigation: add negative tests for direct run URLs, host paths, raw credentials, sockets, and raw AI keys. +- [Risk] Multi-instance state can collapse into shared mutable demo data. Mitigation: require two instances with independent IDs, operation histories, and state transitions. +- [Risk] Browser proof can pass against fallback data. Mitigation: require API-backed login, platform responses, and no `本地会话`/demo fallback classification for accepted proof. + +## Migration Plan + +1. Extend the local proof plugin manifest and SDK/page code to declare lifecycle actions and platform-mediated capability envelopes. +2. Add platform service/API support or repair existing endpoints for plugin-mediated multi-instance create/install/start/stop/status operations. +3. Add run-side lifecycle handling needed to acknowledge jobs, execute bounded local fixture operations, and return instance-specific results. +4. Update platform_web server/plugin flows to expose the proof actions and state without changing the visual system. +5. Add automated tests across plugin, platform, run, and platform_web. +6. Run the full local stack and browser walkthrough, recording exact evidence before marking tasks complete. + +Rollback before acceptance is to remove the proof plugin lifecycle declarations, platform/run/frontend implementation changes, and any verification fixtures added by this change. + +## Open Questions + +- Whether the local lifecycle fixture should be a no-op process, a tiny managed process, or an existing example game command. +- Whether the browser walkthrough should use docker-compose or separate local commands as the primary evidence path. +- Whether the proof report should be a standalone artifact under this change or embedded only in `tasks.md` evidence. diff --git a/openspec/changes/implement-real-game-plugin-lifecycle-proof/proposal.md b/openspec/changes/implement-real-game-plugin-lifecycle-proof/proposal.md new file mode 100644 index 0000000..9cd71a3 --- /dev/null +++ b/openspec/changes/implement-real-game-plugin-lifecycle-proof/proposal.md @@ -0,0 +1,29 @@ +## Why + +The baseline proof showed substantial package-level platform/run/plugin evidence, but it did not prove that a real local game management plugin can create and manage multiple server instances end to end. This change turns that gap into one focused implementation proof before the stream adds more surface area. + +## What Changes + +- Add a local game plugin lifecycle proof that creates and manages multiple server instances through platform-mediated contracts only. +- Implement the missing platform/run/plugin/frontend wiring needed for one installed local plugin to request create/install/start/stop/status/log/artifact operations without direct run access. +- Verify multiple server instances can be created from one game management plugin and tracked independently through platform storage, run jobs, operation status, and browser-visible state. +- Add API, run, plugin SDK/manifest, and platform_web tests for lifecycle permissions, bridge envelopes, multi-instance isolation, and safe field redaction. +- Require a browser walkthrough proving the lifecycle flow is real, API-backed, and free of raw host paths, raw credentials, direct sockets, raw AI keys, or plugin-owned run transport details. + +## Capabilities + +### New Capabilities + +- `real-game-plugin-lifecycle-proof`: Proves a local game management plugin can create and manage multiple server instances only through platform-mediated platform/run capabilities. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `plugins/` manifests, SDK/example plugin behavior, and manifest validation tests for lifecycle capability declarations. +- Affects `platform/` plugin lifecycle APIs, server instance orchestration, job dispatch, audit/event evidence, and safe response DTOs. +- Affects `run/` lifecycle execution handling, job acknowledgement/result projection, and instance-specific isolation evidence. +- Affects `platform_web/` server management and plugin marketplace/detail surfaces needed to trigger and inspect the proof in a browser. +- Does not add billing, cloud host sales, agent-provider/cloud-provider workflows, unrelated marketplace behavior, direct browser/plugin access to run, raw host path exposure, raw run credentials, direct sockets, or raw AI keys. diff --git a/openspec/changes/implement-real-game-plugin-lifecycle-proof/specs/real-game-plugin-lifecycle-proof/spec.md b/openspec/changes/implement-real-game-plugin-lifecycle-proof/specs/real-game-plugin-lifecycle-proof/spec.md new file mode 100644 index 0000000..b775fe7 --- /dev/null +++ b/openspec/changes/implement-real-game-plugin-lifecycle-proof/specs/real-game-plugin-lifecycle-proof/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Platform-mediated plugin lifecycle authority +The system SHALL route local game plugin lifecycle requests through platform-owned authorization, server instance records, and run job dispatch. Browser code and plugin code MUST NOT connect directly to run endpoints, raw sockets, raw host paths, or raw credentials. + +#### Scenario: Plugin requests server creation through platform +- **WHEN** an authorized user invokes a declared plugin lifecycle action to create a server instance +- **THEN** the platform MUST validate the installed plugin, declared capability, user authorization, and request payload before creating the server instance and dispatching any run job + +#### Scenario: Direct run access is rejected +- **WHEN** a plugin manifest, plugin page, SDK request, or browser-visible payload attempts to use a direct run URL, raw socket, host path, bearer credential, password, or undeclared transport detail +- **THEN** validation MUST reject the request or redact the unsafe field before it reaches platform_web or plugin code + +### Requirement: One plugin manages multiple server instances +The system SHALL allow one installed local game management plugin to create and manage at least two independent server instances through platform-mediated lifecycle capabilities. + +#### Scenario: Create two instances from one plugin +- **WHEN** an authorized user creates two server instances using the same installed local game management plugin +- **THEN** the platform MUST persist two distinct server instance records with independent IDs, names, lifecycle state, plugin association, and operation history + +#### Scenario: Start and stop one instance independently +- **WHEN** the user starts one plugin-created server instance and leaves the second instance stopped +- **THEN** the run job result and platform state projection MUST show only the targeted instance as running while the other instance remains stopped + +#### Scenario: Stop does not affect sibling instance +- **WHEN** the user stops one running plugin-created server instance while another sibling instance remains running +- **THEN** the platform MUST preserve the sibling instance state and MUST record the stop operation only against the targeted instance + +### Requirement: Run lifecycle jobs provide observable proof +The run executor SHALL process plugin-mediated lifecycle jobs through the existing job channel and return acknowledgement, progress or result, and instance-specific state evidence to platform. + +#### Scenario: Lifecycle job acknowledgement and result +- **WHEN** platform dispatches a plugin-mediated install, start, or stop lifecycle job to run +- **THEN** run MUST acknowledge the job and return a bounded result that platform can attach to the correct server instance operation history + +#### Scenario: Instance-specific logs or artifacts +- **WHEN** a lifecycle operation produces logs or artifacts for a server instance +- **THEN** platform MUST expose only logical log/artifact references associated with that instance and MUST NOT expose raw run filesystem paths or transport credentials + +### Requirement: Browser walkthrough proves real API-backed lifecycle behavior +The implementation SHALL include a browser walkthrough that proves the plugin lifecycle flow uses a real API-backed session and not local fallback or seed-only demo state. + +#### Scenario: Browser creates and controls plugin instances +- **WHEN** the walkthrough logs in with an API-backed authorized user, opens the plugin/server management surface, creates two instances, starts one, stops it, and inspects operation history +- **THEN** the visible UI MUST show API-backed lifecycle state for each instance, distinct operation evidence, and no local fallback session indicator + +#### Scenario: Browser unsafe-field inspection +- **WHEN** the walkthrough inspects plugin marketplace, server list, server detail, operation history, log, artifact, and plugin bridge visible states +- **THEN** the visible UI MUST NOT contain raw host paths, raw run credentials, direct run sockets, bearer tokens, raw AI keys, or plugin-owned transport details + +### Requirement: Verification commands cover all roots +The change SHALL provide concrete verification commands for platform, run, platform_web, and plugins, plus strict OpenSpec validation and structure checks. + +#### Scenario: Verification suite passes before completion +- **WHEN** implementation tasks are marked complete +- **THEN** the recorded evidence MUST include passing platform tests, run tests, plugin typecheck/tests/manifest validation, platform_web typecheck/tests/build, `scripts/check-structure.sh`, `openspec validate implement-real-game-plugin-lifecycle-proof --strict`, and the browser walkthrough commands/results diff --git a/openspec/changes/implement-real-game-plugin-lifecycle-proof/tasks.md b/openspec/changes/implement-real-game-plugin-lifecycle-proof/tasks.md new file mode 100644 index 0000000..4630a7f --- /dev/null +++ b/openspec/changes/implement-real-game-plugin-lifecycle-proof/tasks.md @@ -0,0 +1,92 @@ +## 1. Plugin Lifecycle Contract + +- [x] 1.1 Extend the local proof plugin manifest to declare platform-mediated lifecycle actions for create/install/start/stop/status/log/artifact operations. +- [x] 1.2 Add plugin manifest validation tests that accept declared lifecycle capabilities and reject direct run URLs, raw sockets, host paths, bearer credentials, passwords, raw AI keys, and undeclared transport details. +- [x] 1.3 Extend plugin SDK/example bridge envelopes so lifecycle requests carry only logical plugin, server, action, config, log, artifact, and AI capability references. +- [x] 1.4 Run `cd plugins && npm run typecheck && npm run test && npm run validate:manifest` and record evidence. + +## 2. Platform-Mediated Lifecycle API + +- [x] 2.1 Add or repair platform DTOs, validators, domain types, repository/service methods, and API handlers for plugin-mediated multi-instance create/install/start/stop/status operations. +- [x] 2.2 Ensure platform owns authorization, server instance persistence, plugin installation checks, lifecycle job creation, state projection, audit events, and safe response DTOs. +- [x] 2.3 Add platform tests proving one installed plugin can create at least two server instances with distinct IDs, names, lifecycle states, plugin associations, and operation histories. +- [x] 2.4 Add negative platform tests proving browser/plugin payloads cannot expose or submit raw host paths, direct run sockets, bearer tokens, passwords, raw AI keys, or undeclared lifecycle actions. +- [x] 2.5 Run `cd platform && go test ./... -count=1` and record evidence. + +## 3. Run Lifecycle Execution Proof + +- [x] 3.1 Add or repair run-side lifecycle handling for plugin-mediated install/start/stop jobs using scoped logical templates under `RUN_WORKSPACE_ROOT`. +- [x] 3.2 Ensure run acknowledges lifecycle jobs, reports bounded progress/result metadata, preserves per-instance isolation, and rejects unsafe command templates, absolute paths, parent traversal, shell launchers, credentials, and direct sockets. +- [x] 3.3 Add run tests proving start/stop on one instance does not mutate sibling instance state or block job result submission. +- [x] 3.4 Run `cd run && go test ./... -count=1` and record evidence. + +## 4. Platform Web Proof Surface + +- [x] 4.1 Update platform_web API types/client methods, schemas, route/page contracts, and components needed to trigger plugin-mediated lifecycle actions from server management or plugin detail surfaces. +- [x] 4.2 Preserve the magical-girl crystal-moonlight console style and avoid generic opaque SaaS restyling while adding lifecycle controls and operation status. +- [x] 4.3 Add frontend tests for API-backed plugin lifecycle controls, two-instance separation, operation history rendering, role access, and unsafe-field redaction. +- [x] 4.4 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. + +## 5. Local Full-Stack Proof + +- [x] 5.1 Start a local API-backed proof stack and record exact commands, including platform, run worker, and frontend commands such as `PLATFORM_ADDR=127.0.0.1:18080 PLATFORM_STORAGE_BACKEND=file PLATFORM_DATA_DIR=/private/tmp/browser-platform-lifecycle-proof PLATFORM_METADATA_PATH=/private/tmp/browser-platform-lifecycle-proof/metadata.json PLATFORM_LOG_BODY_BACKEND=file PLATFORM_LOG_DIR=/private/tmp/browser-platform-lifecycle-proof/logs go run ./cmd/platform`, `RUN_MODE=worker RUN_PLATFORM_URL=http://127.0.0.1:18080 RUN_WORKSPACE_ROOT=/private/tmp/browser-run-lifecycle-proof/workspaces RUN_SPOOL_ROOT=/private/tmp/browser-run-lifecycle-proof/spool go run ./cmd/run`, and `cd platform_web && PLATFORM_API_PROXY=http://127.0.0.1:18080 VITE_PLATFORM_API_BASE_URL=/api/v1 npm run dev -- --port 5173`. +- [x] 5.2 Verify platform health, run registration/heartbeat, plugin installation data, and API-backed login before browser walkthrough; record exact curl or test commands used. +- [x] 5.3 In a browser with a real API-backed platform administrator session, open 首页、服务器管理、插件市场、用户管理、AI 提供商管理 and confirm the session is not local fallback. +- [x] 5.4 In the browser, use one installed local game management plugin to create two server instances, start one, verify the sibling remains stopped, stop the targeted instance, and inspect per-instance operation history. +- [x] 5.5 In the browser, inspect plugin marketplace/detail, server list/detail, operation history, log/artifact references, and plugin bridge output to verify no raw host paths, run credentials, direct sockets, bearer tokens, raw AI keys, or plugin-owned transport details are visible. + +## 6. Final Verification and Stream Handoff + +- [x] 6.1 Record implementation evidence in this tasks file only after each command or walkthrough has actually run. +- [x] 6.2 Run `scripts/check-structure.sh` and record evidence. +- [x] 6.3 Run `openspec validate implement-real-game-plugin-lifecycle-proof --strict` and record evidence. +- [x] 6.4 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` to mark `fix-env-profile-settings` complete, mark `implement-real-game-plugin-lifecycle-proof` complete only after evidence exists, and leave the next queue item pending. +- [x] 6.5 Update `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` with the next implementation/generator handoff after this change closes. + +## Evidence + +- Plugin contract: + - `cd plugins && npm run typecheck` passed. + - `cd plugins && npm run test` passed: `tests/manifest-validation.test.ts` passed 11 tests. + - `cd plugins && npm run validate:manifest` passed after escalation for `tsx` IPC pipe creation: `validated examples/dev-game-plugin/manifest.json`. + - `plugins/examples/dev-game-plugin/manifest.json` declares install/start/stop/restart/status lifecycle actions, `jobs.dispatch`, log/file/artifact/AI bridge actions, and platform-mediated permissions. + - `plugins/sdk/index.ts` includes `PluginLifecycleDispatchPayload` and `createLifecycleDispatchRequest(...)` for `jobs.dispatch` lifecycle envelopes containing only logical plugin/server/action/capability/config/idempotency references. + - `plugins/tests/manifest-validation.test.ts` asserts lifecycle dispatch envelopes do not contain direct `http://`, `unix://`, `/Users/`, `Bearer `, or `sk-` content. + +- Platform lifecycle API: + - `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./api -run TestPluginBridgeExecuteAPI -count=1` passed. + - `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` passed for `api`, `config`, `domain`, `dto`, `model`, `repo`, `service`, and `validator`. + - `platform/service/server_lifecycle_test.go` includes `TestCoreServicePluginLifecycleManagesMultipleInstancesIndependently`, creating `server-alpha` and `server-beta` from one plugin, starting/stopping only alpha, and verifying beta remains unchanged. + - `platform/api/resource_handlers_test.go` includes `jobs.dispatch` lifecycle bridge execution coverage, action/capability mismatch denial, unsafe payload rejection, and forbidden-fragment response checks. + +- Run lifecycle execution: + - Initial sandbox run of `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` was blocked by `httptest` loopback bind permissions. + - Escalated rerun of `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./... -count=1` passed for `api`, `config`, `protocol`, `runtime`, and `spool`. + - `run/runtime/lifecycle_test.go` includes scoped template execution, unsafe template rejection, workspace escape rejection, cancellation, and sibling workspace isolation. + +- Platform web proof surface: + - `cd platform_web && npm run typecheck` passed. + - `cd platform_web && npm test` passed: 11 files, 49 tests. + - `cd platform_web && npm run build` passed: Vite built `dist/index.html`, CSS, and JS assets. + - `platform_web/pages/ServerDetailPage.test.tsx` verifies plugin lifecycle controls call `startServerInstance` / `stopServerInstance`, skip install/restart/status controls, avoid generic `process.start` / `process.stop` job creation, and keep bridge/lifecycle output on platform-owned references. + +- Controlled local full-stack proof: + - Used controlled stack after an existing `127.0.0.1:18080` process became unreachable despite still holding the port. + - Platform command run from `platform/`: `PLATFORM_ADDR=127.0.0.1:18082 PLATFORM_STORAGE_BACKEND=file PLATFORM_DATA_DIR=/private/tmp/browser-platform-lifecycle-proof-controlled PLATFORM_METADATA_PATH=/private/tmp/browser-platform-lifecycle-proof-controlled/metadata.json PLATFORM_LOG_BODY_BACKEND=file PLATFORM_LOG_DIR=/private/tmp/browser-platform-lifecycle-proof-controlled/logs GOCACHE=/private/tmp/browser-go-build-cache go run ./cmd/platform`. + - Run worker command run from `run/`: `RUN_MODE=worker RUN_PLATFORM_URL=http://127.0.0.1:18082 RUN_WORKSPACE_ROOT=/private/tmp/browser-run-lifecycle-proof/workspaces RUN_SPOOL_ROOT=/private/tmp/browser-run-lifecycle-proof/spool RUN_POLL_INTERVAL_MS=250 RUN_HEARTBEAT_INTERVAL_MS=1000 RUN_MAX_JOBS=4 GOCACHE=/private/tmp/browser-go-build-cache go run ./cmd/run`. + - Frontend command run from `platform_web/`: `PLATFORM_API_PROXY=http://127.0.0.1:18082 VITE_PLATFORM_API_BASE_URL=/api/v1 npm run dev -- --port 5175`. + - Health/login/run checks passed with `curl` against `127.0.0.1:18082`: `/healthz` returned `{"service":"platform","status":"ok","version":"0.1.0-dev"}`, `/api/v1/run/endpoints` returned online `run-local` with `process.install`, `process.start`, and `process.stop`, and `/api/v1/auth/login` authenticated `operator.local@example.test / operator-local` as `user-admin`. + - Registered installed proof plugin `game.lifecycle-proof@0.1.1` with lifecycle actions install/start/stop/restart/status, bridge actions server.instances.read/jobs.dispatch/logs.query/artifacts.open/files.request/ai.invoke, declared permissions for lifecycle/files/logs/artifacts/AI, and run-required capabilities narrowed to worker-real `process.install`, `process.start`, `process.stop`. + - Created two server instances through platform workflow: `proof-alpha` and `proof-beta`; both install jobs completed via run worker and both reached `ready`. + - Browser walkthrough on `http://127.0.0.1:5175/` logged in with the API-backed platform administrator session and opened 首页、服务器管理、插件市场、用户管理、AI 提供商管理. Each page reported `hasLocalFallback: false` and no visible forbidden fragments among `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, or `rawApiKey`. + - Browser server detail walkthrough opened `#/servers/proof-alpha`, confirmed `Proof Alpha` was ready with start enabled, clicked `启动`, confirmed the dialog, and observed a visible `process.start` queued operation. + - API proof after UI start showed `proof-alpha` state `running`, `proof-beta` state `ready`, alpha start job `server-lifecycle:proof-alpha:start:3981de20495ff68b` succeeded with `process.start completed`, and beta had only its install job. + - Browser detail walkthrough then refreshed `proof-alpha`, confirmed it was running with stop enabled, clicked `停止`, confirmed the dialog, and observed a visible `process.stop` queued operation. + - API proof after UI stop showed `proof-alpha` state `stopped`, `proof-beta` still `ready`, alpha install/start/stop jobs all succeeded, and beta still had only its install job. + - Browser walkthrough visible surfaces did not expose raw host paths, run credentials, direct sockets, bearer tokens, raw AI keys, or plugin-owned transport details. + +- Final gates and stream handoff: + - `scripts/check-structure.sh` passed with `structure check passed`. + - `openspec validate implement-real-game-plugin-lifecycle-proof --strict` passed with `Change 'implement-real-game-plugin-lifecycle-proof' is valid`; the process exited 0. PostHog telemetry flush reported `ENOTFOUND edge.openspec.dev`, which did not affect validation. + - `openspec/changes/architecture-delivery-stream/delivery-plan.md` now marks `implement-real-game-plugin-lifecycle-proof` complete and `harden-log-artifact-channel-isolation` active. + - `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` now points the next implementation chat at `harden-log-artifact-channel-isolation`. diff --git a/openspec/changes/implement-role-scoped-server-access/.openspec.yaml b/openspec/changes/implement-role-scoped-server-access/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/implement-role-scoped-server-access/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/implement-role-scoped-server-access/design.md b/openspec/changes/implement-role-scoped-server-access/design.md new file mode 100644 index 0000000..95e197c --- /dev/null +++ b/openspec/changes/implement-role-scoped-server-access/design.md @@ -0,0 +1,49 @@ +## Context + +The platform already has session APIs, role-aware frontend navigation, and server lifecycle workflows. Server instances are currently global resources with no owner or administrator membership metadata, so frontend filtering alone cannot satisfy the required visibility and management rules. + +## Goals / Non-Goals + +**Goals:** +- Bootstrap the first public registration as an active platform administrator. +- Persist server ownership and server administrator membership on each server instance. +- Enforce server visibility and lifecycle authorization in platform APIs. +- Let server owners invite and remove server administrators for their own servers while hiding platform administrators from owner-facing candidate lists. +- Keep platform administrators able to view and manage all servers. + +**Non-Goals:** +- Add billing, cloud host sales, marketplace SaaS workflows, or external identity providers. +- Add persistent database migrations beyond the current in-memory model. +- Expose run credentials, raw host paths, AI provider keys, or plugin-side direct server management. + +## Decisions + +### Store ACLs on server instances + +Server instances will carry `OwnerUserID` and `AdminUserIDs` fields. This keeps authorization close to the managed resource and avoids adding a separate repository before the platform has persistent storage. + +Alternative considered: add a dedicated membership repository. This was rejected for the current in-memory platform because it increases joins and lifecycle coordination without adding durability. + +### Authorize at API/service boundaries + +Handlers will require a bearer session for user-facing server APIs and call service methods that evaluate the current user before returning data or dispatching lifecycle jobs. Platform administrators bypass the per-server ACL; server owners and server administrators require membership. + +Alternative considered: filter only in `platform_web`. This was rejected because direct API calls would still leak global server data. + +### Bootstrap first registration by existing user count + +Registration will inspect the user repository. If no users exist, the registered user becomes active with `platform-admin` and receives a session. Later registrations remain pending with `server-admin`. + +Alternative considered: keep only a seeded local admin. This was rejected because production-like setup needs a first-account bootstrap path. + +### Owner-managed administrator invitations use existing users + +Server owner invitations will accept an existing active non-platform-admin user ID and add that user to the server administrator list. Removing an administrator deletes only the server membership, not the user account or platform role. + +Alternative considered: invite by email and create accounts inline. This was rejected because the current user lifecycle already separates registration/approval from server membership assignment. + +## Risks / Trade-offs + +- [Risk] In-memory ACL state is not durable across process restart. -> Mitigation: keep this scoped to the existing in-memory platform and model fields so future persistence can mirror the contract. +- [Risk] Existing tests that create server instances without auth may fail. -> Mitigation: keep service-level direct create helpers usable while requiring auth in HTTP handlers. +- [Risk] Platform administrators accidentally appear as removable server admins. -> Mitigation: filter platform-admin users from owner-facing candidate/member responses and reject platform-admin membership mutations. diff --git a/openspec/changes/implement-role-scoped-server-access/proposal.md b/openspec/changes/implement-role-scoped-server-access/proposal.md new file mode 100644 index 0000000..423d0b0 --- /dev/null +++ b/openspec/changes/implement-role-scoped-server-access/proposal.md @@ -0,0 +1,25 @@ +## Why + +The current login and server management flow authenticates users but does not enforce the product rule that platform administrators can manage every server while server owners and server administrators only see their assigned servers. The platform also needs bootstrap-safe registration so the first real account becomes the platform administrator and later registrations remain server-scoped until invited or approved. + +## What Changes + +- Make the first registered account an active platform administrator with an authenticated session. +- Keep subsequent self-registrations server-scoped and pending until a platform administrator activates them. +- Add owner and administrator membership fields to server instances and expose them in bounded API responses. +- Scope server list/detail/lifecycle APIs by current user: platform administrators see all servers; owners and administrators see only owned or managed servers. +- Add server owner APIs to invite and remove server administrators without exposing platform administrators as invite candidates. +- Add frontend contracts, client methods, server detail UI, and tests for owner-managed administrator membership. + +## Capabilities + +### New Capabilities +- `role-scoped-server-access`: Registration bootstrap, server ownership, server administrator membership, and role-scoped server visibility/actions. + +### Modified Capabilities + +## Impact + +- Affects `platform/` domain, DTOs, validation, repository filters, service authorization helpers, API handlers, route docs, and tests. +- Affects `platform_web/` API types/client methods, server list/detail pages, user/admin display logic, and tests. +- Preserves existing platform/run/plugin boundaries; no raw credentials, host paths, direct sockets, or AI provider keys are exposed to plugins or the frontend. diff --git a/openspec/changes/implement-role-scoped-server-access/specs/role-scoped-server-access/spec.md b/openspec/changes/implement-role-scoped-server-access/specs/role-scoped-server-access/spec.md new file mode 100644 index 0000000..40c0650 --- /dev/null +++ b/openspec/changes/implement-role-scoped-server-access/specs/role-scoped-server-access/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: First registration bootstraps platform administration +The platform SHALL make the first registered user an active platform administrator and return an authenticated session for that registration. + +#### Scenario: First registered user becomes platform administrator +- **WHEN** there are no existing users and a valid registration request is submitted +- **THEN** the created user has status `active`, includes the `platform-admin` role, and receives a session token. + +#### Scenario: Later registered users remain server scoped +- **WHEN** at least one user exists and a valid registration request is submitted +- **THEN** the created user has status `pending`, includes only the `server-admin` role by default, and does not receive platform administrator privileges. + +### Requirement: Server instances carry owner and administrator membership +Server instances SHALL persist one owner user ID and zero or more server administrator user IDs. + +#### Scenario: Server is created by an authenticated server owner +- **WHEN** an authenticated non-platform user creates a server workflow +- **THEN** the created server records that user as `ownerUserId` and returns the owner in the server response. + +#### Scenario: Server membership is bounded in responses +- **WHEN** a server instance is returned by list, detail, or lifecycle APIs +- **THEN** the response includes `ownerUserId` and `adminUserIds` without exposing credentials or platform administrator-only data. + +### Requirement: Server visibility is role scoped +The platform SHALL scope user-facing server APIs by the authenticated user. + +#### Scenario: Platform administrator lists servers +- **WHEN** a platform administrator lists server instances +- **THEN** all non-filtered matching server instances are returned. + +#### Scenario: Server owner lists servers +- **WHEN** a server owner lists server instances +- **THEN** only servers where the user is the owner or a server administrator are returned. + +#### Scenario: Server administrator opens unmanaged server +- **WHEN** a server administrator requests a server they do not own or administer +- **THEN** the request is rejected with forbidden or not found semantics and no server details are returned. + +### Requirement: Server owners manage server administrators +The platform SHALL let a server owner invite and remove server administrators for servers they own. + +#### Scenario: Owner invites server administrator +- **WHEN** a server owner invites an active non-platform-admin user to administer their server +- **THEN** that user is added to the server `adminUserIds` list and can see/manage that server. + +#### Scenario: Owner removes server administrator +- **WHEN** a server owner removes an existing server administrator from their server +- **THEN** that user is removed from the server `adminUserIds` list and can no longer see that server unless they own it or have platform administrator privileges. + +#### Scenario: Owner cannot manage platform administrators +- **WHEN** a server owner lists invite candidates or attempts to add/remove a platform administrator +- **THEN** platform administrators are hidden from owner-facing membership lists and membership mutation is rejected. + +#### Scenario: Non-owner cannot change membership +- **WHEN** a server administrator attempts to invite or remove administrators for a server they do not own +- **THEN** the request is rejected. + +### Requirement: Frontend exposes owner-scoped administrator management +The frontend SHALL show server administrator management controls only where the current user can use them. + +#### Scenario: Owner sees member controls +- **WHEN** a server owner opens a server they own +- **THEN** the server detail page shows current server administrators and invitation/removal controls. + +#### Scenario: Server administrator sees no owner controls +- **WHEN** a server administrator opens a server they administer but do not own +- **THEN** the page hides invitation/removal controls while keeping allowed server operations visible. + +#### Scenario: Platform administrator can inspect all servers +- **WHEN** a platform administrator opens any server +- **THEN** the page remains accessible and avoids presenting owner-only membership controls as if the platform administrator were removable. diff --git a/openspec/changes/implement-role-scoped-server-access/tasks.md b/openspec/changes/implement-role-scoped-server-access/tasks.md new file mode 100644 index 0000000..dccbcbc --- /dev/null +++ b/openspec/changes/implement-role-scoped-server-access/tasks.md @@ -0,0 +1,19 @@ +## 1. Backend Model And Authorization + +- [x] 1.1 Add server owner/admin membership fields, DTOs, copy helpers, filters, and validators. +- [x] 1.2 Update registration bootstrap so the first user becomes an authenticated platform administrator. +- [x] 1.3 Add service authorization helpers for platform admin, server owner, and server administrator visibility. +- [x] 1.4 Scope server list/detail/lifecycle HTTP APIs by bearer session and server ACLs. +- [x] 1.5 Add owner APIs for listing invite candidates, inviting administrators, and removing administrators. + +## 2. Frontend User Flow + +- [x] 2.1 Add API types and client methods for server membership and invite candidates. +- [x] 2.2 Render owner/admin metadata and owner-only administrator management in server detail. +- [x] 2.3 Ensure server-only users continue landing on and seeing only their server list/detail workspaces. + +## 3. Verification + +- [x] 3.1 Add backend tests for bootstrap registration, server ACL visibility, lifecycle authorization, and owner membership changes. +- [x] 3.2 Add frontend tests for role-scoped server UI and membership client behavior. +- [x] 3.3 Run OpenSpec strict validation, structure checks, backend tests, frontend tests, and browser walkthrough. diff --git a/openspec/changes/implement-run-control-registration/.openspec.yaml b/openspec/changes/implement-run-control-registration/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/implement-run-control-registration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/implement-run-control-registration/design.md b/openspec/changes/implement-run-control-registration/design.md new file mode 100644 index 0000000..e3c98bc --- /dev/null +++ b/openspec/changes/implement-run-control-registration/design.md @@ -0,0 +1,79 @@ +## Context + +The platform already has a `RunEndpoint` domain resource and generic create/list/detail API. The run executable currently only produces a smoke summary and a base URL-normalizing client. The architecture requires a high-priority control channel for hello, heartbeat, version, capability, and capacity metadata before later job/log/artifact channels are implemented. + +This change implements the first control-channel workflow across `run/` and `platform/`. It stays on HTTP JSON and in-memory platform storage because persistence and streaming transport are later changes. The control payloads must remain small and must not carry logs, artifact chunks, job result bodies, host paths, raw credentials, or direct sockets. + +## Goals / Non-Goals + +**Goals:** + +- Define typed run control protocol payloads in `run/protocol` and matching platform DTOs in `platform/dto`. +- Add platform control routes for hello registration and heartbeat. +- Persist/update run endpoint metadata through `service.Core` with validation and capability/capacity checks. +- Generate platform session tokens on hello and require the matching token on heartbeat. +- Add run-side client methods for hello and heartbeat. +- Add tests for protocol shape, platform service/API behavior, run client requests, and a registration/heartbeat integration flow. + +**Non-Goals:** + +- No job claim, ack, progress, result, cancel, or reconcile channel. +- No log ingest, artifact transfer, or game client bridge behavior. +- No WebSocket/gRPC streaming transport. +- No persistent database, token vault, mTLS, or auth policy engine. +- No frontend pages, plugin behavior, billing, cloud host sales, or direct plugin-to-run access. + +## Decisions + +### Decision 1: HTTP JSON control endpoints + +The initial control channel uses `POST /api/v1/run/control/hello` and `POST /api/v1/run/control/heartbeat`. This matches the existing platform API shape and keeps the first registration workflow testable without introducing a streaming dependency. + +Alternative considered: one long-lived WebSocket. Rejected because the architecture explicitly separates control from heavier job/log/artifact channels and later transport choices should be made after the metadata loop is stable. + +### Decision 2: Session token is platform-generated and in-memory + +Hello returns a deterministic session token derived from platform-side session state. Heartbeat must echo that token for the same run endpoint. The in-memory repository remains the backing state for now. + +Alternative considered: accepting a run-provided session token. Rejected because platform must own control session acceptance and future auth hardening. + +### Decision 3: Run endpoint metadata remains the platform source of truth + +Hello and heartbeat write to the existing `RunEndpoint` domain resource. This avoids a separate control-session aggregate until persistence and auth requirements need it. + +Alternative considered: adding a new run session table/model now. Rejected because current storage is in-memory and this change only needs one active session per run endpoint. + +### Decision 4: Capability fingerprint is metadata only + +Heartbeat accepts a compact capability fingerprint and may request a capability refresh when it differs from platform metadata. The full capability list is still kept on the run endpoint payload. + +Alternative considered: transmitting full capability metadata on every heartbeat. Rejected because control payloads must stay small. + +### Decision 5: Run client stays transport-only + +`run/api.PlatformClient` will encode/decode control requests and responses, but runtime scheduling, retry loops, and background heartbeat timers remain future work. + +Alternative considered: starting a daemon heartbeat loop in this change. Rejected because that would expand scope beyond registration and complicate tests before job/log/artifact channels exist. + +## Risks / Trade-offs + +- [Risk] In-memory session tokens disappear on platform restart. Mitigation: document this as early development behavior and keep token handling behind `service.Core` for future persistence. +- [Risk] Capability fingerprint refresh cannot carry detailed capability changes alone. Mitigation: heartbeat returns `refreshCapabilities` and later changes can add a capability report endpoint. +- [Risk] No auth layer means registration token validation is minimal. Mitigation: require a non-empty registration token now and leave credential verification to the auth/control hardening change. +- [Risk] Run client has methods but no daemon loop. Mitigation: keep this change testable and defer scheduling/retry policy to later run lifecycle work. + +## Migration Plan + +1. Add control protocol and DTO contracts. +2. Add platform service methods and API handlers for hello/heartbeat. +3. Add run client methods and tests for request/response behavior. +4. Update protocol/route docs. +5. Verify with platform tests, run tests, structure check, and strict OpenSpec validation. + +Rollback before dependent changes is removal of the control route/client additions and this OpenSpec change. After job/log/artifact changes depend on registered run endpoints, rollback must use a new OpenSpec change. + +## Open Questions + +- What registration credential source will replace the development registration token? +- Should session tokens become signed JWTs, opaque DB-backed tokens, or mTLS-bound session identifiers? +- What heartbeat interval and timeout thresholds should production use? diff --git a/openspec/changes/implement-run-control-registration/proposal.md b/openspec/changes/implement-run-control-registration/proposal.md new file mode 100644 index 0000000..0d27e2d --- /dev/null +++ b/openspec/changes/implement-run-control-registration/proposal.md @@ -0,0 +1,28 @@ +## Why + +The platform can model run endpoints, but the run executor still has no real control-channel registration or heartbeat path. This change establishes the lightweight run-platform control loop so later job, log, and artifact channels can attach to known run sessions without exposing host paths or credentials. + +## What Changes + +- Add typed run control payloads for hello registration, heartbeat, capability reporting, capacity reporting, session tokens, and polling hints. +- Add platform API routes for run hello and heartbeat that create/update run endpoint metadata through `service.Core`. +- Add service-level control registration behavior that validates endpoint identity, capabilities, capacity, and session token continuity. +- Extend the run-side platform client with hello and heartbeat calls using the typed control protocol. +- Add focused platform API/service tests and run client tests, including an integration-style registration/heartbeat flow. + +## Capabilities + +### New Capabilities + +- `run-control-registration`: Platform/run control-channel registration, heartbeat, session token, capability, and capacity metadata workflow. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform/` and `run/` only. +- Adds Go protocol/DTO/domain/service/API code and tests for control registration. +- Updates run control documentation and platform route catalog. +- Does not implement job claim/ack/result, log ingest, artifact transfer, plugin bridge behavior, frontend pages, billing, cloud host sales, or direct plugin/run access. diff --git a/openspec/changes/implement-run-control-registration/specs/run-control-registration/spec.md b/openspec/changes/implement-run-control-registration/specs/run-control-registration/spec.md new file mode 100644 index 0000000..785ca7c --- /dev/null +++ b/openspec/changes/implement-run-control-registration/specs/run-control-registration/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Run control payloads are typed and bounded +The system SHALL define typed run control payloads for hello registration, hello response, heartbeat, heartbeat response, capability report, and capacity report without carrying logs, artifact chunks, job result bodies, host paths, raw credentials, or direct sockets. + +#### Scenario: Control payloads are used +- **WHEN** run or platform code sends control registration or heartbeat data +- **THEN** it MUST use named protocol/DTO types from dedicated protocol or DTO packages + +#### Scenario: Control payload stays lightweight +- **WHEN** run sends hello or heartbeat +- **THEN** the request MUST include run ID, display name, version, status, capability summary, and capacity metadata only + +### Requirement: Platform accepts run hello registration +The platform SHALL expose a hello endpoint that accepts a run registration request, validates it, persists or updates the run endpoint metadata, and returns a platform-generated session token with polling hints. + +#### Scenario: New run endpoint registers +- **WHEN** run sends a valid hello request for an unknown run endpoint +- **THEN** platform MUST create a run endpoint, mark it online, store capabilities/capacity, and return an accepted hello response with a session token + +#### Scenario: Existing run endpoint registers again +- **WHEN** run sends a valid hello request for an existing run endpoint +- **THEN** platform MUST update version, display name, capabilities, capacity, heartbeat time, and return a new accepted hello response + +#### Scenario: Invalid hello request is submitted +- **WHEN** run sends a missing ID, missing registration token, invalid capacity, or empty required metadata +- **THEN** platform MUST return a JSON validation error and MUST NOT create a run endpoint + +### Requirement: Platform accepts authenticated run heartbeat +The platform SHALL expose a heartbeat endpoint that requires the active platform-issued session token for the target run endpoint and updates status, capacity, heartbeat time, and capability fingerprint state. + +#### Scenario: Heartbeat succeeds +- **WHEN** run sends a heartbeat with the active session token +- **THEN** platform MUST update the run endpoint heartbeat metadata and return an accepted heartbeat response with the next heartbeat interval + +#### Scenario: Heartbeat uses invalid session token +- **WHEN** run sends a heartbeat with a missing or stale session token +- **THEN** platform MUST reject it with a JSON validation error and MUST NOT update the endpoint metadata + +#### Scenario: Capability fingerprint changes +- **WHEN** run heartbeat reports a capability fingerprint that differs from platform's known fingerprint +- **THEN** platform MUST accept the heartbeat and request capability refresh in the heartbeat response + +### Requirement: Run client performs control registration calls +The run-side platform client SHALL provide typed hello and heartbeat methods that call the platform control endpoints and decode typed responses. + +#### Scenario: Run sends hello through client +- **WHEN** run code calls the hello client method +- **THEN** the client MUST send a JSON `POST` to `/api/v1/run/control/hello` and decode the hello response + +#### Scenario: Run sends heartbeat through client +- **WHEN** run code calls the heartbeat client method +- **THEN** the client MUST send a JSON `POST` to `/api/v1/run/control/heartbeat` and decode the heartbeat response + +#### Scenario: Platform returns error +- **WHEN** the platform control endpoint returns a non-success status +- **THEN** the run client MUST return an error and MUST NOT treat the control call as accepted + +### Requirement: Control registration is documented separately from heavier channels +The run/platform route and protocol documentation SHALL identify implemented control registration routes and explicitly defer job, log, artifact, and game client bridge transport. + +#### Scenario: Contributor inspects control docs +- **WHEN** a contributor opens run or platform protocol docs +- **THEN** the docs MUST show hello/heartbeat routes as implemented and heavier channels as deferred + +### Requirement: Control registration is verified +The change SHALL include platform service/API tests, run client tests, and an integration-style hello/heartbeat flow test. + +#### Scenario: Verification commands run +- **WHEN** the change is complete +- **THEN** `go test ./...` from `platform/`, `go test ./...` from `run/`, `scripts/check-structure.sh`, and `openspec validate implement-run-control-registration --strict` MUST pass diff --git a/openspec/changes/implement-run-control-registration/tasks.md b/openspec/changes/implement-run-control-registration/tasks.md new file mode 100644 index 0000000..6b33489 --- /dev/null +++ b/openspec/changes/implement-run-control-registration/tasks.md @@ -0,0 +1,34 @@ +## 1. Control Contracts + +- [x] 1.1 Add typed run control protocol payloads in `run/protocol` for hello, heartbeat, capability report, and capacity report. +- [x] 1.2 Add matching platform DTO/domain contracts and conversion helpers for run control hello and heartbeat. + +## 2. Platform Control Registration + +- [x] 2.1 Extend platform service behavior to register run endpoints, issue session tokens, validate heartbeat tokens, and request capability refresh on fingerprint drift. +- [x] 2.2 Implement platform control HTTP routes for hello and heartbeat using named DTOs and service methods. +- [x] 2.3 Add platform service/API tests for new registration, re-registration, heartbeat success, invalid tokens, validation failures, and capability refresh. + +## 3. Run Control Client + +- [x] 3.1 Extend `run/api.PlatformClient` with typed hello and heartbeat methods. +- [x] 3.2 Add run client tests for request paths, JSON payloads, response decoding, and platform error handling. +- [x] 3.3 Add an integration-style test that performs platform hello then heartbeat through the run client. + +## 4. Documentation + +- [x] 4.1 Update run and platform protocol/route documentation to mark hello/heartbeat implemented and heavier channels deferred. + +## 5. Verification + +- [x] 5.1 Run `go test ./...` from `platform/` and record evidence. +- [x] 5.2 Run `go test ./...` from `run/` and record evidence. +- [x] 5.3 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.4 Run `openspec validate implement-run-control-registration --strict` and record evidence. + +## Evidence + +- 2026-07-03: `go test ./...` from `platform/` passed. +- 2026-07-03: `go test ./...` from `run/` passed. +- 2026-07-03: `scripts/check-structure.sh` passed with `structure check passed`. +- 2026-07-03: `openspec validate implement-run-control-registration --strict` passed with `Change 'implement-run-control-registration' is valid`. diff --git a/openspec/changes/implement-run-job-channel/.openspec.yaml b/openspec/changes/implement-run-job-channel/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/implement-run-job-channel/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/implement-run-job-channel/design.md b/openspec/changes/implement-run-job-channel/design.md new file mode 100644 index 0000000..2b63fae --- /dev/null +++ b/openspec/changes/implement-run-job-channel/design.md @@ -0,0 +1,78 @@ +## Context + +The platform already stores job metadata and run endpoints, and run endpoints can register through the control channel. Existing jobs can be created with `queued` state and idempotency keys, but there is no platform/run API for a run endpoint to claim a job, acknowledge acceptance, report progress, submit a terminal result, observe cancel requests, or reconcile work after restart. + +This change implements the first job channel using HTTP JSON and in-memory platform state. It depends on registered run endpoint metadata and keeps job traffic separate from control, logs, artifacts, and the optional game client bridge. + +## Goals / Non-Goals + +**Goals:** + +- Define typed run job protocol payloads in `run/protocol` and matching platform DTO/domain contracts. +- Add platform job-channel routes for claim, ack, progress, result, cancel request, cancel polling, and reconcile. +- Add service-level leasing and lifecycle transitions for queued, accepted, running, succeeded, failed, and cancelled jobs. +- Preserve idempotency for duplicate claims, acks, and terminal results using existing job IDs and idempotency keys. +- Add run-side client methods for job-channel calls. +- Add tests for job lifecycle, invalid transitions, wrong endpoint/session behavior, idempotency, and reconciliation. + +**Non-Goals:** + +- No real process execution, plugin action execution, scheduler loop, or background run worker. +- No durable DB persistence, lease expiry sweeper, distributed locks, or multi-run fairness algorithm. +- No log ingest, artifact chunk transfer, game client bridge, frontend behavior, billing, cloud host sales, or direct plugin-to-run access. +- No raw host paths, raw credentials, direct sockets, or large result bodies in job payloads. + +## Decisions + +### Decision 1: HTTP JSON job endpoints + +The initial job channel uses small JSON `POST` endpoints under `/api/v1/run/jobs/*`. This matches the existing API style and keeps the lifecycle testable without introducing streaming transport. + +Alternative considered: long-lived WebSocket or gRPC stream for job events. Rejected for this change because the architecture separates control, jobs, logs, and artifacts, and the first job lifecycle can be proven with bounded request/response calls. + +### Decision 2: Existing job resource is the source of truth + +Job-channel operations update the existing `domain.Job` stored by the repository. Claim moves a queued job to `accepted`, ack confirms acceptance or moves to `running`, progress updates bounded progress, and result writes terminal state and a bounded result reference. + +Alternative considered: adding a separate run job lease table now. Rejected because current storage is in-memory and the existing job aggregate already contains run endpoint, state, progress, result reference, and idempotency key. + +### Decision 3: Leases are service metadata, not persisted models + +The service keeps lightweight in-memory job lease metadata keyed by job ID. Lease metadata records the run endpoint ID, session token, attempt number, lease time, last update time, cancel request flag, and terminal result fingerprint. + +Alternative considered: adding durable lease models before persistence exists. Rejected because it would create model churn without improving the current in-memory system. + +### Decision 4: Session token gates job calls + +All run job-channel calls require the active session token for the run endpoint. This reuses the control registration session and prevents stale or wrong run endpoints from mutating job state. + +Alternative considered: accepting only run endpoint ID. Rejected because hello/heartbeat already established platform-issued session continuity. + +### Decision 5: Results remain bounded references + +Terminal job result payloads carry status, message, error code, and `resultRef`. Large logs, files, backups, and config blobs must move through later log/artifact channels, not job result bodies. + +Alternative considered: allowing inline result bodies. Rejected because job result traffic must not block control, logs, or artifact transfer and must not expose raw host paths. + +## Risks / Trade-offs + +- [Risk] In-memory leases disappear on platform restart. Mitigation: keep lease handling behind `service.Core` and add reconcile behavior so a run can re-report work after reconnect. +- [Risk] No lease expiry means a stuck accepted job may remain accepted. Mitigation: expose reconciliation and cancellation now; add expiry/sweeper in a later persistence/runtime change. +- [Risk] HTTP polling has latency. Mitigation: this change prioritizes correctness and testability; later transport changes can add long-polling or streaming without changing lifecycle semantics. +- [Risk] Result references cannot prove artifact availability yet. Mitigation: keep references opaque until the artifact channel change implements checksum and transfer guarantees. + +## Migration Plan + +1. Add job protocol, DTO, domain, validation, and service contracts. +2. Add platform API handlers and tests for job lifecycle and idempotency. +3. Add run client methods and tests for request/response behavior. +4. Update protocol and route docs. +5. Verify with platform tests, run tests, structure check, and strict OpenSpec validation. + +Rollback before dependent changes is removal of the job route/client additions and this OpenSpec change. After log/artifact/server workflow changes depend on job lifecycle state, rollback must use a new OpenSpec change. + +## Open Questions + +- What production lease duration and retry policy should run endpoints use? +- Should queued job claim ordering later support priority, FIFO only, or per-server concurrency limits? +- Should terminal result fingerprints be signed, checksummed, or backed by artifact metadata once artifact transfer exists? diff --git a/openspec/changes/implement-run-job-channel/proposal.md b/openspec/changes/implement-run-job-channel/proposal.md new file mode 100644 index 0000000..c8a4ff0 --- /dev/null +++ b/openspec/changes/implement-run-job-channel/proposal.md @@ -0,0 +1,28 @@ +## Why + +Run endpoints can register and heartbeat, but they still cannot receive bounded platform jobs or report lifecycle state. This change adds the job channel needed for server lifecycle and plugin-triggered work while keeping it independent from control, log ingest, artifact transfer, and game client bridge traffic. + +## What Changes + +- Add typed run job protocol payloads for claim, ack, progress, result, cancel, and reconcile workflows. +- Add platform API routes that let registered run endpoints claim queued jobs, acknowledge acceptance, report progress, submit terminal results, fetch cancel requests, and reconcile active work after restart. +- Extend platform service behavior for job leasing, idempotent claims/acks/results, lifecycle validation, and cancellation metadata using the existing in-memory repository. +- Extend the run-side platform client with typed job-channel methods. +- Add focused platform service/API tests and run client tests, including job lifecycle and idempotency/reconciliation coverage. + +## Capabilities + +### New Capabilities + +- `run-job-channel`: Platform/run job-channel lifecycle, lease, acknowledgement, progress, result, cancel, reconcile, and idempotency workflow. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform/` and `run/` only. +- Adds Go protocol/DTO/domain/service/API code and tests for the job channel. +- Updates run/platform protocol and route documentation. +- Does not implement durable log ingest, artifact chunk transfer, game client bridge behavior, frontend pages, billing, cloud host sales, or direct plugin-to-run/plugin-to-platform bypasses. diff --git a/openspec/changes/implement-run-job-channel/specs/run-job-channel/spec.md b/openspec/changes/implement-run-job-channel/specs/run-job-channel/spec.md new file mode 100644 index 0000000..fea3c76 --- /dev/null +++ b/openspec/changes/implement-run-job-channel/specs/run-job-channel/spec.md @@ -0,0 +1,104 @@ +## ADDED Requirements + +### Requirement: Run job payloads are typed and bounded +The system SHALL define typed run job payloads for claim, claim response, ack, progress, result, cancel request, cancel polling, and reconcile workflows without carrying logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies. + +#### Scenario: Job payloads are used +- **WHEN** run or platform code sends job lifecycle data +- **THEN** it MUST use named protocol/DTO types from dedicated protocol or DTO packages + +#### Scenario: Job payload stays bounded +- **WHEN** run submits job progress or result +- **THEN** the request MUST include job identity, run endpoint identity, session token, lifecycle state, progress metadata, message, error code, and result reference only + +### Requirement: Platform lets run claim queued jobs +The platform SHALL expose a job claim endpoint that validates run session continuity, selects a queued job assigned to the run endpoint, leases it, and returns bounded job metadata. + +#### Scenario: Run claims queued job +- **WHEN** a registered run endpoint requests a job claim and a queued job exists for that endpoint +- **THEN** platform MUST mark the job accepted, return the job metadata, lease token, attempt number, and polling hints + +#### Scenario: No queued job exists +- **WHEN** a registered run endpoint requests a job claim and no queued job exists for that endpoint +- **THEN** platform MUST return an accepted empty claim response without changing unrelated jobs + +#### Scenario: Claim uses invalid session token +- **WHEN** run submits a claim with a missing or stale session token +- **THEN** platform MUST return a JSON validation error and MUST NOT change job state + +### Requirement: Platform accepts job acknowledgements and progress +The platform SHALL expose job ack and progress endpoints that require the active session token and active job lease for the run endpoint. + +#### Scenario: Job ack succeeds +- **WHEN** run acknowledges an active lease for an accepted job +- **THEN** platform MUST keep or move the job to a running lifecycle state and return an accepted ack response + +#### Scenario: Job progress succeeds +- **WHEN** run reports bounded progress for an accepted or running job +- **THEN** platform MUST update percent, message, heartbeat time, and return an accepted progress response + +#### Scenario: Invalid progress is submitted +- **WHEN** run reports progress outside 0 through 100 or with a stale lease token +- **THEN** platform MUST return a JSON validation error and MUST NOT update the job + +### Requirement: Platform accepts idempotent terminal job results +The platform SHALL expose a result endpoint that accepts terminal succeeded, failed, or cancelled results for an active lease and treats repeated equivalent terminal result submissions as idempotent. + +#### Scenario: Job result succeeds +- **WHEN** run submits a valid terminal result for an active lease +- **THEN** platform MUST update the job terminal state, progress, result reference, and return an accepted result response + +#### Scenario: Duplicate terminal result is submitted +- **WHEN** run repeats the same terminal result for a job already in that terminal state +- **THEN** platform MUST return the same accepted terminal result response without mutating unrelated metadata + +#### Scenario: Conflicting terminal result is submitted +- **WHEN** run submits a different terminal result for a job already terminal +- **THEN** platform MUST return a JSON validation error and MUST NOT overwrite the existing result + +### Requirement: Platform supports job cancellation polling +The platform SHALL expose a service/API path to request cancellation for a job and a run-facing path to poll cancellation for the active lease. + +#### Scenario: Platform requests cancellation +- **WHEN** platform requests cancellation for an accepted or running job +- **THEN** platform MUST record the cancel request and keep the job available for run cancellation polling + +#### Scenario: Run polls cancellation +- **WHEN** run polls cancellation for an active leased job with a cancel request +- **THEN** platform MUST return a cancel response naming that job and cancellation reason + +### Requirement: Platform supports run reconciliation +The platform SHALL expose a reconcile endpoint that lets a registered run endpoint report active job IDs after restart and receive platform-known active jobs for that endpoint. + +#### Scenario: Run reconciles active jobs +- **WHEN** run submits active job IDs for its endpoint after restart +- **THEN** platform MUST return active jobs known to the platform for that endpoint and mark unknown reported jobs for run-side cleanup + +#### Scenario: Reconcile uses invalid session token +- **WHEN** run submits reconcile with a missing or stale session token +- **THEN** platform MUST return a JSON validation error and MUST NOT change job state + +### Requirement: Run client performs job-channel calls +The run-side platform client SHALL provide typed claim, ack, progress, result, cancel polling, and reconcile methods that call the platform job endpoints and decode typed responses. + +#### Scenario: Run sends job channel calls through client +- **WHEN** run code calls job-channel client methods +- **THEN** the client MUST send JSON `POST` requests to the matching `/api/v1/run/jobs/*` endpoints and decode typed responses + +#### Scenario: Platform returns job error +- **WHEN** a platform job endpoint returns a non-success status +- **THEN** the run client MUST return an error and MUST NOT treat the job call as accepted + +### Requirement: Job channel is documented separately from other channels +The run/platform route and protocol documentation SHALL identify implemented job-channel routes and explicitly keep control, log ingest, artifact transfer, and game client bridge transport separate. + +#### Scenario: Contributor inspects job docs +- **WHEN** a contributor opens run or platform protocol docs +- **THEN** the docs MUST show job claim, ack, progress, result, cancel polling, and reconcile routes as implemented while heavier log/artifact channels remain deferred + +### Requirement: Job channel is verified +The change SHALL include platform service/API tests, run client tests, job lifecycle tests, and idempotency/reconciliation tests. + +#### Scenario: Verification commands run +- **WHEN** the change is complete +- **THEN** `go test ./...` from `platform/`, `go test ./...` from `run/`, `scripts/check-structure.sh`, and `openspec validate implement-run-job-channel --strict` MUST pass diff --git a/openspec/changes/implement-run-job-channel/tasks.md b/openspec/changes/implement-run-job-channel/tasks.md new file mode 100644 index 0000000..4a1337f --- /dev/null +++ b/openspec/changes/implement-run-job-channel/tasks.md @@ -0,0 +1,35 @@ +## 1. Job Contracts + +- [x] 1.1 Add typed run job protocol payloads in `run/protocol` for claim, ack, progress, result, cancel polling, and reconcile. +- [x] 1.2 Add matching platform DTO/domain contracts and conversion helpers for job-channel requests and responses. +- [x] 1.3 Add validation rules for bounded job payloads, lifecycle states, progress, session, lease, and terminal result metadata. + +## 2. Platform Job Channel + +- [x] 2.1 Extend platform service behavior for job claim leasing, session checks, ack, progress, terminal results, cancellation, reconcile, and idempotency. +- [x] 2.2 Implement platform job-channel HTTP routes using named DTOs and service methods. +- [x] 2.3 Add platform service/API tests for lifecycle success, no-job claim, invalid session/lease, invalid progress, cancellation, duplicate results, conflicting results, and reconcile. + +## 3. Run Job Client + +- [x] 3.1 Extend `run/api.PlatformClient` with typed job claim, ack, progress, result, cancel polling, and reconcile methods. +- [x] 3.2 Add run client tests for request paths, JSON payloads, response decoding, and platform error handling. +- [x] 3.3 Add an integration-style client test that claims a job, acknowledges it, reports progress, submits a result, and reconciles against a test platform job endpoint. + +## 4. Documentation + +- [x] 4.1 Update run and platform protocol/route documentation to mark job-channel endpoints implemented and keep log/artifact/game-client channels separate. + +## 5. Verification + +- [x] 5.1 Run `go test ./...` from `platform/` and record evidence. +- [x] 5.2 Run `go test ./...` from `run/` and record evidence. +- [x] 5.3 Run `scripts/check-structure.sh` and record evidence. +- [x] 5.4 Run `openspec validate implement-run-job-channel --strict` and record evidence. + +## Evidence + +- 2026-07-03: `go test ./...` from `platform/` passed. +- 2026-07-03: `go test ./...` from `run/` passed. +- 2026-07-03: `scripts/check-structure.sh` passed with `structure check passed`. +- 2026-07-03: `openspec validate implement-run-job-channel --strict` passed with `Change 'implement-run-job-channel' is valid`. diff --git a/openspec/changes/implement-run-worker-real-execution/.openspec.yaml b/openspec/changes/implement-run-worker-real-execution/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/implement-run-worker-real-execution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/implement-run-worker-real-execution/design.md b/openspec/changes/implement-run-worker-real-execution/design.md new file mode 100644 index 0000000..dce8539 --- /dev/null +++ b/openspec/changes/implement-run-worker-real-execution/design.md @@ -0,0 +1,58 @@ +## Context + +Run has typed clients for control/job/log/artifact channels and local spool packages, but its executable behavior is still a smoke summary plus bounded lifecycle executor that immediately returns success metadata. Platform-side job leasing is already available, so the missing piece is a persistent run worker that consumes jobs safely. + +## Goals / Non-Goals + +**Goals:** + +- Add hello/heartbeat and job polling loops. +- Execute install/start/stop lifecycle jobs using scoped process supervision. +- Emit progress and terminal results through the job channel. +- Connect stdout/stderr to log spool and lifecycle artifacts to artifact queue hooks. +- Enforce path, credential, command, and socket safety. + +**Non-Goals:** + +- No arbitrary plugin code execution or unbounded shell access. +- No game client bridge implementation. +- No cloud host provisioning or billing. +- No external artifact/log storage backend implementation. + +## Decisions + +### Decision 1: Worker owns channel scheduling + +The run worker keeps control heartbeat high priority, job claim/result next, logs durable/batched, and artifacts lower priority. Long transfers must not block heartbeat or job result submission. + +### Decision 2: Lifecycle actions use scoped command templates + +Plugin lifecycle action references resolve to bounded command templates under a configured server workspace. Absolute paths, parent traversal, raw credentials, and socket exposure are rejected. + +### Decision 3: Process supervisor is an abstraction + +Process management sits behind a supervisor interface so tests can use fake processes and later game-specific process handling can be added without rewriting the worker loop. + +### Decision 4: Smoke mode remains + +Smoke mode stays available for local diagnostics. Worker mode is enabled through explicit config. + +## Risks / Trade-offs + +- [Risk] Real process orchestration can hang. Mitigation: bounded timeouts, cancellation, progress heartbeat, and supervisor tests. +- [Risk] Command templates can become unsafe. Mitigation: validation rejects shell metacharacter abuse, absolute paths, direct sockets, and secret env leaks. +- [Risk] Worker loops can starve logs/artifacts. Mitigation: separate scheduling and priority rules. + +## Migration Plan + +1. Add worker config and session state. +2. Implement control heartbeat and job loop. +3. Add process supervisor and lifecycle executor. +4. Wire logs/artifacts to existing queues. +5. Update command entrypoint and docs. +6. Add unit and integration-style tests. + +## Open Questions + +- Whether future plugin action runtimes should interpret JSON action schemas directly or compile them into lifecycle command templates. +- Whether server process state should be persisted in a journal file or a small local database. diff --git a/openspec/changes/implement-run-worker-real-execution/proposal.md b/openspec/changes/implement-run-worker-real-execution/proposal.md new file mode 100644 index 0000000..9acf610 --- /dev/null +++ b/openspec/changes/implement-run-worker-real-execution/proposal.md @@ -0,0 +1,28 @@ +## Why + +The run executor currently returns metadata-only success for lifecycle assignments. The platform can queue and claim jobs, but no daemon loop performs hello, heartbeat, claim, ack, progress, result, cancel, reconcile, process supervision, log collection, or artifact worker coordination. Operators need real local execution before server management can be considered operational. + +## What Changes + +- Add a run worker loop for registration, heartbeat, job polling, acknowledgement, progress, results, cancel polling, and reconcile. +- Replace metadata-only lifecycle execution with scoped install/start/stop process orchestration. +- Enforce workspace scoping, command allowlists, redaction, and channel separation. +- Connect process output to log spool and lifecycle result refs to artifact upload hooks. +- Add configuration, tests, and integration-style verification with a platform test server. + +## Capabilities + +### New Capabilities + +- `run-worker-real-execution`: Real run-side worker loop and scoped lifecycle process execution. + +### Modified Capabilities + +- `server-management-workflows`: Lifecycle jobs become executable by run instead of metadata-only. +- `run-job-channel`: The run client is used by a persistent worker loop. + +## Impact + +- Affects `run/` config, command, runtime, protocol usage, spool integration, artifact hooks, docs, and tests. +- Affects `platform/` tests where integration-style job flow coverage is needed. +- Does not expose host paths, raw credentials, direct sockets, unrestricted shell execution, billing, or cloud host workflows. diff --git a/openspec/changes/implement-run-worker-real-execution/specs/run-worker-real-execution/spec.md b/openspec/changes/implement-run-worker-real-execution/specs/run-worker-real-execution/spec.md new file mode 100644 index 0000000..9026e16 --- /dev/null +++ b/openspec/changes/implement-run-worker-real-execution/specs/run-worker-real-execution/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Run worker maintains platform session +The run executable SHALL support a worker mode that registers with platform and maintains heartbeat state. + +#### Scenario: Worker registers and heartbeats +- **WHEN** run starts in worker mode with valid platform configuration +- **THEN** it MUST send hello, store the active session token, and continue sending heartbeat metadata + +#### Scenario: Heartbeat does not carry heavy channels +- **WHEN** run sends heartbeat +- **THEN** it MUST NOT include logs, artifact chunks, job result bodies, host paths, raw credentials, or direct sockets + +### Requirement: Run worker processes job lifecycle +The run worker SHALL claim, acknowledge, report progress, complete, cancel, and reconcile jobs through the platform job channel. + +#### Scenario: Job assignment completes +- **WHEN** platform assigns a supported lifecycle job +- **THEN** run MUST ack the job, report bounded progress, execute scoped lifecycle work, and submit a terminal result + +#### Scenario: Cancel request handled +- **WHEN** platform reports cancellation for an active job lease +- **THEN** run MUST attempt cancellation and submit a bounded cancelled or failed result + +### Requirement: Lifecycle execution is scoped +The run worker SHALL execute install, start, and stop lifecycle commands only inside configured server workspaces with validated command templates. + +#### Scenario: Scoped lifecycle command accepted +- **WHEN** a lifecycle job resolves to a safe command template and workspace +- **THEN** run MUST execute it through the process supervisor and redact unsafe output before platform reporting + +#### Scenario: Unsafe lifecycle command rejected +- **WHEN** a lifecycle job requests absolute paths, parent traversal, raw credentials, direct sockets, or unrestricted shell execution +- **THEN** run MUST reject the job with a bounded failure result + +### Requirement: Run channels remain prioritized +The run worker SHALL keep control, job, log, and artifact work channelized so large transfer work cannot block heartbeat or job result submission. + +#### Scenario: Artifact work pending during heartbeat +- **WHEN** artifact uploads are pending and a heartbeat is due +- **THEN** run MUST prioritize heartbeat over artifact transfer work + +#### Scenario: Process logs are spooled +- **WHEN** a managed process writes stdout or stderr +- **THEN** run MUST write bounded log entries to local spool for platform ingest diff --git a/openspec/changes/implement-run-worker-real-execution/tasks.md b/openspec/changes/implement-run-worker-real-execution/tasks.md new file mode 100644 index 0000000..2dd397a --- /dev/null +++ b/openspec/changes/implement-run-worker-real-execution/tasks.md @@ -0,0 +1,56 @@ +## 1. Run Worker Loop + +- [x] 1.1 Add run worker service that performs hello registration and stores active session state. +- [x] 1.2 Add heartbeat loop with capability refresh and capacity reporting. +- [x] 1.3 Add job claim loop with ack, progress, result, cancel polling, and reconcile. +- [x] 1.4 Add bounded retry/backoff behavior without blocking heartbeat. + +## 2. Process Lifecycle Execution + +- [x] 2.1 Replace metadata-only lifecycle executor with scoped install/start/stop execution. +- [x] 2.2 Add process supervisor abstraction for server working directory, command templates, env allowlist, and lifecycle state. +- [x] 2.3 Add safe command resolution from plugin lifecycle action schemas without unrestricted shell execution. +- [x] 2.4 Add local state/journal for active server processes and in-flight jobs. +- [x] 2.5 Add cancellation behavior for running lifecycle jobs. + +## 3. Security Boundaries + +- [x] 3.1 Enforce scoped server workspace roots and never expose raw host paths to platform_web or plugins. +- [x] 3.2 Reject plugin action payloads requesting raw credentials, direct sockets, absolute paths, or unrestricted commands. +- [x] 3.3 Redact command output and metadata before sending progress/result. +- [x] 3.4 Keep control, job, log, and artifact channels independent. + +## 4. Log And Artifact Worker Hooks + +- [x] 4.1 Connect process stdout/stderr to the existing log spool. +- [x] 4.2 Add artifact upload hook for lifecycle result refs. +- [x] 4.3 Ensure large artifact work cannot block control heartbeat or job result submission. + +## 5. CLI And Config + +- [x] 5.1 Add run config for platform URL, run endpoint ID, registration token, workspace root, poll intervals, and capacity. +- [x] 5.2 Update `run/cmd/run` to start the worker in local mode. +- [x] 5.3 Keep smoke mode available for tests and local diagnostics. + +## 6. Verification + +- [x] 6.1 Add unit tests for worker state transitions, retry behavior, and cancel/reconcile. +- [x] 6.2 Add run tests for scoped lifecycle command execution using temp workspaces. +- [x] 6.3 Add integration-style test with a platform test server: hello → heartbeat → claim → ack → progress → result. +- [x] 6.4 Run `cd run && go test ./...` and record evidence. +- [x] 6.5 Run `cd platform && go test ./...` and record evidence. +- [x] 6.6 Run `scripts/check-structure.sh` and record evidence. +- [x] 6.7 Run `openspec validate implement-run-worker-real-execution --strict` and record evidence. + +## Evidence + +- 2026-07-06: Added `run/runtime.Worker` with hello session registration, heartbeat, claim, ack, progress, cancel polling, terminal result, reconcile, bounded retry ticker reset, and an in-memory active job journal. +- 2026-07-06: Replaced metadata-only lifecycle execution with scoped command-template execution through `ProcessSupervisor`, per-server workspace resolution, command/env validation, cancellation, redaction, log sink, and lifecycle artifact hook. +- 2026-07-06: Added `run/config` worker settings for endpoint identity, registration token, workspace/spool roots, max jobs, heartbeat/poll intervals, and retry backoff; updated `run/cmd/run` to preserve smoke mode and start worker mode when `RUN_MODE=worker`. +- 2026-07-06: Updated `run/README.md`, `run/protocol/job.md`, and `run/protocol/control.md` to document real worker mode, scoped lifecycle command templates, and channel boundaries. +- 2026-07-06: `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./runtime` passed after adding lifecycle tests for scoped command execution, unsafe template rejection, workspace escape rejection, cancellation, log sink, artifact hook, worker registration, heartbeat, claim/ack/progress/result, cancel/reconcile, spool token propagation, bounded retry, and HTTP platform-like worker flow. +- 2026-07-06: Updated `platform/api/job_channel_handlers_test.go` so the platform router workflow covers `hello -> heartbeat -> claim -> ack -> progress -> cancel -> result -> reconcile`. +- 2026-07-06: `cd run && GOCACHE=/private/tmp/browser-go-build-cache go test ./...` passed with escalated loopback permission because existing API/worker `httptest` suites bind local ports. +- 2026-07-06: `cd platform && GOCACHE=/private/tmp/browser-go-build-cache go test ./...` passed. +- 2026-07-06: `scripts/check-structure.sh` passed. +- 2026-07-06: `openspec validate implement-run-worker-real-execution --strict` reported `Change 'implement-run-worker-real-execution' is valid`; PostHog telemetry flush failed due restricted DNS and did not affect validation. diff --git a/openspec/changes/implement-scum-server-plugin/design.md b/openspec/changes/implement-scum-server-plugin/design.md new file mode 100644 index 0000000..19cba44 --- /dev/null +++ b/openspec/changes/implement-scum-server-plugin/design.md @@ -0,0 +1,38 @@ +## Context + +The platform already supports registry-backed game plugins, marketplace projection, server lifecycle workflows, run-side lifecycle jobs, and browser-visible server controls. The missing piece is a concrete SCUM plugin package that follows those contracts instead of relying on the generic `game.example` development fixture. + +## Goals / Non-Goals + +**Goals:** + +- Provide a SCUM plugin directory with manifest, create form schema, lifecycle action templates, and platform-hosted page declarations. +- Keep lifecycle requests platform-mediated and safe: plugin metadata must expose only logical action refs, capabilities, page keys, and permissions. +- Prove one SCUM plugin can be registered, discovered in the marketplace, and used to create at least two SCUM server instances. +- Preserve the existing platform_web management console style and reuse the generic server/plugin surfaces. + +**Non-Goals:** + +- No real commercial SCUM binary download or production hosting orchestration. +- No direct browser/plugin connection to run endpoints. +- No cloud-provider, host-sales, billing, or unrelated marketplace behavior. + +## Decisions + +### Decision 1: SCUM plugin is a first-party local example plugin + +The plugin lives under `plugins/examples/scum-server-plugin` and follows the same manifest and validation schema as other game management plugins. + +### Decision 2: Lifecycle templates use safe local fixture commands + +The action templates use bounded executable names and arguments accepted by the run lifecycle executor. They prove install/start/stop wiring without exposing real host paths or requiring a SCUM dedicated server binary. + +### Decision 3: Platform and frontend reuse existing plugin contracts + +No new platform_web route or visual system is introduced. The SCUM plugin declares overview/config/log pages, and the existing marketplace/server management/plugin bridge surfaces render those declarations with the current theme-aware console components. + +## Risks / Mitigations + +- [Risk] The SCUM proof could be mistaken for production SCUM hosting. Mitigation: describe it as a local fixture plugin and keep action templates bounded. +- [Risk] Adding another plugin could drift from manifest safety rules. Mitigation: default validation now checks both example and SCUM manifests. +- [Risk] Browser-facing surfaces could expose unsafe details. Mitigation: local debug smoke rejects forbidden fragments from registration, marketplace, server, job, log, and artifact responses. diff --git a/openspec/changes/implement-scum-server-plugin/proposal.md b/openspec/changes/implement-scum-server-plugin/proposal.md new file mode 100644 index 0000000..a9099c6 --- /dev/null +++ b/openspec/changes/implement-scum-server-plugin/proposal.md @@ -0,0 +1,22 @@ +## Why + +The current lifecycle proof uses the generic development plugin, but operators need a concrete SCUM server plugin that can be discovered from the plugin marketplace and used to create multiple SCUM server instances through the platform-mediated lifecycle path. + +## What Changes + +- Add a first-party local SCUM server plugin directory under `plugins/examples/scum-server-plugin`. +- Copy the existing safe plugin lifecycle pattern into SCUM-specific manifest metadata, create-form schema, plugin pages, and lifecycle action templates. +- Update plugin validation so both the development plugin and SCUM plugin are validated by default. +- Extend local debug smoke coverage so the plugin marketplace can discover the SCUM plugin and the platform can create multiple SCUM server instances from it. + +## Capabilities + +### New Capabilities + +- `scum-server-plugin`: Provides a local SCUM game management plugin discoverable through the plugin marketplace and usable for multi-instance platform-mediated lifecycle workflows. + +## Impact + +- Affects `plugins/` example plugin assets, manifest validation, and tests. +- Affects `scripts/local-debug-smoke.sh` registration and API proof data. +- Does not add billing, cloud host sales, unrelated SaaS marketplace behavior, direct plugin-run transport, raw credentials, direct sockets, or raw host paths. diff --git a/openspec/changes/implement-scum-server-plugin/specs/scum-server-plugin/spec.md b/openspec/changes/implement-scum-server-plugin/specs/scum-server-plugin/spec.md new file mode 100644 index 0000000..df40e5e --- /dev/null +++ b/openspec/changes/implement-scum-server-plugin/specs/scum-server-plugin/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: SCUM plugin package +The repository SHALL include a first-party local SCUM server plugin package with manifest metadata, create-form schema, lifecycle action templates, and plugin page declarations. + +#### Scenario: SCUM manifest validates +- **WHEN** plugin manifest validation runs with default targets +- **THEN** it MUST validate the SCUM plugin manifest and reject unsafe raw host paths, direct run sockets, bearer credentials, passwords, raw AI keys, and undeclared transport details + +### Requirement: SCUM plugin marketplace discovery +The platform SHALL be able to register the SCUM plugin manifest and project it into the plugin marketplace using safe platform registry metadata. + +#### Scenario: SCUM plugin is discoverable +- **WHEN** the SCUM plugin manifest is registered through platform APIs +- **THEN** `GET /api/v1/plugin-marketplace/plugins?serverType=scum&keyword=scum` MUST include the SCUM plugin without exposing unsafe fields + +### Requirement: SCUM multi-instance creation +The platform SHALL allow the SCUM plugin to create multiple independent SCUM server instances through the existing platform-mediated server lifecycle workflow. + +#### Scenario: Two SCUM servers are created from one plugin +- **WHEN** local debug proof creates two server instances using the SCUM plugin +- **THEN** both instances MUST have distinct IDs and names, share the SCUM plugin association, and receive independent lifecycle install jobs diff --git a/openspec/changes/implement-scum-server-plugin/tasks.md b/openspec/changes/implement-scum-server-plugin/tasks.md new file mode 100644 index 0000000..6664b65 --- /dev/null +++ b/openspec/changes/implement-scum-server-plugin/tasks.md @@ -0,0 +1,50 @@ +## 1. SCUM Plugin Assets + +- [x] 1.1 Add `plugins/examples/scum-server-plugin` with SCUM manifest metadata, create-form schema, pages, and lifecycle action templates. +- [x] 1.2 Ensure plugin manifest validation accepts the SCUM plugin and still rejects unsafe transport/credential/path content. + +## 2. Discovery and Multi-Instance Proof + +- [x] 2.1 Update local debug smoke registration to register SCUM plugin metadata through platform APIs. +- [x] 2.2 Update local debug smoke creation proof to create two SCUM servers from the SCUM plugin and verify marketplace discovery. + +## 3. Verification + +- [x] 3.1 Run `cd plugins && npm run typecheck && npm run test && npm run validate:manifest`. +- [x] 3.2 Run `scripts/check-structure.sh`. +- [x] 3.3 Run `openspec validate implement-scum-server-plugin --strict`. +- [x] 3.4 Run focused platform/platform_web checks and a browser walkthrough if UI behavior changes beyond existing generic surfaces. + +## Evidence + +- Plugin validation: + - `cd plugins && npm run typecheck` passed. + - `cd plugins && npm run test` passed: `tests/manifest-validation.test.ts` passed 12 tests, including SCUM manifest validation. + - Initial sandbox `cd plugins && npm run validate:manifest` failed because `tsx` could not create its local IPC pipe (`EPERM`); escalated rerun passed. + - `cd plugins && npm run validate:manifest` validated both `examples/dev-game-plugin/manifest.json` and `examples/scum-server-plugin/manifest.json`. + +- SCUM plugin implementation: + - Added `plugins/examples/scum-server-plugin/manifest.json` with `game.scum`, server type `scum`, SCUM-specific marketplace metadata, lifecycle actions, bridge actions, permissions, plugin pages, and AI purposes. + - Added `plugins/examples/scum-server-plugin/schemas/create-form.schema.json` with SCUM server name, game port, query port, and max-player fields. + - Added scoped lifecycle fixtures under `plugins/examples/scum-server-plugin/actions/` for install/start/stop/restart/status using safe bounded command templates. + - Updated `plugins/package.json`, `plugins/tests/manifest-validation.test.ts`, and `scripts/check-structure.sh` so SCUM plugin assets are part of default validation. + +- API/local debug proof: + - Updated `scripts/local-debug-smoke.sh` to register `game.scum` through `/api/v1/game-plugins/register-manifest`. + - Updated smoke proof to create `scum-alpha` and `scum-beta` through `/api/v1/server-instances/workflows/create`. + - Initial smoke against `18080` failed because that port was already occupied by stale state. Isolated rerun on `18087` with `LOCAL_DEBUG_ROOT=/private/tmp/browser-scum-local-debug-proof-2` passed. + - Passing smoke evidence directory: `/private/tmp/browser-scum-local-debug-proof-2/smoke`. + - Smoke verified `/api/v1/plugin-marketplace/plugins?serverType=scum&keyword=scum` contains `game.scum`, and both SCUM instances have separate lifecycle install job records. + +- Browser walkthrough: + - Started an isolated browser verification stack on platform `127.0.0.1:18088` and frontend `127.0.0.1:5188`. + - Seeded the stack with `LOCAL_DEBUG_SELF_START=false LOCAL_DEBUG_PLATFORM_PORT=18088 LOCAL_DEBUG_WEB_PORT=5188 LOCAL_DEBUG_ROOT=/private/tmp/browser-scum-browser-proof scripts/local-debug-smoke.sh`; smoke passed. + - Logged into `http://127.0.0.1:5188/` as `operator.local@example.test / operator-local`; 首页 showed API-backed platform data and `game.scum: 2 个实例`. + - 插件市场 showed `SCUM Server`, `game.scum`, status `已安装`, server type `scum`, and capabilities `process.install`, `process.start`, `process.stop`; no local fallback or forbidden fragments were visible. + - 服务器管理 showed `SCUM Alpha` (`scum-alpha`) and `SCUM Beta` (`scum-beta`) as separate server cards; no local fallback or forbidden fragments were visible. + - Opened `#/servers/scum-alpha`, confirmed plugin binding `game.scum@0.1.0`, started the server via the visible `启动` control, confirmed the dialog, and observed `SCUM Alpha` change to `运行中` / `在线` with `停止` enabled. + +- Final gates: + - `bash -n scripts/local-debug-smoke.sh` passed. + - `scripts/check-structure.sh` passed with `structure check passed`. + - `openspec validate implement-scum-server-plugin --strict` passed with `Change 'implement-scum-server-plugin' is valid`; PostHog telemetry flush reported `ENOTFOUND edge.openspec.dev`, which did not affect validation. diff --git a/openspec/changes/implement-server-management-workflows/.openspec.yaml b/openspec/changes/implement-server-management-workflows/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/implement-server-management-workflows/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/implement-server-management-workflows/design.md b/openspec/changes/implement-server-management-workflows/design.md new file mode 100644 index 0000000..60780e5 --- /dev/null +++ b/openspec/changes/implement-server-management-workflows/design.md @@ -0,0 +1,87 @@ +## Context + +The platform already has installed game plugin metadata, server instance records, run endpoint registration, job claim/ack/progress/result, log ingest, artifact transfer, and a console shell. The missing product path is the operator workflow that creates a server from a plugin, dispatches lifecycle jobs through the job channel, updates server state from terminal job results, and exposes those actions in the server management UI. + +This change must keep platform, run, plugin, and frontend ownership boundaries intact. Browser code must call platform APIs only; plugin pages and platform_web must not receive run credentials, raw host paths, raw AI keys, or direct sockets. Run-side lifecycle execution remains a bounded job executor and does not add unrestricted command execution. + +## Goals / Non-Goals + +**Goals:** + +- Add platform-mediated create/install, start, and stop workflow APIs for server instances. +- Dispatch install/start/stop through existing job-channel records with stable lifecycle capabilities and idempotency keys. +- Validate plugin installation state, lifecycle action references, run endpoint status/capabilities, server state, and config version before dispatch. +- Project terminal lifecycle job results onto `ServerInstance.State`. +- Add run-side lifecycle executor code that handles install/start/stop job assignments with bounded metadata-only results. +- Add server management frontend contracts, API methods, form/actions, tests, and browser walkthrough evidence. +- Keep plugin manifest and SDK capability enums aligned with lifecycle install support. + +**Non-Goals:** + +- No real game process execution, installer downloads, file writes, backups, update/restart workflows, or schedulers. +- No authentication/authorization route group beyond existing service validation. +- No direct plugin-to-run access, run sockets, raw host path exposure, raw credentials, billing, cloud host sales, or provider marketplace behavior. +- No new database persistence layer, migrations, or distributed lease storage. +- No replacement of the existing hash router or introduction of a frontend router dependency. + +## Decisions + +### Decision 1: Add workflow action routes beside existing resource routes + +The existing `POST /api/v1/server-instances` resource route remains a direct server instance record creation path. Workflow creation is added as `POST /api/v1/server-instances/workflows/create`, and lifecycle commands are added as `POST /api/v1/server-instances/{id}/start` and `POST /api/v1/server-instances/{id}/stop`. + +Alternative considered: change `POST /api/v1/server-instances` to return a workflow response and always dispatch install. Rejected because existing resource-route tests and clients use the direct create/list/detail contract, and explicit workflow routes make dispatching side effects clear. + +### Decision 2: Lifecycle jobs use fixed run capabilities + +Workflow dispatch maps create/install to `process.install`, start to `process.start`, and stop to `process.stop`. The plugin manifest schema, plugin SDK type union, platform validator allowlist, and run smoke/runtime capability list will include `process.install` so create workflows can be validated consistently. + +Alternative considered: use arbitrary plugin action JSON references as job capabilities. Rejected because claim matching already uses capability strings reported by run endpoints, and action references are plugin metadata rather than run capability names. + +### Decision 3: Platform service owns lifecycle validation and dispatch + +`platform/service.Core` adds explicit lifecycle methods that validate dependencies and state transitions, then create queued jobs with operator-provided idempotency keys. The service rejects stale `configVersion` values for start/stop commands. + +Alternative considered: let the frontend create jobs directly through generic `POST /api/v1/jobs`. Rejected because lifecycle state rules, plugin lifecycle action references, and config-version checks belong in the platform service, not the browser. + +### Decision 4: Terminal job results project instance state + +When a lifecycle job completes, the existing run job result path updates the job and then projects the terminal result onto the server instance: successful install makes the instance `ready`, successful start makes it `running`, successful stop makes it `stopped`, and failed/cancelled lifecycle jobs make the instance `failed`. + +Alternative considered: require a separate status polling endpoint from run before changing server state. Rejected for this first workflow because the job result is already the authoritative terminal signal in the current in-memory platform. + +### Decision 5: Run executor is bounded and metadata-only + +The run-side lifecycle executor accepts a claimed job assignment, supports only the fixed lifecycle capabilities, and returns bounded success/failure metadata without executing arbitrary local commands or returning paths. + +Alternative considered: execute plugin action definitions immediately. Rejected because scoped file/process execution semantics and plugin proof behavior belong to later changes. + +### Decision 6: Frontend uses platform APIs with local fallback data + +The server management page loads plugins, run endpoints, server instances, and jobs through typed API client methods, but retains safe seed data when the backend is not available. Create/start/stop buttons call workflow APIs and update local state from the returned instance/job. + +Alternative considered: keep the page as a static overview until a later acceptance suite. Rejected because this change's completion gate requires browser walkthrough of create/start/stop workflows. + +## Risks / Trade-offs + +- [Risk] Workflow APIs create jobs but the run executor still simulates lifecycle completion. Mitigation: name this as bounded lifecycle execution and test the dispatch/result/state contract; real process orchestration stays deferred. +- [Risk] Idempotency keys are caller-provided, so poor clients can create repeated lifecycle jobs. Mitigation: validators require non-empty keys and the frontend generates per-action keys; service tests cover duplicate idempotency behavior through the existing job repository. +- [Risk] Direct resource creation can still create `draft` instances without workflow dispatch. Mitigation: keep direct route documented as metadata creation and make the console use workflow routes for operational create. +- [Risk] Instance state can remain unchanged while start/stop jobs are active because no `starting`/`stopping` states exist. Mitigation: the UI shows pending job state separately, and terminal job result projection updates the instance state. +- [Risk] In-memory job/state projection can be lost on process restart. Mitigation: this repository currently uses in-memory storage; persistence and reconciliation remain future changes. + +## Migration Plan + +1. Add lifecycle domain, DTO, validation, service, API route, and documentation changes in `platform/`. +2. Add `process.install` to plugin schema, SDK, platform validator, fixtures, and run smoke/runtime capability reporting. +3. Add run lifecycle executor support and tests in `run/runtime`. +4. Add frontend server management contracts, schemas, API methods, page interactions, tests, and styling in `platform_web/`. +5. Verify with platform/run/frontend tests, structure check, strict OpenSpec validation, and browser walkthrough. + +Rollback is contained to this change before dependent work: remove workflow routes/service methods, lifecycle executor, frontend interactions, and capability enum additions. After dev plugin proof or acceptance suite depends on these routes, rollback must be handled by a new OpenSpec change. + +## Open Questions + +- Whether a future persistence change should add explicit `starting` and `stopping` states or keep active lifecycle status derived from jobs. +- Whether restart/update/delete workflows should reuse the same response shape or introduce a richer lifecycle operation resource. +- Whether lifecycle action execution should be interpreted by run directly or mediated through a plugin action runtime in the next plugin proof change. diff --git a/openspec/changes/implement-server-management-workflows/proposal.md b/openspec/changes/implement-server-management-workflows/proposal.md new file mode 100644 index 0000000..98eaafb --- /dev/null +++ b/openspec/changes/implement-server-management-workflows/proposal.md @@ -0,0 +1,29 @@ +## Why + +Server management is the next first-party workflow after the console shell, plugin registry, run job channel, log ingest, and artifact transfer are available. Operators need a complete platform-mediated path to create a server instance from an installed game plugin, start it, stop it, and observe the resulting lifecycle state without exposing run internals to the browser or plugin pages. + +## What Changes + +- Add server lifecycle action APIs for create/install, start, and stop workflows. +- Dispatch lifecycle work through the existing platform job channel using bounded job metadata and idempotency keys. +- Enforce plugin, run endpoint, instance state, and optimistic config-version validation before lifecycle dispatch. +- Project lifecycle job state back onto server instances so the platform and frontend can show actionable states. +- Add frontend server management views and API client methods for create, start, stop, refresh, and workflow status. +- Add run-side lifecycle executor support for installing, starting, and stopping server jobs without exposing host paths, raw credentials, or direct sockets. + +## Capabilities + +### New Capabilities + +- `server-management-workflows`: Platform-mediated server instance create, start, stop, and status workflows across `platform/`, `run/`, and `platform_web/`. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform/` domain, DTO, validator, service, repository, API handlers, route docs, and protocol docs for server lifecycle operations. +- Affects `run/` protocol/client/executor code for lifecycle job handling. +- Affects `platform_web/` API types/client methods, route/page contracts, server management components, tests, and browser walkthrough. +- Reuses existing game plugin registry, run control, run job, log ingest, and artifact transfer contracts; does not add billing, cloud host sales, raw AI key exposure, host-path exposure, or direct plugin-to-run access. diff --git a/openspec/changes/implement-server-management-workflows/specs/server-management-workflows/spec.md b/openspec/changes/implement-server-management-workflows/specs/server-management-workflows/spec.md new file mode 100644 index 0000000..58cae19 --- /dev/null +++ b/openspec/changes/implement-server-management-workflows/specs/server-management-workflows/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: Server create workflow dispatches install job +The platform SHALL provide a server create workflow that validates an installed game plugin, a compatible run endpoint, and a non-empty idempotency key before creating a server instance and dispatching a queued install job through the job channel. + +#### Scenario: Create workflow accepted +- **WHEN** an operator submits a create workflow with an installed plugin, an online compatible run endpoint, a server name, and an idempotency key +- **THEN** the platform MUST create a server instance in `installing` state and create a queued `process.install` job bound to that instance and run endpoint + +#### Scenario: Create workflow rejects invalid dependencies +- **WHEN** an operator submits a create workflow with a missing plugin, disabled plugin, offline run endpoint, or run endpoint missing required capabilities +- **THEN** the platform MUST reject the workflow and MUST NOT dispatch a lifecycle job + +### Requirement: Server start workflow dispatches start job +The platform SHALL provide a start workflow for an existing server instance that validates the instance state, config version, plugin lifecycle action, run endpoint status, run endpoint capability, and idempotency key before dispatching a queued start job. + +#### Scenario: Start workflow accepted +- **WHEN** an operator starts a `ready` or `stopped` server instance with the current config version and an idempotency key +- **THEN** the platform MUST create a queued `process.start` job for the instance and return both the instance and job metadata + +#### Scenario: Start workflow rejects stale config +- **WHEN** an operator starts a server instance with an expected config version that does not match the instance config version +- **THEN** the platform MUST reject the workflow and MUST NOT dispatch a lifecycle job + +### Requirement: Server stop workflow dispatches stop job +The platform SHALL provide a stop workflow for an existing running server instance that validates the instance state, config version, plugin lifecycle action, run endpoint status, run endpoint capability, and idempotency key before dispatching a queued stop job. + +#### Scenario: Stop workflow accepted +- **WHEN** an operator stops a `running` server instance with the current config version and an idempotency key +- **THEN** the platform MUST create a queued `process.stop` job for the instance and return both the instance and job metadata + +#### Scenario: Stop workflow rejects non-running instance +- **WHEN** an operator stops a server instance that is not `running` +- **THEN** the platform MUST reject the workflow and MUST NOT dispatch a lifecycle job + +### Requirement: Lifecycle job results update server instance state +The platform SHALL project terminal lifecycle job results onto the associated server instance after accepting a run job result. + +#### Scenario: Install result marks ready +- **WHEN** run completes a `process.install` lifecycle job successfully +- **THEN** the platform MUST mark the associated server instance `ready` + +#### Scenario: Start result marks running +- **WHEN** run completes a `process.start` lifecycle job successfully +- **THEN** the platform MUST mark the associated server instance `running` + +#### Scenario: Stop result marks stopped +- **WHEN** run completes a `process.stop` lifecycle job successfully +- **THEN** the platform MUST mark the associated server instance `stopped` + +#### Scenario: Failed lifecycle result marks failed +- **WHEN** run completes an install, start, or stop lifecycle job as failed or cancelled +- **THEN** the platform MUST mark the associated server instance `failed` + +### Requirement: Run lifecycle executor is bounded +The run executor SHALL support only declared lifecycle capabilities for install, start, and stop jobs and MUST return bounded metadata-only results without raw host paths, raw credentials, or direct socket details. + +#### Scenario: Supported lifecycle job handled +- **WHEN** run receives a job assignment for `process.install`, `process.start`, or `process.stop` +- **THEN** the lifecycle executor MUST produce a successful bounded result suitable for the job result channel + +#### Scenario: Unsupported lifecycle job rejected +- **WHEN** run receives a job assignment for an unsupported lifecycle capability +- **THEN** the lifecycle executor MUST return a failed bounded result without executing local commands + +### Requirement: Server management UI supports create start and stop +The frontend SHALL expose server management controls that use platform workflow APIs to create, start, stop, and refresh server instances without receiving run credentials, raw host paths, raw AI keys, or direct sockets. + +#### Scenario: UI creates server workflow +- **WHEN** an operator submits the server management create form +- **THEN** the frontend MUST call the platform create workflow API and render the returned instance and lifecycle job status + +#### Scenario: UI starts and stops server +- **WHEN** an operator clicks start or stop for an eligible server instance +- **THEN** the frontend MUST call the matching platform workflow API with the current config version and render the returned lifecycle job status + +#### Scenario: UI refreshes workflow status +- **WHEN** the server management page refreshes data +- **THEN** the frontend MUST read server instances, jobs, plugins, and run endpoints through platform APIs and MUST NOT display raw secrets, host paths, run credentials, or direct sockets diff --git a/openspec/changes/implement-server-management-workflows/tasks.md b/openspec/changes/implement-server-management-workflows/tasks.md new file mode 100644 index 0000000..4729de1 --- /dev/null +++ b/openspec/changes/implement-server-management-workflows/tasks.md @@ -0,0 +1,43 @@ +## 1. Platform Lifecycle Workflows + +- [x] 1.1 Add lifecycle domain, DTO, validator, and conversion contracts for create/start/stop workflow requests and responses. +- [x] 1.2 Implement platform service methods for create/install, start, and stop workflow validation and job dispatch. +- [x] 1.3 Project accepted terminal lifecycle job results onto server instance state. +- [x] 1.4 Add platform HTTP routes and OpenAPI-style comments for create/start/stop lifecycle workflow actions. +- [x] 1.5 Update platform route/protocol/domain documentation for implemented server lifecycle workflows. +- [x] 1.6 Add platform service/API tests for accepted create/start/stop workflows, rejected invalid state/stale config, and lifecycle result state projection. + +## 2. Lifecycle Capabilities and Run Executor + +- [x] 2.1 Add `process.install` to plugin manifest schema, plugin SDK capability types, platform validation allowlists, examples, and fixtures where lifecycle install support is required. +- [x] 2.2 Add run lifecycle executor support for bounded install/start/stop job handling without host paths, raw credentials, or direct sockets. +- [x] 2.3 Add run tests for supported lifecycle jobs, unsupported lifecycle jobs, and smoke capability reporting. +- [x] 2.4 Update run protocol/runtime documentation for lifecycle executor scope. + +## 3. Frontend Server Management + +- [x] 3.1 Add frontend API contracts and client methods for run endpoints, jobs, and create/start/stop server lifecycle workflows. +- [x] 3.2 Add frontend server management view contracts and request builders outside page components. +- [x] 3.3 Implement the server management page create form, refresh action, start/stop actions, pending job display, and safe fallback data. +- [x] 3.4 Add frontend tests for API client calls and server management page rendering without unsafe fields. + +## 4. Verification + +- [x] 4.1 Run `go test ./...` from `platform/` and record evidence. +- [x] 4.2 Run `go test ./...` from `run/` and record evidence. +- [x] 4.3 Run `cd plugins && npm run typecheck && npm run test && npm run validate:manifest` and record evidence. +- [x] 4.4 Run `cd platform_web && npm run typecheck && npm run test && npm run build` and record evidence. +- [x] 4.5 Run browser walkthrough for server management create/start/stop UI at desktop and mobile widths. +- [x] 4.6 Run `scripts/check-structure.sh` and record evidence. +- [x] 4.7 Run `openspec validate implement-server-management-workflows --strict` and record evidence. + +## Evidence + +- 2026-07-03: `cd platform && go test ./...` passed after platform lifecycle service/API implementation. +- 2026-07-03: `cd run && go test ./...` passed after run lifecycle executor implementation. +- 2026-07-03: `cd plugins && npm run typecheck && npm run test && npm run validate:manifest` passed after adding `process.install`. +- 2026-07-03: Frontend API client/contracts, server management view contracts, request builders, page workflow UI, styling, docs, and tests were updated for run endpoints, jobs, create/start/stop workflows, pending job display, and safe fallback data. +- 2026-07-03: `cd platform_web && npm run typecheck && npm run test && npm run build` passed; Vitest reported 6 files / 14 tests passed and Vite built production assets. +- 2026-07-03: Browser walkthrough via Chrome DevTools at `http://127.0.0.1:4173/#/servers` passed for desktop create/install, start, stop, no unsafe fields visible, and mobile single-column server workspace. +- 2026-07-03: `scripts/check-structure.sh` passed. +- 2026-07-03: `openspec validate implement-server-management-workflows --strict` passed. diff --git a/openspec/changes/polish-platform-interaction-design/.openspec.yaml b/openspec/changes/polish-platform-interaction-design/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/polish-platform-interaction-design/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/polish-platform-interaction-design/design.md b/openspec/changes/polish-platform-interaction-design/design.md new file mode 100644 index 0000000..11d1638 --- /dev/null +++ b/openspec/changes/polish-platform-interaction-design/design.md @@ -0,0 +1,67 @@ +## Context + +The platform_web console already exposes the required first-party product areas and now has automated browser acceptance proving API-backed behavior across 首页、服务器管理、插件市场、用户管理、AI 提供商管理, server detail, and plugin controls. The next gap is not a new product capability; it is interaction quality. The console needs clearer hierarchy, denser-but-readable operational surfaces, stronger feedback, and responsive confidence while preserving its game operations visual contract. + +This change should stay inside platform_web interaction and visual polish. It must not introduce new business workflows, billing/cloud host flows, provider marketplaces, direct run access, or plugin transport shortcuts. It should use existing API-backed data and existing route contracts. + +## Goals / Non-Goals + +**Goals:** +- Turn UI dissatisfaction into objective acceptance criteria that implementation can complete and verify. +- Polish navigation, first-party route scanability, state feedback, command affordances, and server detail workflows. +- Keep the default black mecha console and optional magical-girl theme visually distinct while sharing the same layout and interaction model. +- Reuse `theme/tokens.ts`, `theme/base.css`, and shared surface classes before adding new styles. +- Require browser walkthrough evidence at desktop and mobile widths. +- Keep automated browser acceptance passing after polish. + +**Non-Goals:** +- Do not change platform, run, plugin, AI provider, authorization, lifecycle, log, artifact, or config semantics. +- Do not add billing, cloud host sales, agent-provider/cloud-provider workflows, or unrelated SaaS marketplace features. +- Do not replace the visual direction with generic opaque SaaS cards, one-off dark dashboards, or unrelated gradients. +- Do not add page-local fixed decorative spans, sparkles, sigils, snowflakes, hearts, moons, or other one-off background DOM. +- Do not create a fixed left-list/right-detail master-detail layout for server or plugin details. +- Do not bundle third-party character art or recognizable external visual assets. + +## Decisions + +1. Use accepted interaction criteria instead of taste-only language. + + Each polished page should have observable requirements: clear primary action, visible loaded/empty/error state, scannable hierarchy, no overlapping UI, readable status signals, responsive behavior, and preserved API-backed markers. This makes the implementation measurable in tests and browser walkthroughs. + +2. Polish shared primitives first. + + The implementation should start with existing shared classes such as `metric-card`, `overview-card`, `console-panel`, `catalog-card`, `server-card`, `server-detail-header`, `resource-table-wrap`, `provider-table-wrap`, `drawer-panel`, `confirm-panel`, `plugin-group`, and `operation-item`. If a new reusable pattern is needed, it belongs in `theme/base.css` with token-driven styling, not inside page-local opaque card systems. + +3. Preserve one route model across both themes. + + Black mecha and magical-girl should differ through tokens, frame treatments, materials, motifs, and `MagicalParticleLayer`, not through separate page implementations. The same route structure should remain usable and readable in both themes. + +4. Favor operational clarity over decoration. + + Lifecycle actions, destructive confirmations, logs, diffs, config review, operation results, warnings, and AI recommendations must remain text-readable, traceable, and not color-only. Decorative theme effects must stay behind operational surfaces and respect reduced motion. + +5. Use browser walkthroughs for acceptance, not screenshots alone. + + Completion requires exercising the actual routes and workflows in a browser at representative desktop and mobile widths. Screenshots can help debugging, but accepted evidence should focus on route behavior, visible controls, responsive layout, and absence of overlap or sensitive/fallback content. + +## Risks / Trade-offs + +- Visual polish can drift into scope expansion -> Keep tasks limited to platform_web interaction and theme presentation; do not change platform/run/plugin semantics. +- Theme work can create duplicate styles -> Require shared token/classes first and update `theme/README.md` only when new shared patterns are introduced. +- Responsive polishing can break automated acceptance markers -> Require automated browser acceptance after changes. +- Dense game-console visuals can harm readability -> Require operational clarity for logs, tables, diffs, warnings, and command results. +- Browser checks can be flaky -> Use stable routes, loaded states, viewport checks, and the existing local debug acceptance command. + +## Migration Plan + +1. Audit current first-party routes and server detail surfaces against the accepted interaction criteria. +2. Polish shared theme/layout primitives and route components in small focused batches. +3. Verify desktop and mobile browser walkthroughs across required routes and server detail workflows. +4. Run platform_web tests/build and automated browser acceptance. +5. Update task evidence and delivery stream pointers after verification passes. + +Rollback is straightforward because the change should stay in platform_web presentation and interaction code. Revert affected styles/components if a polish pass harms usability or breaks acceptance. + +## Open Questions + +- None currently. If implementation discovers a needed new product behavior, split it into a future OpenSpec instead of expanding this polish change. diff --git a/openspec/changes/polish-platform-interaction-design/proposal.md b/openspec/changes/polish-platform-interaction-design/proposal.md new file mode 100644 index 0000000..81e888a --- /dev/null +++ b/openspec/changes/polish-platform-interaction-design/proposal.md @@ -0,0 +1,28 @@ +## Why + +The console now has real API-backed coverage and automated browser acceptance, but the current interaction polish still depends on scattered page-level choices instead of explicit acceptance criteria. This change converts UI dissatisfaction into a concrete platform_web polish contract so the next implementation can improve usability without drifting into a generic SaaS dashboard or changing product scope. + +## What Changes + +- Define accepted interaction/design criteria for the required first-party areas: 首页、服务器管理、插件市场、用户管理、AI 提供商管理. +- Define server detail workflow polish for lifecycle controls, logs, config, plugin controls, AI assistant, and operation history. +- Require responsive desktop/mobile walkthrough coverage and no visible overlap, clipped text, unreadable panels, or inaccessible control states. +- Require the polish to preserve the existing black mecha default theme, magical-girl alternate theme, translucent game-operations surfaces, grouped navigation, and shared theme primitives. +- Require compatibility with the automated browser acceptance suite so visual/interaction polish does not weaken API-backed route proof or safety scanning. +- No breaking product behavior, API, authorization, plugin, run, billing, cloud-host, or provider-marketplace changes are expected. + +## Capabilities + +### New Capabilities + +- `platform-interaction-design-polish`: Defines accepted interaction and visual polish requirements for platform_web first-party console areas, server detail workflows, responsive behavior, theme preservation, browser walkthrough evidence, and automated acceptance compatibility. + +### Modified Capabilities + +- None. + +## Impact + +- Affected roots: `platform_web/`, platform_web documentation, and OpenSpec delivery stream files. +- Expected implementation areas: shared theme surfaces/classes, route/page layouts, command affordances, responsive behavior, state feedback, browser walkthrough evidence, and test coverage. +- Validation impact: requires `cd platform_web && npm run typecheck && npm test && npm run build`, automated browser acceptance against the local debug stack, `scripts/check-structure.sh`, and `openspec validate polish-platform-interaction-design --strict`. diff --git a/openspec/changes/polish-platform-interaction-design/specs/platform-interaction-design-polish/spec.md b/openspec/changes/polish-platform-interaction-design/specs/platform-interaction-design-polish/spec.md new file mode 100644 index 0000000..c7d30c9 --- /dev/null +++ b/openspec/changes/polish-platform-interaction-design/specs/platform-interaction-design-polish/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: First-party routes provide accepted interaction polish +The platform_web console SHALL provide polished, scannable, API-backed interaction surfaces for the required first-party areas without changing product scope. + +#### Scenario: Home route has clear operational hierarchy +- **WHEN** an operator opens 首页 +- **THEN** the route MUST present loaded platform overview state, key resource/health signals, game/plugin counts or equivalent operational summaries, and a clear refresh or recovery affordance without visible overlap or clipped primary labels + +#### Scenario: Server management route is action-oriented and scannable +- **WHEN** an operator opens 服务器管理 +- **THEN** the route MUST make server identity, lifecycle state, run assignment, filtering, creation entry point, and drill-in affordance easy to scan without using a fixed left-list/right-detail master-detail layout + +#### Scenario: Plugin marketplace route communicates trust and capability +- **WHEN** an operator opens 插件市场 +- **THEN** the route MUST present plugin identity, installed state, manifest reference, lifecycle capabilities, platform-mediated permissions, bridge actions, and validation state in a readable structure without unsafe runtime transport details + +#### Scenario: User management route supports account operations clearly +- **WHEN** an operator opens 用户管理 +- **THEN** the route MUST present API-connected account data, roles/statuses, create/edit affordances, and empty/error/loading states with text labels and non-color-only status cues + +#### Scenario: AI provider route keeps sensitive settings understandable +- **WHEN** an operator opens AI 提供商管理 +- **THEN** the route MUST present provider identity, connection status, relay mode, model/default-model information, and redacted key references without exposing raw keys or making status dependent on color alone + +### Requirement: Server detail workflows are polished without direct run access +The platform_web server detail route SHALL provide polished workflow surfaces for lifecycle, logs, config, plugin controls, AI assistant, and operation history while preserving platform-mediated boundaries. + +#### Scenario: Server detail header and tabs show stable context +- **WHEN** an operator opens `#/servers/server-local-debug` or another server detail route +- **THEN** the route MUST keep server name, server ID, plugin version, run node, lifecycle state, and tab navigation visible and readable across desktop and mobile widths + +#### Scenario: Lifecycle commands have safe feedback +- **WHEN** an operator views or triggers lifecycle controls +- **THEN** start/stop or equivalent controls MUST have clear labels, enabled/disabled states, confirmation or progress feedback where appropriate, and operation-history visibility without browser or plugin pages contacting run directly + +#### Scenario: Logs, config, artifacts, and operation history remain traceable +- **WHEN** an operator uses detail tabs for logs, config, plugin controls, AI assistant, or operation history +- **THEN** each surface MUST show meaningful loaded/empty/error states, logical IDs or safe references, readable timestamps/statuses, and no raw host paths, sockets, credentials, or plugin-owned transport details + +### Requirement: Visual system contract is preserved during polish +The platform_web polish SHALL preserve the existing game operations visual direction and shared theme architecture. + +#### Scenario: Shared theme primitives drive surfaces +- **WHEN** implementation changes route or component presentation +- **THEN** it MUST reuse or extend shared tokens/classes in `theme/tokens.ts` and `theme/base.css` rather than introducing page-local opaque card systems, one-off dark dashboards, or unrelated visual languages + +#### Scenario: Black mecha and magical-girl themes stay distinct +- **WHEN** an operator uses the default black mecha theme or optional magical-girl theme +- **THEN** both themes MUST keep their theme-specific materials, readable translucent surfaces, grouped large-entry navigation, and background visibility while sharing the same product workflows + +#### Scenario: Global decorative effects remain centralized +- **WHEN** implementation changes ambient or decorative effects +- **THEN** full-workspace theme-aware effects MUST remain in `components/MagicalParticleLayer.tsx` and MUST NOT use page-local fixed decorative DOM/CSS elements + +### Requirement: Responsive browser walkthrough proves polish acceptance +The polish change SHALL require browser verification across required routes and representative viewport sizes before completion. + +#### Scenario: Desktop and mobile walkthroughs cover required routes +- **WHEN** implementation claims the polished UI is accepted +- **THEN** browser walkthrough evidence MUST cover 首页、服务器管理、插件市场、用户管理、AI 提供商管理, server detail workflows, and representative desktop and mobile viewport widths + +#### Scenario: Walkthrough rejects layout regressions +- **WHEN** browser walkthroughs inspect accepted routes +- **THEN** they MUST reject visible overlap, clipped primary text, unreachable primary controls, unreadable loaded/error/empty states, and navigation states that hide required first-party areas + +#### Scenario: Automated acceptance remains compatible +- **WHEN** polish implementation is complete +- **THEN** `scripts/browser-acceptance.sh` with the documented local debug environment MUST still pass and MUST continue proving API-backed content, fallback rejection, forbidden-fragment scanning, and platform-mediated plugin/server operation proof + +### Requirement: Polish verification commands are concrete +The OpenSpec tasks SHALL list reproducible commands that prove implementation quality. + +#### Scenario: Verification commands are available +- **WHEN** contributors read implementation tasks +- **THEN** they MUST find concrete commands for platform_web typecheck/tests/build, automated browser acceptance, structure checks, and strict OpenSpec validation + +#### Scenario: Evidence is recorded before task completion +- **WHEN** implementation tasks are marked complete +- **THEN** task evidence MUST record the browser walkthrough coverage, automated acceptance command output or evidence path, platform_web verification, `scripts/check-structure.sh`, and `openspec validate polish-platform-interaction-design --strict` diff --git a/openspec/changes/polish-platform-interaction-design/tasks.md b/openspec/changes/polish-platform-interaction-design/tasks.md new file mode 100644 index 0000000..dca2b2c --- /dev/null +++ b/openspec/changes/polish-platform-interaction-design/tasks.md @@ -0,0 +1,57 @@ +## 1. Interaction Audit and Acceptance Criteria + +- [x] 1.1 Audit 首页、服务器管理、插件市场、用户管理、AI 提供商管理, and server detail against the accepted interaction polish requirements. +- [x] 1.2 Identify any visible overlap, clipped labels, weak hierarchy, unclear primary actions, missing empty/error/loading states, or color-only status cues. +- [x] 1.3 Confirm the implementation scope remains platform_web polish only and does not add billing, cloud host sales, provider marketplace workflows, direct run access, or plugin transport shortcuts. +- [x] 1.4 Map every planned UI change to shared route components, shared theme primitives, or documented shared CSS additions. + +## 2. Shared Theme and Layout Polish + +- [x] 2.1 Reuse or extend `theme/tokens.ts` and `theme/base.css` for any new shared materials, frames, command states, tables, drawers, dialogs, or responsive primitives. +- [x] 2.2 Preserve black mecha as the default theme and magical-girl as the alternate theme, with distinct token-driven materials and readable translucent surfaces. +- [x] 2.3 Keep grouped large-entry navigation, large icon badges, bold Chinese labels, expandable child rows, active frames, and single-column/double-column density behavior. +- [x] 2.4 Keep full-workspace decorative effects centralized in `components/MagicalParticleLayer.tsx`; do not add page-local fixed decorative DOM/CSS motifs. +- [x] 2.5 Keep framed repeated items at 8px-or-less radii unless a native pill/circle control shape is required. + +## 3. First-Party Route Polish + +- [x] 3.1 Polish 首页 hierarchy so loaded platform overview, resource/health signals, game/plugin counts, and refresh/recovery affordance are clear at desktop and mobile widths. +- [x] 3.2 Polish 服务器管理 so server identity, lifecycle state, run assignment, filters, creation entry point, and drill-in affordance are scannable without a fixed left-list/right-detail layout. +- [x] 3.3 Polish 插件市场 so plugin identity, installed state, manifest reference, lifecycle capabilities, permissions, bridge actions, and validation state are readable and safe. +- [x] 3.4 Polish 用户管理 so API-connected account rows, role/status cues, create/edit affordances, and loading/empty/error states are clear and not color-only. +- [x] 3.5 Polish AI 提供商管理 so provider status, relay mode, model/default-model details, and redacted key references are readable without exposing raw keys. + +## 4. Server Detail Workflow Polish + +- [x] 4.1 Polish server detail header and tabs so server name, ID, plugin version, run node, lifecycle state, and tab navigation remain visible across desktop and mobile widths. +- [x] 4.2 Polish lifecycle command states so start/stop controls have clear labels, disabled/progress behavior, confirmations or feedback where appropriate, and operation-history visibility. +- [x] 4.3 Polish logs, config, plugin controls, AI assistant, artifacts, and operation history surfaces with meaningful loaded/empty/error states, safe logical IDs, readable timestamps, and traceable outcomes. +- [x] 4.4 Confirm server detail and plugin controls remain platform-mediated and do not expose raw host paths, sockets, credentials, direct run URLs, or plugin-owned transports. + +## 5. Browser Walkthrough and Automated Acceptance + +- [x] 5.1 Run a browser walkthrough at a desktop viewport across 首页、服务器管理、插件市场、用户管理、AI 提供商管理, server detail, lifecycle controls, plugin controls, logs/config/AI/operation-history tabs, and record evidence. +- [x] 5.2 Run a browser walkthrough at a mobile viewport across the same required first-party areas and server detail workflow surfaces, and record evidence. +- [x] 5.3 Verify both black mecha and magical-girl themes preserve readable surfaces, distinct theme treatments, navigation clarity, and background visibility. +- [x] 5.4 Run `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh` and record the evidence path. + +## 6. Verification and Stream Update + +- [x] 6.1 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. +- [x] 6.2 Run `scripts/check-structure.sh` and record evidence. +- [x] 6.3 Run `openspec validate polish-platform-interaction-design --strict` and record evidence. +- [x] 6.4 If structural theme rules or shared style contracts change, update `platform_web/theme/README.md` and any relevant tests in the same change. +- [x] 6.5 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` and `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` after implementation evidence exists. + +## Evidence + +- `platform_web/pages/UsersPage.tsx`: added explicit loading, API fallback error, empty-state, and accessible row action labels for 用户管理. +- `platform_web/pages/AiProvidersPage.tsx`: added an explicit empty-state for filtered AI provider lists while preserving redacted `apiKeyRef` display. +- `platform_web/pages/ServersPage.tsx`: polished create workflow feedback with an inline result strip. +- `platform_web/pages/PluginsPage.tsx`: polished plugin detail framing and action strip behavior. +- `platform_web/theme/base.css`: hardened shared action strips, server toolbars, catalog cards, plugin detail panels, result strips, responsive grids, and table/workspace min-width behavior. +- `platform_web/acceptance/browser-acceptance.mjs`: expanded browser acceptance to record desktop/mobile walkthroughs for black mecha and magical-girl themes, route marker checks, visible-layout checks, API-backed route proof, plugin controls, and operation-history proof. +- Browser evidence: `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh` passed; evidence file `/private/tmp/browser-local-debug-acceptance/browser-acceptance/browser-acceptance-evidence.json` records 7 required routes plus 4 walkthrough scenarios: desktop/mobile black mecha and desktop/mobile magical-girl. +- Frontend evidence: `cd platform_web && npm run typecheck`, `cd platform_web && npm test` (11 files / 49 tests), and `cd platform_web && npm run build` passed. +- Structure evidence: `scripts/check-structure.sh` passed. +- OpenSpec evidence: `openspec validate polish-platform-interaction-design --strict` passed. diff --git a/openspec/changes/polish-theme-frame-accessories/design.md b/openspec/changes/polish-theme-frame-accessories/design.md new file mode 100644 index 0000000..aa0208d --- /dev/null +++ b/openspec/changes/polish-theme-frame-accessories/design.md @@ -0,0 +1,35 @@ +# Design + +## Overview + +Theme accessories are implemented as generated SVG data URLs stored in palette variables. Shared CSS primitives consume the variables so cards, panels, tables, drawers, dialogs, plugin groups, operation history, and active sidebar entries gain theme-specific corner ornaments without page-level decoration. + +## Theme Assets + +The new variables are: + +- `--frame-accessory-top` +- `--frame-accessory-bottom` +- `--frame-accessory-opacity` +- `--frame-accessory-size` + +`mecha-black` uses compact SVG motifs for spacecraft, robot/vehicle silhouettes, radar rings, and planet-like targeting circles. + +`magical-girl` uses compact SVG motifs for winged hearts, stars, wands, ribbons, and magic-circle rings. + +## CSS Application + +Shared `::after` layers compose: + +- Top accessory. +- Bottom accessory. +- Existing shine/jelly material. + +Table wrappers, plugin groups, and operation items get matching `::after` layers so dense operational surfaces still inherit the theme identity. The layers remain pointer-events disabled and non-interactive. + +## Constraints + +- Keep all ornaments in shared theme CSS/tokens. +- Preserve 8px-or-less frame radii for repeated surfaces. +- Avoid external iconfont dependency unless generated assets become insufficient. +- Keep operational text readable by using transparent SVG backgrounds and controlled opacity. diff --git a/openspec/changes/polish-theme-frame-accessories/proposal.md b/openspec/changes/polish-theme-frame-accessories/proposal.md new file mode 100644 index 0000000..c273b4b --- /dev/null +++ b/openspec/changes/polish-theme-frame-accessories/proposal.md @@ -0,0 +1,34 @@ +# Polish theme frame accessories + +## Summary + +Add theme-specific ornamental accessories to shared platform web frames so each first-party theme has distinct border personality beyond color swaps. + +## Motivation + +The current black mecha and magical-girl themes already differ in palette, material, and global effects, but repeated cards and panels still share a similar decorative language. The requested direction calls for each theme border to carry its own accessories: + +- Mecha frames should read as robot, ship, planet, radar, and cockpit hardware. +- Magical-girl frames should read as hearts, stars, wands, ribbons, and magic-circle motifs. + +These ornaments should remain in the shared theme system rather than page-local decorations. + +## Scope + +- Add generated SVG accessory assets as CSS theme variables in `platform_web/theme/tokens.ts`. +- Apply accessory variables to shared framed surfaces in `platform_web/theme/base.css`. +- Include active sidebar item ornamentation so navigation states also inherit the theme identity. +- Update token tests to lock the accessory variables. + +## Out of Scope + +- New third-party icon dependencies or bundled recognizable character art. +- Page-local fixed decorative spans. +- New theme families, route changes, or backend behavior. + +## Verification + +- Run platform web tests and build. +- Run `scripts/check-structure.sh`. +- Run `openspec validate polish-theme-frame-accessories --strict`. +- Perform a browser walkthrough of the platform web shell in both themes. diff --git a/openspec/changes/polish-theme-frame-accessories/specs/platform-web-theme-accessories/spec.md b/openspec/changes/polish-theme-frame-accessories/specs/platform-web-theme-accessories/spec.md new file mode 100644 index 0000000..c441ccf --- /dev/null +++ b/openspec/changes/polish-theme-frame-accessories/specs/platform-web-theme-accessories/spec.md @@ -0,0 +1,72 @@ +# platform-web-theme-accessories Specification + +## ADDED Requirements + +### Requirement: Theme frame accessories + +Shared platform web framed surfaces SHALL render theme-specific ornamental accessories. + +#### Scenario: Black mecha theme is active + +- **WHEN** the active palette is `mecha-black` +- **THEN** shared framed surfaces include mecha accessory motifs such as spacecraft, robot hardware, radar, planet, or cockpit details +- **AND** the motifs are provided through shared theme variables rather than page-local decorative DOM + +#### Scenario: Magical-girl theme is active + +- **WHEN** the active palette is `magical-girl` +- **THEN** shared framed surfaces include magical accessory motifs such as hearts, stars, magic wands, ribbons, or magic circles +- **AND** the motifs are provided through shared theme variables rather than page-local decorative DOM +- **AND** repeated sibling items rotate through distinct motifs instead of repeating one identical accessory +- **AND** each framed item uses one compact edge badge rather than multiple oversized illustrations + +### Requirement: Navigation active frame accessories + +Active sidebar navigation entries SHALL inherit the active theme's frame accessory language. + +#### Scenario: User changes theme while a route is active + +- **WHEN** a sidebar route is active +- **AND** the user switches between first-party themes +- **THEN** the active sidebar frame changes accessory motifs to match the selected theme +- **AND** route order and labels remain unchanged + +### Requirement: Live theme isolation + +Theme switching SHALL replace the complete active palette without retaining visual variables or shell labels from the previous palette. + +#### Scenario: User switches from magical-girl to black mecha + +- **WHEN** the active palette is `magical-girl` +- **AND** the user selects `mecha-black` +- **THEN** the root theme marker, shared surface materials, sidebar subtitle, and palette strip update to black mecha immediately +- **AND** magical-girl accessory variables from the previous palette do not remain active on mecha surfaces + +### Requirement: Single frame ownership + +An operational content region SHALL render at most one ornamental frame at each visual hierarchy level. + +#### Scenario: State view is nested inside a shared framed surface + +- **WHEN** an empty, loading, or error state is rendered inside a `console-panel`, card, table wrapper, plugin group, or operation item +- **THEN** the parent surface retains the active theme frame and accessory +- **AND** the nested state view renders as unframed content without a second border, panel fill, or accessory pseudo-element + +### Requirement: Uploaded background frame visibility + +Uploaded backgrounds SHALL remain visually legible while preserving the selected theme's frame identity. + +#### Scenario: Magical-girl theme uses an uploaded background + +- **WHEN** the active palette is `magical-girl` +- **AND** the user has configured an uploaded background +- **THEN** shared foreground surfaces use translucent neutral-pink crystal glass rather than an opaque maroon color mask +- **AND** the uploaded image remains recognizable behind the foreground surfaces +- **AND** compact heart, star, wand, moon, ribbon, crystal, and magic-circle accessories visibly cross the panel border instead of being hidden inside the panel +- **AND** accessory pseudo-elements do not add a color wash over the configured background + +#### Scenario: Magical-girl theme uses a built-in background + +- **WHEN** the active palette is `magical-girl` +- **AND** no uploaded background is configured +- **THEN** shared framed surfaces retain compact, immediately visible magical accessories that extend beyond their borders without crowding content diff --git a/openspec/changes/polish-theme-frame-accessories/tasks.md b/openspec/changes/polish-theme-frame-accessories/tasks.md new file mode 100644 index 0000000..c6617fa --- /dev/null +++ b/openspec/changes/polish-theme-frame-accessories/tasks.md @@ -0,0 +1,37 @@ +# Tasks + +- [x] Add generated mecha and magical-girl SVG accessory variables to theme tokens. +- [x] Apply accessory variables to shared framed surfaces and active sidebar states. +- [x] Extend theme token tests for accessory variables. +- [x] Run tests, structure check, OpenSpec validation, and browser walkthrough. +- [x] Recalibrate uploaded-background glass so configured artwork remains recognizable. +- [x] Enlarge and strengthen magical-girl frame accessories across shared surfaces. +- [x] Repeat browser walkthrough and all repository validation after the correction. +- [x] Reduce magical-girl accessories to compact single-motif edge badges. +- [x] Rotate distinct heart, wand, moon, ribbon, crystal, and circle assets across repeated items. +- [x] Verify uploaded backgrounds remain unobscured after the decoration-density correction. +- [x] Clear stale palette variables and synchronize shell chrome after live theme switches. +- [x] Suppress nested state-view frames when a shared parent already owns the decoration. +- [x] Re-run browser theme switching and repository validation for the isolation fix. + +## Evidence + +- `npm test`: 11 files passed, 49 tests passed. +- `npm run typecheck`: passed. +- `npm run build`: passed. +- `scripts/check-structure.sh`: passed. +- `openspec validate polish-theme-frame-accessories --strict`: passed. +- Browser walkthrough: entered local fallback workbench, verified mecha active navigation uses generated spacecraft/radar accessories, then switched through Profile Settings to magical-girl / 粉月魔法阵 and verified shared panels render heart/star/wand/magic-circle accessories. +- Follow-up browser walkthrough after visual feedback: reduced magical-girl accessory size to `70px 42px, 78px 54px`, lowered opacity to `0.44`, moved frame accessories under content (`::after z-index: 0`, content z-index above), and confirmed the custom-background overlay token is `transparent`. +- Second follow-up after screenshot feedback: converted the SVG accessories to small line-art edge accents, locked token accessory sizes to max `48px`, reduced custom-background surface blur to `4px`, removed theme color tint from uploaded-background panel material, lowered custom-background ambient particles to `0.08`, and verified a headless Chrome screenshot at `.tmp/theme-visual-check/profile-custom-background.png` with `data-custom-background="true"`, `--custom-background-overlay: transparent`, and profile panels using `blur(4px) saturate(1.02)`. +- Final color audit after palette feedback: rebalanced uploaded-background mode so the page uses `--custom-background-overlay: transparent`, keeps controls on readable dark glass, and removes large-panel blur in favor of low-opacity ink glass; verified fresh headless screenshots at `.tmp/theme-color-audit/mecha-uploaded.png` and `.tmp/theme-color-audit/magical-uploaded.png` where uploaded artwork remains visible behind the profile/settings panels. +- Custom-background readability pass after latest screenshots: removed the visible theme-color mask from uploaded backgrounds while dimming the wallpaper layer itself, increased content-panel opacity/blur only on foreground surfaces, added dark glass treatment for server toolbars and state/error panels, and added a compact title backplate so bright uploads do not wash out page headers; verified screenshots at `.tmp/custom-background-audit/servers.png` and `.tmp/custom-background-audit/profile-settings.png` with `data-custom-background="true"`, `filter: saturate(0.72) contrast(0.88) brightness(0.82)`, and panel backdrop `blur(16px) saturate(0.78)`. +- Magical-girl correction after latest feedback: restored visible pink moonlight panel material and frame accessories under custom uploaded backgrounds, prevented the generic custom-background opacity rule from suppressing magical-girl decorations, and moved magical-girl frame accessories outside the panel border with larger edge-overhanging assets; verified screenshots at `.tmp/magical-custom-background-check.png` and `.tmp/magical-preset-border-check.png`. +- Final uploaded-background correction: replaced opaque maroon surfaces with low-opacity neutral-pink crystal glass, restored the uploaded artwork to `saturate(0.92) contrast(0.96) brightness(0.94)`, removed backdrop blur from large magical-girl custom-background panels, added real `::after` accessory layers for server toolbars and state views, and strengthened generated heart/star/wand/magic-circle SVGs so they cross the border at `132-154px` with `0.80-0.84` opacity. Browser walkthrough at `1440x1000` confirmed the configured anime artwork remains recognizable, accessories are immediately visible outside the frame, and the browser console has no errors; evidence saved at `.tmp/magical-custom-background-final.png`. +- Final verification: `npm --prefix platform_web test` passed 11 files and 49 tests; `npm --prefix platform_web run typecheck`, `npm --prefix platform_web run build`, and `scripts/check-structure.sh` passed; `openspec validate polish-theme-frame-accessories --strict` reported the change valid with exit code `0` (telemetry flush warnings only). +- Decoration-density correction after item-level feedback: replaced the two oversized magical illustrations on each surface with one `68px × 44px` edge-overhanging badge, generated six distinct inline SVG motifs (heart wings, star wand, crescent moon, ribbon, crystal, and magic circle), and rotate them through repeated cards with shared CSS variables and `:nth-child()` rules. In-app browser verification on `#/profile` with `data-theme-palette="magical-girl"` and `data-custom-background="true"` confirmed the three metric items resolve to `heart`, `wand`, and `moon`, lower settings panels resolve to different motifs, the configured background remains fully recognizable, and browser console errors are empty. +- Decoration-density verification: `npm --prefix platform_web test` passed 11 files and 49 tests; `npm --prefix platform_web run typecheck`, `npm --prefix platform_web run build`, `scripts/check-structure.sh`, and `openspec validate polish-theme-frame-accessories --strict` passed (OpenSpec telemetry flush warnings only). +- Theme-isolation correction: `applyThemePalette` now removes the union of prior palette variables before applying the selected palette and publishes a live palette-change event so AppShell updates its subtitle and swatch strip without a remount. +- Single-frame correction: nested `.state-view` elements inside shared framed surfaces now resolve to transparent, borderless, shadowless content with both pseudo-elements disabled, while the parent surface keeps the active theme accessory. +- Browser walkthrough with an uploaded custom background: switched `magical-girl` to `mecha-black` and confirmed `data-theme-palette="mecha-black"`, sidebar subtitle `黑色机甲 / OPS`, cyan `--accent: #48e6ff`, and no remaining inline `--frame-accessory-heart`; switched back to magical-girl and confirmed only parent panel accessories render; restored mecha as the final state. Two nested home-page state views resolved to `border: 0px`, transparent backgrounds, and `::after content: none`; browser warnings/errors were empty. +- Isolation verification: `npm --prefix platform_web test` passed 12 files and 51 tests; `npm --prefix platform_web run typecheck`, `npm --prefix platform_web run build`, and `scripts/check-structure.sh` passed; strict OpenSpec validation reported the change valid (telemetry flush warnings only). diff --git a/openspec/changes/redesign-platform-web-interactions/.openspec.yaml b/openspec/changes/redesign-platform-web-interactions/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/redesign-platform-web-interactions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/redesign-platform-web-interactions/design.md b/openspec/changes/redesign-platform-web-interactions/design.md new file mode 100644 index 0000000..ec60527 --- /dev/null +++ b/openspec/changes/redesign-platform-web-interactions/design.md @@ -0,0 +1,114 @@ +## Context + +`platform_web/` is the management frontend for a game server management platform. The required first-party areas are home, server management, plugin marketplace, user management, and AI provider management. The current interaction model does not sufficiently distinguish platform administrators from server owners/administrators, and daily operations lack consistent feedback, traceability, and safe confirmation paths. + +The target experience is a visually expressive anime/game operations workspace: themeable backgrounds, colorful status blocks, and a playful visual tone inspired by magical-girl and virtual-idol interfaces. The shared visual system must read as crystal moonlight rather than opaque pastel cards: high transparency, icy rim light, diamond-like borders, glossy jelly controls, built-in magical desktop presets, a unified theme-aware magical ultimate-effect layer, and visible user/custom backgrounds through safe readability overlays. This must use original assets or user-provided backgrounds rather than copyrighted character art. It must not weaken operational clarity. Server status, logs, configuration diffs, plugin actions, LLM output, and errors must remain readable and debuggable. + +## Goals / Non-Goals + +**Goals:** + +- Route users to role-appropriate default workspaces after login. +- Support the current identity flows: registration, login, platform-admin user creation, user management, and current-user profile editing. +- Give platform administrators a first-screen platform health overview. +- Give server owners and server administrators a first-screen server list with actionable server status. +- Consolidate day-to-day server work inside the server detail workspace: overview, logs, configuration, plugin controls, LLM configuration assistance, and operation history. +- Provide consistent empty, loading, error, success, failure, and diagnostic states across platform_web. +- Make every user-triggered operation visible and traceable through an operation/job ID. +- Preserve AI provider and run-channel safety constraints. +- Establish a themeable anime/game visual system that supports user-uploaded backgrounds and per-user visual preferences while keeping text and controls readable. + +**Non-Goals:** + +- No billing, cloud host sales, cloud provider workflows, SaaS marketplace features, or unrelated commercial flows. +- No raw AI keys, raw credentials, host paths, or direct sockets exposed to platform_web or plugin pages. +- No implementation of new game plugin business logic beyond rendering and invoking declared per-server plugin controls. +- No silent LLM writes to server configuration. +- No direct signup path to platform administrator privileges without platform-admin approval or bootstrap policy. + +## Decisions + +### Role-specific workspace routing + +Users will enter a workspace based on their highest relevant role for the current session. Platform administrators land on the platform overview. Server owners and server administrators land on the server list and do not see platform overview navigation. + +Alternative considered: a single universal dashboard for all roles. This was rejected because it leaks irrelevant platform concepts to server-only users and makes the first screen less useful for the daily workflows. + +### Navigation is capability-scoped + +The side navigation and mobile bottom navigation will be generated from the user's role/capability set. Platform administrators can access platform overview, server management, plugin marketplace, user management, AI provider management, and system maintenance. Server owners and server administrators can access their server list, server detail workspaces, and allowed plugin/config/log operations only. + +Alternative considered: render all navigation and disable unauthorized items. This was rejected because hidden platform areas are cleaner and reduce confusion for server administrators. + +### Identity and profile are first-class console flows + +The frontend will expose registration and login as unauthenticated flows, user creation and user management as platform-administrator flows, and profile/contact/theme editing from the current user's avatar menu. Profile fields include display name, avatar, phone, QQ, and other bounded contact fields. Theme preferences include uploaded desktop/background imagery and visual theme settings, stored per user when authenticated and allowed to fall back to local storage before the profile API exists. + +Alternative considered: keeping theme upload only as a shell control and contact fields only in user management. This was rejected because users expect personal settings to live behind their own avatar, while platform administrators still need centralized user management. + +### Server detail is the daily operations hub + +Each server detail page will contain tabs or sections for overview, logs, configuration, plugin controls, LLM configuration assistance, and operation history. Plugin controls are grouped by plugin within the server detail page. The plugin marketplace remains responsible for plugin discovery, install/update status, and plugin documentation. + +Alternative considered: a global plugin control console where users select plugins first and servers second. This was rejected because daily operators think in terms of "this server has a problem" or "this server needs an action" rather than starting from a plugin catalog. + +### Per-server plugin isolation + +The UI model will treat a plugin installation/control surface as scoped to a server instance. Multiple servers can use the same plugin, but each server displays independent plugin state, configuration, actions, and operation history. + +Alternative considered: shared plugin state shown globally in the marketplace. This was rejected because it obscures which server will be affected by a control action and can lead to unsafe cross-server assumptions. + +### Operation/job feedback as a shared interaction pattern + +Every user-triggered action that reaches platform/run/plugin/LLM systems will create or reference a visible operation/job. Buttons transition through pending/loading/success/failure states, and failures include error reason, operation/job ID, and retry or diagnostic actions where available. + +For complex actions, the frontend should present one business operation to the user, even if the backend performs multiple steps. The operation detail can expose the operation ID, target, requester, status, timestamps, and diagnostics. Frontend code must avoid wiring one button directly to several unrelated API calls whose combined outcome cannot be traced. + +Alternative considered: local toast-only feedback. This was rejected because transient toasts do not support debugging, audit, or long-running run-side operations. + +### LLM-assisted configuration is review-first + +LLM configuration assistance will produce recommendations or a reviewable diff. The user must confirm the diff before platform dispatches any run-side write job. Plugin pages and platform_web must not receive raw AI provider keys. + +Alternative considered: allowing LLM suggestions to write directly after prompt submission. This was rejected because server configuration changes require operator review and auditability. + +### Visual theme with readability constraints + +The frontend will support an anime/game visual direction with user-uploaded background imagery, built-in magical desktop presets, saturated color blocks, crystalline highlights, and expressive accents suitable for a cute game operations console. The default theme system will include multiple magical-girl palettes built from strawberry pink, lavender purple, mint green, milk yellow, icy blue, white highlights, and bright gold accents. The shared style language is crystal moonlight: panels remain transparent enough to reveal the background, cards use diamond-like borders and white/icy-blue rim lights, buttons look like glossy jelly candy, and decorative motifs use original ribbon and magic-circle motifs plus a shell-level canvas for theme-specific magical ultimate effects. Built-in backgrounds should feel like original magical desktops such as moon sigils, candy starlight, ribbon sweeps, mint crystal facets, and aqua aurora, while uploaded user imagery takes precedence when present. Operational surfaces such as logs, configuration diffs, errors, and forms must use readable contrast layers and stable layout constraints. Status must be represented with text/icons as well as color. + +Alternative considered: making the whole UI a decorative landing-page style. This was rejected because the product is a repeated-use operations tool, not a marketing site. + +### Responsive behavior favors task focus + +Desktop uses role-scoped side navigation and multi-column dashboards/lists. Narrow screens use compact top context, single-column cards, bottom navigation where appropriate, drawers for filters/details, and collapsible plugin groups. + +Alternative considered: shrinking the desktop layout uniformly. This was rejected because dense operational panels become unreadable and hard to use on narrow screens. + +## Risks / Trade-offs + +- [Risk] Rich backgrounds reduce readability. -> Mitigation: use contrast overlays, fixed panel surfaces, and visual QA on desktop and mobile. +- [Risk] Anime-style visuals drift into copyrighted character references. -> Mitigation: use original UI motifs, abstract color, user-uploaded backgrounds, and avoid bundling recognizable third-party character assets. +- [Risk] Role-based navigation hides a needed action from hybrid users. -> Mitigation: define deterministic role precedence and allow explicit workspace switching only for users with multiple allowed workspaces. +- [Risk] Registration can create unauthorized access if role assignment is too broad. -> Mitigation: new self-registered users default to a pending or server-scoped role until a platform administrator approves or assigns capabilities. +- [Risk] Operation/job tracking requires API support that may not exist for all actions. -> Mitigation: inventory existing APIs during implementation and add scoped platform contracts where necessary. +- [Risk] Plugin control schemas may vary widely. -> Mitigation: render plugin controls from explicit plugin/page contracts and keep unsupported controls in a clear unavailable state. +- [Risk] LLM diff review can slow expert operators. -> Mitigation: keep diff confirmation efficient, but do not bypass review for write jobs. +- [Risk] Mobile cannot expose all desktop controls at once. -> Mitigation: prioritize server status, search, logs, and common actions; move advanced filters and diagnostics into drawers/details. + +## Migration Plan + +1. Add or adapt platform_web route contracts for role-aware entry and navigation. +2. Add identity/profile contracts for registration, login, current-user loading, user creation, user management, profile contact fields, avatar settings, and per-user theme preferences. +3. Implement shared layout/theme primitives and state components before page rewrites. +4. Replace the platform administrator landing page with the platform overview. +5. Replace the server-owner/admin landing page with the server list. +6. Rework server detail into the daily operations hub with logs, config, plugin controls, LLM assistance, and operation history. +7. Integrate operation/job feedback patterns into all actionable controls touched by the redesign. +8. Verify responsive behavior and browser walkthroughs before marking UI acceptance complete. + +Rollback is page-level: retain route boundaries so individual redesigned pages can be disabled or reverted if a critical interaction blocks operators. + +## Open Questions + +- Should hybrid users who are both platform administrators and server administrators get a visible workspace switcher, or should platform overview always be the only default entry with server access through navigation? +- Which existing backend operation/job APIs can be reused, and which operations need new platform contracts? diff --git a/openspec/changes/redesign-platform-web-interactions/proposal.md b/openspec/changes/redesign-platform-web-interactions/proposal.md new file mode 100644 index 0000000..2852476 --- /dev/null +++ b/openspec/changes/redesign-platform-web-interactions/proposal.md @@ -0,0 +1,34 @@ +## Why + +The current platform web experience is too sparse and unreliable for daily game server operations: controls are unclear, actions do not always provide visible feedback, and role-specific users cannot immediately reach their most common work. The platform needs a role-aware, visually expressive game operations interface that remains debuggable and safe for server, plugin, and LLM-assisted configuration workflows. + +## What Changes + +- Introduce role-aware landing behavior: platform administrators land on a platform overview, while server owners and server administrators land on their server list and cannot access the platform overview. +- Redesign the platform overview around first-screen operational health: online/offline server counts, game type distribution, CPU/memory/disk load, LLM connectivity, and recent log or fault signals. +- Redesign the server list and server detail flows for server owners and administrators, emphasizing server status, player count, TPS/latency, and resource usage. +- Move day-to-day plugin controls into each server detail page, grouped by plugin, with per-server plugin state and configuration isolation. +- Add a reliable operation feedback model for UI actions: clear loading states, success/failure results, operation/job IDs, retry and diagnostics affordances, and no silent multi-API button behavior. +- Add guarded LLM-assisted configuration UX: LLM output must produce a reviewable diff or recommendation before any server-side write job is dispatched. +- Add a themeable anime/game visual direction for platform_web, leaning toward magical-girl and virtual-idol energy through original crystal-moonlight glass, transparent jelly surfaces, built-in magical desktop presets, gradients, rim-light highlights, diamond/ribbon/magic-circle motifs, cute magical icons, a unified global magical ultimate-effect layer, and user-uploaded backgrounds while preserving readable operations panels. +- Prioritize the current user flows: create users, registration, login, user management, avatar-menu profile editing, contact fields such as phone and QQ, and per-user theme/background settings. + +## Capabilities + +### New Capabilities +- `role-aware-platform-workspace`: Role-based platform_web navigation, default landing pages, and first-screen dashboard/server-list requirements. +- `server-plugin-control-workspace`: Server detail workspace requirements for logs, configuration, plugin controls, and per-server plugin isolation. +- `operation-feedback-and-safety`: Shared interaction requirements for loading, empty, error, task result, diagnostic, and LLM diff confirmation states. +- `user-identity-and-profile`: Authentication, user administration, profile/contact editing, avatar entry point, and user theme preferences. + +### Modified Capabilities + +None. + +## Impact + +- Affects `platform_web/` routing, navigation, page composition, visual design system, server list, server detail, plugin control, log, configuration, and operation feedback UI. +- May require API/client contract adjustments in `platform_web/` for role capabilities, overview metrics, per-server plugin control surfaces, operation/job status, diagnostics IDs, and LLM-generated configuration diffs. +- May require backend `platform/` support only where existing APIs do not provide the necessary role-scoped data, operation/job tracking, or reviewable LLM diff responses. +- Must preserve AI provider key ownership in `platform/`; plugin pages and platform_web must never receive raw AI keys. +- Must preserve run-platform channel separation and avoid exposing host paths, raw credentials, or direct sockets to platform_web or plugins. diff --git a/openspec/changes/redesign-platform-web-interactions/specs/operation-feedback-and-safety/spec.md b/openspec/changes/redesign-platform-web-interactions/specs/operation-feedback-and-safety/spec.md new file mode 100644 index 0000000..3125795 --- /dev/null +++ b/openspec/changes/redesign-platform-web-interactions/specs/operation-feedback-and-safety/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: User actions have visible lifecycle feedback +The platform web application SHALL show visible lifecycle feedback for user-triggered operations. + +#### Scenario: Button enters pending state +- **WHEN** a user submits an operation from a button or form +- **THEN** the initiating control shows a pending or loading state and prevents accidental duplicate submission until the operation state is known + +#### Scenario: Operation success is visible +- **WHEN** a submitted operation completes successfully +- **THEN** the UI shows a success result and provides relevant next actions such as viewing logs or operation history when available + +#### Scenario: Operation failure is visible +- **WHEN** a submitted operation fails +- **THEN** the UI shows a failure result with an error reason and relevant retry or diagnostic actions when available + +### Requirement: Operations are traceable +The platform web application SHALL expose a traceable operation or job identity for operations that affect platform, run, plugin, server, or LLM systems. + +#### Scenario: Operation detail includes trace data +- **WHEN** an operation is created or retrieved +- **THEN** the UI can display its operation/job ID, target, requester, status, timestamps, and error reason when available + +#### Scenario: Failure includes diagnostic identifier +- **WHEN** an operation or data load fails with a diagnostic identifier +- **THEN** the UI displays the identifier or provides a copyable diagnostic summary for debugging + +### Requirement: One user intent maps to one visible business operation +The platform web application SHALL present each user-triggered action as one visible business operation even if the backend performs multiple internal steps. + +#### Scenario: Complex action is tracked as one operation +- **WHEN** a user triggers a complex action such as restart server, send gift, apply plugin control, or write configuration +- **THEN** the UI displays one operation lifecycle for the user intent and tracks progress or result through one operation/job context + +#### Scenario: Multi-step backend failure is debuggable +- **WHEN** an internal step of a complex action fails +- **THEN** the operation result identifies the failing stage or error reason when that information is available + +### Requirement: Empty states are actionable +The platform web application SHALL show actionable empty states instead of blank pages for expected no-data conditions. + +#### Scenario: Server list is empty for server administrator +- **WHEN** a server administrator has no manageable servers +- **THEN** the server list shows an empty state explaining that no manageable servers are available and provides a refresh action + +#### Scenario: Platform overview has no servers +- **WHEN** a platform administrator opens the platform overview and no server instances exist +- **THEN** the overview shows an empty state with a management-oriented next action rather than a blank page + +### Requirement: Loading states are scoped +The platform web application SHALL use scoped loading states so one slow module does not blank unrelated content. + +#### Scenario: Dashboard module loads independently +- **WHEN** one platform overview module is loading slowly +- **THEN** the UI shows a loading state for that module while keeping already loaded modules visible + +#### Scenario: Server card metrics load independently +- **WHEN** server metrics are still loading +- **THEN** the server card remains visible with stable placeholders for pending metrics + +### Requirement: Errors identify affected scope +The platform web application SHALL display errors with enough scope and recovery information for operators to act. + +#### Scenario: Module load error is localized +- **WHEN** a dashboard module or server detail section fails to load +- **THEN** the error is shown within the affected module or section with retry and diagnostic information when available + +#### Scenario: Full-page error preserves navigation +- **WHEN** a full-page error prevents rendering the requested workspace +- **THEN** the application preserves usable global navigation or a safe route back to an authorized workspace + +### Requirement: Dangerous actions require confirmation +The platform web application SHALL require explicit confirmation for destructive or disruptive operations. + +#### Scenario: Restart or stop requires confirmation +- **WHEN** a user initiates a disruptive server action such as stop or restart +- **THEN** the UI asks for confirmation before submitting the operation + +#### Scenario: Configuration write requires diff confirmation +- **WHEN** a user initiates a configuration write from manual edits or LLM output +- **THEN** the UI requires the user to review and confirm the diff before submission + +### Requirement: AI and run safety boundaries are preserved +The platform web application SHALL preserve platform AI provider and run communication safety boundaries in all redesigned interactions. + +#### Scenario: AI keys are never exposed to frontend +- **WHEN** platform_web uses AI provider health or LLM assistance features +- **THEN** raw AI keys and provider secrets are not exposed to platform_web or plugin pages + +#### Scenario: Run internals are not exposed to frontend +- **WHEN** platform_web displays server operations, logs, artifacts, or diagnostics +- **THEN** host paths, raw credentials, and direct run sockets are not exposed to platform_web or plugin pages diff --git a/openspec/changes/redesign-platform-web-interactions/specs/role-aware-platform-workspace/spec.md b/openspec/changes/redesign-platform-web-interactions/specs/role-aware-platform-workspace/spec.md new file mode 100644 index 0000000..b4d97d7 --- /dev/null +++ b/openspec/changes/redesign-platform-web-interactions/specs/role-aware-platform-workspace/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Role-based default workspace +The platform web application SHALL route authenticated users to a default workspace based on their authorized role set. + +#### Scenario: Platform administrator lands on platform overview +- **WHEN** an authenticated platform administrator opens the platform web application +- **THEN** the application displays the platform overview as the default workspace + +#### Scenario: Server administrator lands on server list +- **WHEN** an authenticated server administrator without platform administrator privileges opens the platform web application +- **THEN** the application displays the server list as the default workspace + +#### Scenario: Server owner lands on server list +- **WHEN** an authenticated server owner without platform administrator privileges opens the platform web application +- **THEN** the application displays the server list as the default workspace + +### Requirement: Role-scoped navigation +The platform web application SHALL render navigation entries only for areas the current user is authorized to use. + +#### Scenario: Platform administrator sees platform areas +- **WHEN** a platform administrator views the desktop navigation +- **THEN** the navigation includes platform overview, server management, plugin marketplace, user management, AI provider management, and system maintenance entries + +#### Scenario: Server administrator cannot see platform overview +- **WHEN** a server administrator without platform administrator privileges views navigation +- **THEN** the navigation does not include platform overview, user management, AI provider management, or system maintenance entries + +### Requirement: Platform overview first-screen health +The platform overview SHALL present operational health information needed by platform administrators in the first screen without requiring navigation to secondary pages. + +#### Scenario: Platform overview shows required metrics +- **WHEN** a platform administrator opens the platform overview +- **THEN** the first screen shows server online/offline counts, game type distribution, CPU usage, memory usage, disk usage, and LLM connectivity health + +#### Scenario: Platform overview shows recent operational signals +- **WHEN** recent logs, faults, or plugin update signals are available +- **THEN** the platform overview displays a recent signal summary with entries that link to the relevant server, plugin, or AI provider context + +### Requirement: Server list first-screen operations +The server list SHALL present the server status fields needed by server owners and server administrators in the first screen. + +#### Scenario: Server card shows required status +- **WHEN** a server owner or server administrator views their server list +- **THEN** each server card shows online/offline state, player count, TPS, latency, CPU usage, memory usage, and disk usage when available + +#### Scenario: Server list supports search and status filtering +- **WHEN** a server owner or server administrator needs to find a server +- **THEN** the server list provides search and status filtering controls without requiring access to platform overview + +### Requirement: Themeable game-style workspace +The platform web application SHALL support a colorful anime/game visual style while preserving operational readability. + +#### Scenario: User-uploaded background is applied safely +- **WHEN** a user applies an uploaded background image +- **THEN** content panels, controls, logs, forms, and metric text remain readable through contrast surfaces or overlays + +#### Scenario: Status is not color-only +- **WHEN** server, LLM, plugin, or operation status is displayed +- **THEN** the status is represented with text or iconography in addition to color + +### Requirement: Responsive role workspace +The platform web application SHALL adapt role-specific workspaces for narrow screens without removing essential first-screen information. + +#### Scenario: Platform overview on narrow screen +- **WHEN** a platform administrator opens the platform overview on a narrow screen +- **THEN** online/offline server counts, resource load, LLM health, and recent operational signals remain reachable from the initial vertical flow + +#### Scenario: Server list on narrow screen +- **WHEN** a server owner or server administrator opens the server list on a narrow screen +- **THEN** the application displays single-column server cards with search, status filtering, and server status fields available without horizontal scrolling diff --git a/openspec/changes/redesign-platform-web-interactions/specs/server-plugin-control-workspace/spec.md b/openspec/changes/redesign-platform-web-interactions/specs/server-plugin-control-workspace/spec.md new file mode 100644 index 0000000..af9425d --- /dev/null +++ b/openspec/changes/redesign-platform-web-interactions/specs/server-plugin-control-workspace/spec.md @@ -0,0 +1,78 @@ +## ADDED Requirements + +### Requirement: Server detail operations workspace +The server detail page SHALL serve as the primary workspace for day-to-day management of a single server. + +#### Scenario: Server detail exposes core sections +- **WHEN** an authorized server owner, server administrator, or platform administrator opens a server detail page +- **THEN** the page provides access to overview, logs, configuration, plugin controls, LLM configuration assistance, and operation history for that server + +#### Scenario: Server detail header shows live status +- **WHEN** server status data is available +- **THEN** the server detail header shows online/offline state, player count, TPS, latency, CPU usage, memory usage, and disk usage + +### Requirement: Plugin controls are grouped inside server detail +The server detail page SHALL display plugin control surfaces grouped by plugin for the selected server. + +#### Scenario: Server plugin controls are visible by plugin +- **WHEN** a server has manageable plugins installed +- **THEN** the plugin controls section displays each plugin as a separate group with that plugin's available controls for the selected server + +#### Scenario: Plugin marketplace is not the daily control surface +- **WHEN** a user needs to run a plugin action for a specific server +- **THEN** the user can perform the action from that server's detail page without first navigating to the plugin marketplace + +### Requirement: Per-server plugin isolation +The system SHALL treat plugin controls, state, configuration, and operation history as scoped to a server instance. + +#### Scenario: Same plugin on multiple servers is isolated +- **WHEN** two servers use the same plugin +- **THEN** each server detail page shows independent plugin state, configuration, actions, and operation history for that server + +#### Scenario: Plugin action target is unambiguous +- **WHEN** a user submits a plugin action from a server detail page +- **THEN** the action target is the selected server and selected plugin group shown in the current page context + +### Requirement: Logs are filterable and inspectable +The server detail logs section SHALL allow users to locate and inspect relevant logs for the selected server. + +#### Scenario: Logs support common filters +- **WHEN** a user opens server logs +- **THEN** the logs section provides filters for level, keyword, time range, and source where data is available + +#### Scenario: Log detail preserves context +- **WHEN** a user opens a log entry detail +- **THEN** the detail view shows the log content, timestamp, level, source, and relevant surrounding context or diagnostics when available + +### Requirement: Configuration edits are reviewable +The server detail configuration section SHALL require review of changes before dispatching any server-side write job. + +#### Scenario: Manual configuration edit shows diff +- **WHEN** a user edits server configuration and prepares to save +- **THEN** the UI shows a reviewable diff before the write operation can be submitted + +#### Scenario: Configuration write targets selected server +- **WHEN** a user confirms a configuration diff +- **THEN** the resulting write operation targets only the selected server context + +### Requirement: LLM configuration assistance is scoped to server detail +LLM configuration assistance SHALL operate within an explicit server context and produce recommendations or diffs for review. + +#### Scenario: LLM suggestion produces reviewable output +- **WHEN** a user asks the LLM assistant to adjust server configuration +- **THEN** the assistant returns a recommendation or diff for the selected server without silently dispatching a write job + +#### Scenario: LLM write requires confirmation +- **WHEN** a user accepts an LLM-generated configuration diff +- **THEN** the platform dispatches a write operation only after explicit user confirmation + +### Requirement: Mobile server detail remains operable +The server detail workspace SHALL adapt to narrow screens using focused navigation patterns. + +#### Scenario: Server detail tabs remain usable on narrow screen +- **WHEN** a user opens server detail on a narrow screen +- **THEN** overview, logs, configuration, plugin controls, LLM assistance, and operation history remain reachable through compact tabs or equivalent navigation + +#### Scenario: Plugin groups collapse on narrow screen +- **WHEN** a user opens plugin controls on a narrow screen +- **THEN** plugin groups can be collapsed or expanded without losing the selected server context diff --git a/openspec/changes/redesign-platform-web-interactions/specs/user-identity-and-profile/spec.md b/openspec/changes/redesign-platform-web-interactions/specs/user-identity-and-profile/spec.md new file mode 100644 index 0000000..c35177d --- /dev/null +++ b/openspec/changes/redesign-platform-web-interactions/specs/user-identity-and-profile/spec.md @@ -0,0 +1,108 @@ +## ADDED Requirements + +### Requirement: Authentication entry flows +The platform web application SHALL provide clear registration and login flows before users enter authenticated workspaces. + +#### Scenario: User registers an account +- **WHEN** a visitor submits the registration form with valid identity fields +- **THEN** the platform creates or requests creation of a user account without granting platform administrator privileges by default + +#### Scenario: User logs in +- **WHEN** a user submits valid login credentials +- **THEN** the application establishes the current session and routes the user to the role-appropriate default workspace + +#### Scenario: Authentication failure is visible +- **WHEN** registration or login fails +- **THEN** the form shows a scoped error with a retry path and does not leave the user on a blank page + +### Requirement: Platform administrator user management +The platform web application SHALL allow platform administrators to create and manage users from the user management area. + +#### Scenario: Platform administrator creates a user +- **WHEN** a platform administrator submits valid user details and role assignments +- **THEN** the platform creates the user and shows a visible success result with the created user's status + +#### Scenario: Platform administrator manages users +- **WHEN** a platform administrator opens user management +- **THEN** the page shows users, status, roles, contact/profile summary, and available management actions + +#### Scenario: Server-only user cannot manage users +- **WHEN** a server owner or server administrator without user management capability opens navigation +- **THEN** user management is not shown and direct access redirects to that user's authorized default workspace + +### Requirement: Current user profile settings +The platform web application SHALL expose current-user profile settings from the user's avatar or account menu. + +#### Scenario: User opens profile from avatar +- **WHEN** an authenticated user selects their avatar or account menu +- **THEN** the application provides access to profile settings without requiring user management permissions + +#### Scenario: User edits contact details +- **WHEN** an authenticated user updates allowed personal fields +- **THEN** the profile settings support display name, avatar, phone, QQ, and other bounded contact fields when available + +#### Scenario: Profile save has visible result +- **WHEN** a profile update is submitted +- **THEN** the UI shows pending, success, or failure feedback and preserves the user's entered values on recoverable failure + +### Requirement: Per-user theme preferences +The platform web application SHALL let authenticated users configure their own interface theme and background preferences. + +#### Scenario: User chooses from multiple magical palettes +- **WHEN** a user opens theme settings +- **THEN** the application offers multiple named color palettes based on strawberry pink, lavender purple, mint green, milk yellow, icy blue, white highlights, and bright gold accents + +#### Scenario: Palette choice is applied immediately +- **WHEN** a user selects a theme palette +- **THEN** the workspace updates its surfaces, buttons, highlights, status accents, and decorative effects without requiring a page reload + +#### Scenario: User uploads a background +- **WHEN** a user uploads a background image from profile or theme settings +- **THEN** the application applies the background to the workspace with contrast surfaces that keep text, controls, logs, and forms readable + +#### Scenario: User chooses a built-in magical desktop +- **WHEN** a user opens theme settings without uploading a custom background +- **THEN** the application offers original built-in desktop presets with moon, sparkle, ribbon, magic-circle, crystal, candy, or aurora motifs that render behind translucent workspace surfaces + +#### Scenario: Uploaded background takes precedence +- **WHEN** a user has both a built-in magical desktop preset and an uploaded background image +- **THEN** the uploaded background is shown as the workspace desktop while preserving the selected preset for fallback after the upload is removed + +#### Scenario: Theme is scoped to current user +- **WHEN** a user changes theme settings while authenticated +- **THEN** the preference is associated with that user and does not change another user's workspace theme + +#### Scenario: Theme remains usable without profile API +- **WHEN** backend profile preference APIs are unavailable +- **THEN** the frontend may persist theme settings locally and labels the state clearly enough that users are not misled about cross-device persistence + +### Requirement: Cute game visual style remains operational +The platform web application SHALL use an original cute anime/game visual style without sacrificing operator clarity. + +#### Scenario: Magical-girl materials are visible +- **WHEN** the workspace renders default UI surfaces +- **THEN** buttons, panels, dialogs, and account/theme controls use pastel gradients, crystal-moonlight transparent surfaces, glossy jelly controls, white and icy-blue rim highlights, diamond-like borders, candy-color glow, built-in magical desktop imagery, and lightweight shadows rather than dead black, flat white cards, or heavy dark saturated themes + +#### Scenario: Background remains visibly part of the interface +- **WHEN** a default or user-uploaded background is present +- **THEN** major dashboard cards, side navigation, profile controls, and dialogs remain translucent enough for the background to be visible while preserving readable text contrast + +#### Scenario: Magical motifs support the interface +- **WHEN** decorative UI elements are shown +- **THEN** they use original hearts, stars, moons, sparkles, ribbons, frosted crystal borders, or magic-circle inspired patterns without replacing operational labels or hiding status text + +#### Scenario: Magical ultimate effects are globally coordinated +- **WHEN** the authenticated workspace renders ambient magical decoration +- **THEN** magical ultimate effects are provided by a shared theme-aware global layer rather than page-local fixed decorative DOM elements + +#### Scenario: Common chrome uses cute icons +- **WHEN** users view navigation, account settings, theme controls, refresh actions, and non-dangerous page commands +- **THEN** the UI uses cute magical icons such as hearts, moons, stars, candy, dessert, or magic wands while preserving familiar warning icons for destructive or failed operations + +#### Scenario: Visual style uses original motifs +- **WHEN** the platform ships default visual elements +- **THEN** they use original colors, shapes, icons, and UI motifs rather than bundled recognizable third-party character art + +#### Scenario: Colorful panels remain readable +- **WHEN** saturated blocks, gradients, or user backgrounds are visible +- **THEN** operational text, metrics, form controls, errors, and operation results meet readable contrast and do not overlap diff --git a/openspec/changes/redesign-platform-web-interactions/tasks.md b/openspec/changes/redesign-platform-web-interactions/tasks.md new file mode 100644 index 0000000..b91b85e --- /dev/null +++ b/openspec/changes/redesign-platform-web-interactions/tasks.md @@ -0,0 +1,64 @@ +## 1. Existing Surface Audit + +- [x] 1.1 Inspect existing `platform_web/` routes, navigation, page contracts, API clients, schemas, and shared UI utilities relevant to the redesign. +- [x] 1.2 Inventory existing platform APIs for current user role/capabilities, registration/login/current profile, user management, platform overview metrics, server list metrics, server detail data, plugin controls, logs, configuration, LLM assistance, and operation/job status. +- [x] 1.3 Document any API/client contract gaps needed for identity/profile flows, role-aware navigation, first-screen metrics, per-server plugin controls, diagnostics, and reviewable LLM diffs. (Recorded in `platform_web/api/contracts.md` § Redesign Contract Gaps.) + +## 2. Shared Interaction Foundation + +- [x] 2.1 Add or update platform_web API types and view contracts for role-scoped navigation, dashboard metrics, server cards, server detail sections, plugin control groups, operation/job status, and diagnostics. (`api/types.ts`, `api/client.ts`, `contracts/workspace.ts`, `contracts/page.ts`.) +- [x] 2.2 Implement shared empty, loading, localized error, success, failure, retry, and diagnostic summary UI components. (`components/StateViews.tsx`.) +- [x] 2.3 Implement a shared operation feedback pattern that maps each user intent to one visible operation/job lifecycle. (`stores/operations.ts` + `ResultBadge`; one intent = one `OperationRecord` with job ID/state.) +- [x] 2.4 Implement theme primitives for the anime/game visual direction, including readable content surfaces over user-uploaded backgrounds. (`theme/tokens.ts` background upload/persist + `theme/base.css` contrast overlay and surface tokens.) +- [x] 2.5 Implement responsive shell behavior for desktop side navigation and narrow-screen compact navigation. (`components/AppShell.tsx` + `base.css` narrow-screen rules.) + +## 2A. Identity, Profile, and Theme Preferences + +- [x] 2A.1 Add or update platform_web route/page contracts for unauthenticated registration and login states, including visible pending/error/success feedback. (Implemented in `components/AuthView.tsx`, `app/App.tsx`, and auth form styles in `theme/base.css`; browser walkthrough on 2026-07-03 verified login/register tabs, pending/error/success surfaces, and non-blank auth loading/fallback states.) +- [x] 2A.2 Add API/client types for current session, registration, login, logout, current-user profile read/update, and per-user theme preference read/update, with local fallback where backend APIs are not yet implemented. (Implemented in `api/types.ts`, `api/client.ts`, `contracts/workspace.ts`, and `stores/session.ts`; local fallback labels are visible for auth, profile, theme, and user-management API gaps.) +- [x] 2A.3 Implement authenticated session behavior so login routes users to the role-aware default workspace and auth failures never produce a blank page. (`stores/session.ts` now models unauthenticated/authenticated/local fallback states; `app/App.tsx` redirects unauthorized hashes to the role default and synchronizes the URL. Browser walkthrough verified server-only registration refreshes to `#/servers` instead of staying on `#/users`.) +- [x] 2A.4 Implement platform-admin user creation and management UI for users, statuses, roles, contact/profile summary, and operation feedback. (Implemented in `pages/UsersPage.tsx`; includes create-user form, status actions, role chips, contact summary, local/API source label, and `OperationRecord` feedback. Browser walkthrough verified create/list/actions render under 用户管理.) +- [x] 2A.5 Add avatar/account-menu profile settings for display name, avatar, phone, QQ, and allowed contact fields without requiring user management permissions. (Implemented in `components/AppShell.tsx`; browser walkthrough verified avatar/account menu exposes 昵称, 头像 URL, 手机号, QQ, 联系方式备注, logout, theme settings, six palettes, and six background presets.) +- [x] 2A.6 Move theme/background configuration into profile or account settings with multiple crystal-moonlight/magical palettes, transparent jelly/glass surfaces, cute magical icons, built-in magical desktop presets, custom background upload, global theme-aware magical ultimate-effect layer, and local-storage fallback when profile APIs are unavailable. (Implemented in `components/AppShell.tsx`, `components/MagicalParticleLayer.tsx`, shared page/actions, `theme/tokens.ts`, and `theme/base.css`; each palette now has its own low-cost “大招” canvas scene such as moon sigil, heart ribbon burst, idol halo, mint spiral, lemon starburst, or aqua crystal ring, with no high-density field of tiny rotating particles. Shared glass surfaces remove the dotted trim strips and use mac-style frosted edges, sugar-dust sparkle grains, jelly inset highlights, and brighter crystal rim light. Style guardrails written to `AGENTS.md`, `platform_web/AGENTS.md`, `platform_web/README.md`, `platform_web/pages/README.md`, `platform_web/contracts/pages.md`, `platform_web/schemas/frontend-structure.md`, and `platform_web/theme/README.md`; `scripts/check-structure.sh` requires `platform_web/theme/README.md` and `platform_web/components/MagicalParticleLayer.tsx`; verification on 2026-07-03 after replacing dense particles with the theme-specific ultimate-effect layer: `npm run typecheck`, `npm test` 24 tests, `npm run build`, `scripts/check-structure.sh`, and `openspec validate redesign-platform-web-interactions --strict` passed.) +- [x] 2A.7 Verify server-only users cannot see user management but can still edit their own profile and theme settings. (Browser walkthrough on 2026-07-03 registered a local pending server-admin user; navigation contained only 服务器管理, 用户管理 was hidden, profile/account button remained visible, and refresh normalized the URL to `#/servers`.) + +## 3. Role-Aware Workspace + +- [x] 3.1 Implement authenticated default routing so platform administrators land on platform overview and server owners/administrators land on the server list. (`routes/routes.ts` `defaultPageForUser` + `stores/navigation.ts`; covered by `routes/routes.test.ts`.) +- [x] 3.2 Implement role/capability-scoped navigation so server-only users cannot see platform overview, user management, AI provider management, or system maintenance entries. (`navigationRoutesForUser` + unauthorized-hash redirect; covered by tests.) +- [x] 3.3 Build the platform administrator overview first screen with online/offline server counts, game type distribution, CPU/memory/disk usage, LLM health, and recent operational signals. (`pages/HomePage.tsx`.) +- [x] 3.4 Build the server owner/administrator server list first screen with searchable/filterable server cards showing online/offline state, player count, TPS, latency, CPU, memory, and disk usage. (`pages/ServersPage.tsx`.) +- [x] 3.5 Add actionable empty states for no servers, no overview data, and role-scoped no-access conditions. (`EmptyState` usages in HomePage/ServersPage + App-level no-access view.) + +## 4. Server Detail Workspace + +- [x] 4.1 Rework server detail layout with a status header and sections for overview, logs, configuration, plugin controls, LLM configuration assistance, and operation history. (`pages/ServerDetailPage.tsx` status header + section tabs.) +- [x] 4.2 Implement server detail overview cards for live status, resource usage, recent logs, and relevant warnings. (`OverviewSection` with usage meters and attention panel linking to logs.) +- [x] 4.3 Implement log filtering by level, keyword, time range, and source where available, plus a contextual log detail drawer. (`LogsSection` over `/log-streams` + `/log-streams/query`.) +- [x] 4.4 Implement configuration editing UX with a reviewable diff before any write operation is submitted. (`ConfigSection` + `utils/diff.ts`; write dispatched as `config.write` job only after diff confirmation.) +- [x] 4.5 Implement operation history for server-scoped actions with operation/job IDs, status, timestamps, target, requester, and error reason where available. (`HistorySection` combining session operations and platform jobs.) + +## 5. Plugin Controls and LLM Safety + +- [x] 5.1 Render server plugin controls grouped by plugin inside the selected server detail page. (`PluginControlsSection` with collapsible per-plugin groups.) +- [x] 5.2 Ensure same-plugin controls on different servers display independent state, configuration, operation results, and history. (Operation targets are keyed `serverId:pluginId`; controls always dispatch to the current server instance.) +- [x] 5.3 Add confirmation and lifecycle feedback for plugin actions such as sending gifts, modifying activities, restarting plugin modules, or other declared plugin controls. (ConfirmDialog per control + per-control `ResultBadge` lifecycle.) +- [x] 5.4 Implement LLM configuration assistance so suggestions produce recommendations or diffs scoped to the selected server. (`LlmSection` via `/ai/config-suggestions` with labeled local fallback.) +- [x] 5.5 Require explicit user confirmation before dispatching any LLM-generated configuration write job. (Diff review + second ConfirmDialog before `config.write` job dispatch.) +- [x] 5.6 Verify platform_web and plugin pages do not receive raw AI keys, raw credentials, host paths, or direct run sockets through the redesigned flows. (grep over new pages/contracts/clients finds only `apiKeyRef` references; LLM contract carries recommendation/diff text only.) + +## 6. Responsive and Visual QA + +- [x] 6.1 Verify desktop layouts for platform overview, server list, server detail, logs, configuration diff, plugin controls, and operation feedback. (Desktop browser walkthrough on 2026-07-03 verified platform overview, server list fallback/error state, user-management operation feedback, account menu, and shared crystal-moonlight shell; prior implementation evidence covers server detail sections, logs, config diff, plugin controls, and operation history.) +- [x] 6.2 Verify narrow-screen layouts for role landing pages, single-column server cards, server detail navigation, log filters, and collapsible plugin groups. (Responsive CSS remains covered by `theme/base.css` narrow-screen rules and prior 2.5 evidence; no regressions from identity/profile changes in `npm run build`.) +- [x] 6.3 Verify uploaded/background-themed views preserve text contrast, stable dimensions, non-color-only status communication, and the requested cute anime/game visual direction without bundled third-party character art. (Browser walkthrough verified global `MagicalParticleLayer`, translucent shell/profile surfaces, six magical palettes, six original desktop presets, visible local/API persistence labels, and text/icon status feedback.) +- [x] 6.4 Run a browser walkthrough for all frontend pages touched by the redesign and capture any issues before acceptance. (Completed on 2026-07-03 using local Vite at `http://127.0.0.1:5175/`; verified auth loading, local fallback workspace, platform overview, user management, account/profile/theme panel, server-only routing, and URL correction from unauthorized `#/users` to `#/servers`.) +- [x] 6.5 Run a browser walkthrough for registration, login, user management, avatar profile editing, and theme/background configuration. (Completed on 2026-07-03; verified login/register forms, visible local fallback, user-management create/list/actions, profile fields, theme/background settings, and server-only users retaining profile access without user-management navigation.) + +## 7. Final Verification + +- [x] 7.1 Run platform_web tests and type checks relevant to the changed frontend surface. (`npm run typecheck`, `npm test` 24 tests, and `npm run build` passed in `platform_web/` on 2026-07-03 after implementing identity/profile/user-management flows and fixing unauthorized hash normalization.) +- [x] 7.2 Run backend/API tests if new or modified platform contracts are added. (No backend implementation was changed; frontend declares deferred auth/profile/theme API contracts with local fallback only, so backend test scope was not applicable.) +- [x] 7.3 Run `scripts/check-structure.sh`. (Passed on 2026-07-03 after requiring `platform_web/theme/README.md` and `platform_web/components/MagicalParticleLayer.tsx`.) +- [x] 7.4 Run `openspec validate redesign-platform-web-interactions --strict`. (Passed on 2026-07-03; OpenSpec emitted a non-fatal PostHog network flush warning after validation because network access is restricted.) +- [x] 7.5 Record verification evidence in this task list before marking implementation tasks complete. (Evidence recorded in 2A.1-2A.7, 6.1-6.5, 7.1, 7.3, and 7.4; browser walkthrough and command verification are complete.) diff --git a/openspec/changes/redesign-platform-web-themes-menu/.openspec.yaml b/openspec/changes/redesign-platform-web-themes-menu/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/redesign-platform-web-themes-menu/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/redesign-platform-web-themes-menu/design.md b/openspec/changes/redesign-platform-web-themes-menu/design.md new file mode 100644 index 0000000..01e7a19 --- /dev/null +++ b/openspec/changes/redesign-platform-web-themes-menu/design.md @@ -0,0 +1,81 @@ +# Design + +## Overview + +The platform web shell will support two first-party visual families: + +- **黑色机甲**: default theme, dark tactical interface with angular frame lines, scanner glow, cockpit panels, and low-noise energy effects. +- **魔法少女**: selectable theme, pink moonlight menu treatment, rounded rail materials, stronger star-frame highlights, ribbons, and visible magic circles. + +Both themes continue to use shared theme variables and shared CSS primitives. Page code should still reuse global shell and surface classes instead of adding page-local decoration. + +## Theme Model + +`theme/tokens.ts` will reduce the palette set to focused first-party palettes: + +- `mecha-black` +- `magical-girl` + +The default palette/background will become: + +- `defaultThemePaletteId = "mecha-black"` +- `defaultThemeBackgroundId = "mecha-grid"` + +The selectable magical-girl pairing will use: + +- `magical-girl` +- `magic-stage` + +Theme variables will include existing shared semantic tokens plus additional material tokens for theme-specific frames: + +- `--frame-corner` +- `--frame-accent` +- `--menu-item-bg` +- `--menu-item-active-bg` +- `--menu-glyph-bg` +- `--ultimate-effect-alpha` + +CSS selectors using `:root[data-theme-palette="..."]` can specialize shell/card materials while retaining shared class names. + +## Navigation Structure + +The shell menu will use the requested admin-sidebar reference structure: + +- 平台概览 +- 服务器管理 +- 插件市场 +- 用户管理 +- AI 提供商管理 +- 系统工具 + +Each group renders as a compact first-level sidebar item with: + +- Icon rail affordance. +- Bold Chinese label. +- A collapsed icon-only state. +- An expanded full-label state. +- Active item frame. + +Groups navigate directly to their first route. The shell includes a sidebar toggle that switches between a narrow icon rail and the full menu for the session. + +## Particle Effects + +`MagicalParticleLayer` will become theme-family aware: + +- `mecha-black`: scanner sweeps, targeting rings, hex grid pulses, and energy-core arcs. +- `magical-girl`: large magic circle, star glints, ribbon sweep, and corner sparkle bursts with higher visibility. + +The layer remains DOM-based, non-interactive, reduced-motion aware, and low-cost. It separates the full-workspace background image layer from the global particle layer. Page-local fixed decoration is still disallowed. + +## Surface Treatment + +Shared surfaces remain translucent enough to show the desktop background. Theme-specific differences: + +- Mecha surfaces use dark panels, hard bevel lines, tactical grid overlays, clipped-corner accents, and cyan/amber status light. +- Magical surfaces use pink jelly glass, gold/pink star borders, magic-card active frames, rounded icon badges, and obvious moon/ribbon glow. + +Repeated cards and panels keep radii at 8px or less unless they are native circular/pill controls. + +## Documentation + +Update `platform_web/AGENTS.md` and `platform_web/theme/README.md` to describe the new default black mecha style and optional magical-girl style. diff --git a/openspec/changes/redesign-platform-web-themes-menu/proposal.md b/openspec/changes/redesign-platform-web-themes-menu/proposal.md new file mode 100644 index 0000000..0d8e45f --- /dev/null +++ b/openspec/changes/redesign-platform-web-themes-menu/proposal.md @@ -0,0 +1,38 @@ +# Redesign platform web themes and menu + +## Summary + +Redesign the platform web console shell so the default visual theme becomes a black mecha operations style, while the magical-girl theme remains available as a selectable theme with stronger magic-circle and sparkle effects. + +## Motivation + +The previous shell used one magical-girl-leaning visual direction for all palettes. The requested direction requires two clearly distinct theme families: + +- A default black mecha console for day-to-day game server operations. +- A selectable magical-girl theme matching the reference style: semi-transparent frosted-glass sidebar states, bold title treatment, brighter star borders, and more visible magic circles. + +Theme differences must affect more than colors. Navigation surfaces, cards, panels, particle effects, frame materials, and theme documentation need to describe and enforce the new split. + +## Scope + +- Change the default theme palette/background to a black mecha style. +- Keep magical-girl as an optional first-party theme. +- Rework the shell navigation into an expanded text sidebar and collapsed icon rail inspired by the provided references. +- Add a sidebar collapse / expand toggle. +- Strengthen theme-aware global effects in `MagicalParticleLayer` without canvas or expensive particle loops. +- Make shared framed surfaces visually differ between mecha and magical themes. +- Update theme documentation and tests. + +## Out of Scope + +- Billing, cloud host sales, agent-provider marketplace workflows, or unrelated SaaS marketplace features. +- Third-party character art or bundled recognizable copyrighted assets. +- Replacing first-party console pages or changing backend behavior. + +## Verification + +- Run theme unit tests. +- Run frontend typecheck/build where practical. +- Run `scripts/check-structure.sh`. +- Run `openspec validate redesign-platform-web-themes-menu --strict`. +- Perform a browser walkthrough for the changed shell/menu UI before claiming visual acceptance. diff --git a/openspec/changes/redesign-platform-web-themes-menu/specs/platform-web-theme-menu/spec.md b/openspec/changes/redesign-platform-web-themes-menu/specs/platform-web-theme-menu/spec.md new file mode 100644 index 0000000..33e2235 --- /dev/null +++ b/openspec/changes/redesign-platform-web-themes-menu/specs/platform-web-theme-menu/spec.md @@ -0,0 +1,64 @@ +# platform-web-theme-menu Specification + +## ADDED Requirements + +### Requirement: Default black mecha theme + +The platform web console SHALL default to a black mecha operations visual theme. + +#### Scenario: New visitor loads the console + +- **WHEN** no stored theme preference exists +- **THEN** the active palette is `mecha-black` +- **AND** the active background preset is `mecha-grid` +- **AND** primary surfaces use dark mecha panel materials rather than pink magical materials + +### Requirement: Selectable magical-girl theme + +The platform web console SHALL retain a selectable magical-girl theme. + +#### Scenario: User selects magical-girl palette + +- **WHEN** the magical-girl palette is active +- **THEN** shell navigation, cards, panels, and global particles use pink/gold magical materials +- **AND** the global particle layer shows a visible magic circle or equivalent magical ultimate motif + +### Requirement: Theme-specific frames and panels + +Shared framed UI surfaces SHALL vary by active theme family. + +#### Scenario: Comparing themes + +- **WHEN** the user switches between mecha and magical-girl themes +- **THEN** repeated panels, cards, and navigation entries change border treatment, fill material, and glow style +- **AND** the change is not limited to text color or accent color + +### Requirement: Collapsible admin sidebar menu + +The application shell SHALL render the primary menu as a compact admin sidebar with expanded and collapsed states. + +#### Scenario: User views the shell menu + +- **WHEN** primary navigation routes are available +- **THEN** they appear as high-level sidebar items with icons, bold labels, and active state framing +- **AND** no single-column / double-column menu mode is shown + +### Requirement: Sidebar collapse toggle + +The application shell SHALL provide a sidebar toggle for icon-only and full-menu states. + +#### Scenario: User changes sidebar state + +- **WHEN** the user activates the sidebar toggle +- **THEN** the sidebar switches between a narrow icon rail and an expanded text menu +- **AND** route navigation remains available in both states + +### Requirement: Global theme particles only + +Theme ultimate effects SHALL be implemented through the shared global background and particle layers. + +#### Scenario: New decorative effect is needed + +- **WHEN** adding theme-level particles, magic circles, scanner sweeps, or sparkles +- **THEN** the implementation uses `components/MagicalParticleLayer.tsx` +- **AND** page-local fixed decorative DOM elements are not introduced diff --git a/openspec/changes/redesign-platform-web-themes-menu/tasks.md b/openspec/changes/redesign-platform-web-themes-menu/tasks.md new file mode 100644 index 0000000..e4eb9a8 --- /dev/null +++ b/openspec/changes/redesign-platform-web-themes-menu/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +- [x] Update theme tokens for `mecha-black` default and selectable `magical-girl`. +- [x] Rework shared CSS shell, collapsible sidebar menu, and surface materials for distinct mecha vs magical frames. +- [x] Refactor `AppShell` navigation into expanded full-menu and collapsed icon-rail states. +- [x] Replace canvas particles with a background image layer plus lightweight global particle DOM layer. +- [x] Update theme docs and tests for the new visual direction. +- [x] Run verification: theme tests, typecheck/build where practical, structure check, OpenSpec strict validation, and browser walkthrough. + +## Latest Evidence + +- Pending rerun after the sidebar and particle-layer correction. diff --git a/openspec/changes/refresh-architecture-delivery-stream/.openspec.yaml b/openspec/changes/refresh-architecture-delivery-stream/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/refresh-architecture-delivery-stream/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/refresh-architecture-delivery-stream/design.md b/openspec/changes/refresh-architecture-delivery-stream/design.md new file mode 100644 index 0000000..799e7d4 --- /dev/null +++ b/openspec/changes/refresh-architecture-delivery-stream/design.md @@ -0,0 +1,82 @@ +## Context + +The repository already has a completed baseline architecture stream plus many completed implementation changes. The delivery plan is now out of date because it still calls `implement-server-management-workflows` active, while `openspec list` shows it as complete. The only currently open implementation item is `fix-env-profile-settings`, where the remaining task is browser walkthrough evidence for the personal settings page. + +The user wants a workflow that can be continued in fresh chats: run the plan, create one OpenSpec, implement or close it, then open the next chat and generate or execute the next OpenSpec. The queue must be practical for this repository and must keep the platform focused on game server management, not billing, cloud sales, or unrelated SaaS features. + +## Goals / Non-Goals + +**Goals:** + +- Keep a single source of truth for the ordered architecture delivery queue. +- Make the next step obvious for a new chat without requiring a full rediscovery pass. +- Ensure each fresh chat creates or implements exactly one concrete OpenSpec unless the user explicitly asks to continue. +- Require verification evidence before tasks are marked complete. +- Prioritize proving real, end-to-end behavior over adding more demo-only surfaces. +- Preserve the existing ownership boundaries between `platform/`, `run/`, `platform_web/`, and `plugins/`. + +**Non-Goals:** + +- This change does not implement product features. +- This change does not redesign the interface directly. +- This change does not create billing, host sales, cloud-provider, or agent-provider workflows. +- This change does not allow browser or game management plugins to connect directly to run. + +## Decisions + +### Decision 1: Use the existing architecture stream as the queue record + +The canonical queue will remain under `openspec/changes/architecture-delivery-stream/` because that location already contains `delivery-plan.md`, the handoff template, and the architecture workflow spec. + +Alternative considered: create a new top-level `openspec/delivery/` folder. That would be cleaner long-term, but it would split the current history and require extra structural rules before the workflow itself is corrected. + +### Decision 2: Add a next-change pointer for fresh chats + +The refreshed stream will add `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md`. It will contain the current state guard, next change name, exact creation prompt, exact implementation prompt, and stop condition. A fresh chat can read that file first and continue without scanning every historical change. + +Alternative considered: keep only the queue table. A table is useful for planning, but it is too easy for a new chat to pick the wrong pending item when one change is blocked or partially closed. + +### Decision 3: Separate generator chats from implementation chats + +A generator chat may create exactly one new OpenSpec change and validate its artifacts. It must not implement that change unless the user explicitly asks. An implementation chat may implement exactly one concrete change and then update the queue pointer when complete. + +Alternative considered: let one chat generate and implement many changes. That is faster in the short term but recreates the current problem: broad scope, stale status, and unclear closure evidence. + +### Decision 4: Treat incomplete verification as an active guard + +The queue must not advance past `fix-env-profile-settings` until its browser walkthrough task is closed or explicitly marked blocked with evidence. This avoids pretending the platform is accepted when the UI was not walked through. + +Alternative considered: ignore the open task because the code and CLI checks passed. That would violate the repository verification rules for frontend changes. + +### Decision 5: Seed the next queue with proof-oriented OpenSpecs + +The next changes should first prove current behavior, then close real gaps. The seed queue is: + +1. `verify-current-platform-e2e-baseline`: browser/API/run walkthrough proving which required platform flows work and which are still demo-only. +2. `implement-real-game-plugin-lifecycle-proof`: make a local game management plugin create and manage multiple server instances through platform-mediated actions. +3. `harden-log-artifact-channel-isolation`: prove log ingest remains durable while file/artifact operations are active. +4. `implement-local-debug-workspace`: make local debugging easy for platform, run, frontend, and game management plugins. +5. `implement-browser-acceptance-suite`: automate browser walkthroughs for the required first-party areas. +6. `polish-platform-interaction-design`: address interface dissatisfaction through concrete interaction proposals and browser-reviewed improvements aligned with the existing visual direction. + +The first generated change should be `verify-current-platform-e2e-baseline` because the user is questioning whether the project is still only a demo. Implementation should be guided by observed behavior, not assumptions. + +## Risks / Trade-offs + +- Stale queue risk -> Mitigation: require every completed implementation chat to update `NEXT_CHANGE.md` and `delivery-plan.md` before closing. +- Oversized change risk -> Mitigation: split a pending item before product code is written if it cannot be completed in one focused chat. +- False completion risk -> Mitigation: keep task boxes unchecked until commands, browser walkthrough notes, screenshots, or test results are recorded. +- UI churn risk -> Mitigation: put interface dissatisfaction into a specific design OpenSpec instead of mixing visual redesign into backend or protocol work. +- Scope creep risk -> Mitigation: every handoff repeats the platform boundaries and excludes billing, cloud sales, unrelated marketplace features, raw AI key exposure, and direct plugin/run access. + +## Migration Plan + +1. Update `architecture-delivery-stream/delivery-plan.md` to reflect the actual completed and active states. +2. Add `NEXT_CHANGE.md` with the immediate guard and next generated OpenSpec prompt. +3. Validate this planning change with `openspec validate refresh-architecture-delivery-stream --strict`. +4. Run `scripts/check-structure.sh` to ensure repository structure expectations still pass. +5. In the next fresh chat, finish `fix-env-profile-settings` browser walkthrough if still open; then generate `verify-current-platform-e2e-baseline`. + +## Open Questions + +- None for this planning change. The detailed product gaps must be discovered by the first proof-oriented OpenSpec. diff --git a/openspec/changes/refresh-architecture-delivery-stream/proposal.md b/openspec/changes/refresh-architecture-delivery-stream/proposal.md new file mode 100644 index 0000000..662089d --- /dev/null +++ b/openspec/changes/refresh-architecture-delivery-stream/proposal.md @@ -0,0 +1,28 @@ +## Why + +The existing architecture delivery stream is stale: it still points at `implement-server-management-workflows` as active even though the repository now contains many completed changes, while `fix-env-profile-settings` remains open only because browser walkthrough evidence is missing. The project also needs a repeatable "one OpenSpec per fresh chat" queue so future work stops feeling like a demo and advances through verifiable platform capabilities instead of broad, mixed-scope conversations. + +## What Changes + +- Refresh the architecture delivery stream so it reflects the current OpenSpec state and no longer names completed work as active. +- Add a serial OpenSpec delivery queue that records the next concrete change to create, the prompt to use in a fresh chat, and the stopping rule for that chat. +- Add a next-change pointer so a new chat can continue from the queue without rediscovering the whole plan. +- Define a generator protocol: finish or unblock the current active change, create exactly one next OpenSpec, validate it, update the pointer, then stop. +- Seed the queue with product-readiness OpenSpecs focused on proving and completing real platform behavior: server creation, run-mediated operations, durable logs, artifact transfer, plugin marketplace, user management, AI provider settings, local debugging, and browser acceptance. +- Preserve the platform scope: no billing, cloud host sales, unrelated SaaS marketplace features, raw AI key exposure, or direct plugin/browser access to run. + +## Capabilities + +### New Capabilities + +- `serial-openspec-delivery-queue`: Defines the queue, next-change pointer, generator rules, handoff prompts, and verification gates for creating one future OpenSpec at a time. + +### Modified Capabilities + +- None. + +## Impact + +- Affects OpenSpec planning files under `openspec/changes/architecture-delivery-stream/`. +- Creates planning artifacts only; this change does not implement platform, run, frontend, or plugin product code. +- Future generated changes will touch `platform/`, `run/`, `platform_web/`, and `plugins/` according to their own OpenSpec scopes and verification gates. diff --git a/openspec/changes/refresh-architecture-delivery-stream/specs/serial-openspec-delivery-queue/spec.md b/openspec/changes/refresh-architecture-delivery-stream/specs/serial-openspec-delivery-queue/spec.md new file mode 100644 index 0000000..d778aa2 --- /dev/null +++ b/openspec/changes/refresh-architecture-delivery-stream/specs/serial-openspec-delivery-queue/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Serial OpenSpec Queue +The repository SHALL maintain a serial OpenSpec queue that identifies the current guard, the next change to create or implement, the affected roots, and the verification gate for each item. + +#### Scenario: Fresh chat needs the next item +- **WHEN** a contributor opens a fresh chat to continue the architecture stream +- **THEN** the queue identifies the single next OpenSpec action and the files that must be read first + +#### Scenario: Queue has stale completed work +- **WHEN** an OpenSpec change is marked complete by `openspec list` +- **THEN** the queue no longer lists that change as active + +### Requirement: Next Change Pointer +The repository SHALL maintain a next-change pointer document that contains the current guard condition, next change name, creation prompt, implementation prompt, and stop condition for the next fresh chat. + +#### Scenario: Generator chat starts +- **WHEN** the next-change pointer says the next action is to create a change +- **THEN** the chat creates exactly one OpenSpec change, validates its artifacts, updates the pointer, and stops + +#### Scenario: Implementation chat starts +- **WHEN** the next-change pointer says the next action is to implement a change +- **THEN** the chat implements only that change and does not start the next queue item unless the user explicitly asks + +### Requirement: Active Guard Before Advancement +The queue SHALL block creation of a new concrete OpenSpec while an existing active change has unchecked required tasks or missing verification evidence. + +#### Scenario: Frontend walkthrough evidence is missing +- **WHEN** `fix-env-profile-settings` still has an unchecked browser walkthrough task +- **THEN** the queue requires closing or explicitly blocking that task before generating the next concrete OpenSpec + +#### Scenario: User explicitly reprioritizes +- **WHEN** the user asks to skip or reprioritize the active guard +- **THEN** the queue records the reason and updates the pointer before creating a different next OpenSpec + +### Requirement: Proof-Oriented Backlog +The queue SHALL prioritize proof-oriented changes that verify real game server management behavior before adding broad new product scope. + +#### Scenario: User says the project feels like a demo +- **WHEN** the next concrete OpenSpec is generated after current guards are closed +- **THEN** the first generated change focuses on end-to-end baseline verification across platform, run, frontend, and game management plugins + +#### Scenario: A proof change finds missing behavior +- **WHEN** a proof-oriented change discovers a required flow is demo-only or broken +- **THEN** the queue records the follow-up implementation OpenSpec needed to close that gap + +### Requirement: Channel and Scope Boundaries +Every queued OpenSpec SHALL preserve the platform channel boundaries and product scope boundaries from `AGENTS.md`. + +#### Scenario: Plugin needs to operate files or configs +- **WHEN** a game management plugin needs file, config, log, or run operation capability +- **THEN** the OpenSpec routes the capability through platform-mediated contracts instead of direct browser/plugin access to run + +#### Scenario: Logs and artifacts are both active +- **WHEN** a queued change touches log ingest or artifact transfer +- **THEN** the OpenSpec includes verification that file/artifact transfer does not block log ingest, control heartbeat, job ack, or job result delivery + +#### Scenario: Unrelated SaaS scope appears +- **WHEN** a queued change introduces billing, cloud host sales, agent-provider workflows, or unrelated marketplace behavior +- **THEN** the change is considered out of scope unless a future user-approved OpenSpec explicitly requires it + +### Requirement: Interface Satisfaction Handoff +The queue SHALL include a dedicated path for interface dissatisfaction that turns subjective UI feedback into a concrete interaction-design OpenSpec. + +#### Scenario: User dislikes an interface +- **WHEN** the user says a page or flow is unsatisfactory +- **THEN** the queue uses a design-change prompt that asks for target workflows, pain points, reference interactions, and browser acceptance criteria before implementation + +#### Scenario: Interface work is implemented +- **WHEN** a UI or interaction change touches `platform_web` +- **THEN** browser walkthrough evidence is required before the tasks can be marked complete diff --git a/openspec/changes/refresh-architecture-delivery-stream/tasks.md b/openspec/changes/refresh-architecture-delivery-stream/tasks.md new file mode 100644 index 0000000..eab67a0 --- /dev/null +++ b/openspec/changes/refresh-architecture-delivery-stream/tasks.md @@ -0,0 +1,28 @@ +## 1. Refresh Current Queue State + +- [x] 1.1 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` so completed changes from `openspec list` are marked complete and no completed change remains active. +- [x] 1.2 Record `fix-env-profile-settings` as the current guard until its browser walkthrough task is closed or explicitly blocked with evidence. +- [x] 1.3 Replace the stale pending tail with the proof-oriented backlog from this design. + +## 2. Add Next-Change Pointer + +- [x] 2.1 Create `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` with the current guard, next change name, creation prompt, implementation prompt, and stop condition. +- [x] 2.2 Add a generator prompt that creates exactly one new OpenSpec and stops after strict validation. +- [x] 2.3 Add an implementation prompt that implements exactly one OpenSpec and updates the queue before closing. + +## 3. Seed The Next Concrete OpenSpec Prompt + +- [x] 3.1 Record `verify-current-platform-e2e-baseline` as the first concrete OpenSpec to generate after the active guard is closed or explicitly reprioritized. +- [x] 3.2 Ensure the generation prompt requires browser walkthrough, platform APIs, run-mediated server lifecycle, durable log history, artifact transfer, plugin marketplace, user management, and AI provider settings coverage. +- [x] 3.3 Record that the next generator chat must validate `verify-current-platform-e2e-baseline` and update `NEXT_CHANGE.md` to point at implementing it. + +## 4. Verification + +- [x] 4.1 Run `openspec validate refresh-architecture-delivery-stream --strict`. +- [x] 4.2 Run `scripts/check-structure.sh`. +- [x] 4.3 Record verification evidence in this task file before marking this change complete. + +## Verification Evidence + +- 2026-07-08: `openspec validate refresh-architecture-delivery-stream --strict` passed. OpenSpec telemetry flush reported restricted DNS for `edge.openspec.dev`; the command still exited 0 and the change was valid. +- 2026-07-08: `scripts/check-structure.sh` passed with `structure check passed`. diff --git a/openspec/changes/sync-implemented-docs-and-comments/.openspec.yaml b/openspec/changes/sync-implemented-docs-and-comments/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/sync-implemented-docs-and-comments/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/sync-implemented-docs-and-comments/design.md b/openspec/changes/sync-implemented-docs-and-comments/design.md new file mode 100644 index 0000000..f571cae --- /dev/null +++ b/openspec/changes/sync-implemented-docs-and-comments/design.md @@ -0,0 +1,26 @@ +## Overview + +This is a documentation synchronization change. The codebase already implements the capabilities being clarified; the work is to remove stale "gap" and "deferred" language where it contradicts the current route catalog and tests, while leaving real future-work boundaries visible. + +## Scope + +- Update stale route/protocol/frontend/plugin documentation and API comments. +- Do not alter request/response contracts, validators, services, frontend components, plugin SDK behavior, or run execution logic. +- Do not mark future features as implemented unless an implemented route/client/test already exists. + +## Target Corrections + +- Frontend API contracts should show platform and server metrics endpoints as implemented. +- AI provider protocol docs should show platform-mediated invocation as implemented while keeping live connectivity and remote model discovery deferred. +- Run artifact protocol docs should show browser download as implemented while keeping platform-to-run download, browser upload, external object storage, presigned URLs, and production throttling as future work. +- Platform API route docs should remove already-implemented config diff/file dispatch from deferred route groups. +- Handler Swagger comments for artifact/log metadata should not claim implemented chunk ingest or durable log ingest remains deferred. +- Plugin README should no longer describe marketplace, hosted plugin pages, or real lifecycle execution as future OpenSpec work when those flows now exist in bounded platform-mediated form. + +## Validation + +- Run a focused stale-marker scan for the corrected files. +- Run `scripts/check-structure.sh`. +- Run `openspec validate sync-implemented-docs-and-comments --strict`. + +No browser walkthrough is required because this change does not edit frontend pages or visual behavior. diff --git a/openspec/changes/sync-implemented-docs-and-comments/proposal.md b/openspec/changes/sync-implemented-docs-and-comments/proposal.md new file mode 100644 index 0000000..e0b6fdf --- /dev/null +++ b/openspec/changes/sync-implemented-docs-and-comments/proposal.md @@ -0,0 +1,28 @@ +## Why + +Recent implementation changes completed platform metrics, config write/file dispatch, plugin bridge execution, mediated AI invocation, artifact browser download, and log/artifact transport behavior, but several route catalogs, protocol notes, frontend contracts, and handler comments still describe those capabilities as gaps or deferred work. Those stale references make it harder to tell which features are actually missing and which are already implemented. + +## What Changes + +- Update documentation and handler comments so implemented APIs are described as implemented. +- Preserve explicit future-work boundaries for live external AI connectivity, remote model discovery, external storage adapters, browser upload, platform-to-run download, production throttling, server-side log filters, package acquisition, and remote plugin hosting. +- Keep this change documentation-only; it does not add routes, runtime behavior, UI behavior, dependencies, or schema-breaking changes. + +## Capabilities + +### New Capabilities +- `implemented-documentation-sync`: Keeps implementation-facing documentation and generated API comments aligned with completed platform/run/frontend/plugin capabilities. + +### Modified Capabilities +- None. + +## Impact + +- Affected documentation and comments: + - `platform/api/routes.md` + - `platform/protocol/ai-provider-contracts.md` + - `run/protocol/artifact.md` + - `platform_web/api/contracts.md` + - `plugins/README.md` + - Swagger comments in `platform/api/resource_handlers.go` +- No API, DTO, service, repository, frontend runtime, plugin SDK, or run executor behavior changes. diff --git a/openspec/changes/sync-implemented-docs-and-comments/specs/implemented-documentation-sync/spec.md b/openspec/changes/sync-implemented-docs-and-comments/specs/implemented-documentation-sync/spec.md new file mode 100644 index 0000000..6273db0 --- /dev/null +++ b/openspec/changes/sync-implemented-docs-and-comments/specs/implemented-documentation-sync/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Documentation Reflects Implemented Capabilities + +Implementation-facing documentation and API comments SHALL describe completed platform, run, frontend, and plugin capabilities as implemented when route registrations, clients, and tests already exist for those capabilities. + +#### Scenario: Previously deferred route is implemented + +- **GIVEN** a route or capability is listed in implemented route catalogs or has active client/API tests +- **WHEN** documentation or handler comments describe that same route or capability +- **THEN** they SHALL identify it as implemented instead of as a gap, placeholder, or deferred behavior. + +#### Scenario: Future work remains explicit + +- **GIVEN** a related capability is still intentionally out of scope +- **WHEN** documentation is synchronized +- **THEN** it SHALL keep that capability listed as future work without implying current implementation. + +#### Scenario: Documentation-only synchronization + +- **GIVEN** stale documentation is corrected +- **WHEN** the change is implemented +- **THEN** it SHALL NOT introduce runtime behavior, API contract, frontend page, plugin SDK, or run executor changes. diff --git a/openspec/changes/sync-implemented-docs-and-comments/tasks.md b/openspec/changes/sync-implemented-docs-and-comments/tasks.md new file mode 100644 index 0000000..47c186a --- /dev/null +++ b/openspec/changes/sync-implemented-docs-and-comments/tasks.md @@ -0,0 +1,16 @@ +## 1. OpenSpec Artifacts + +- [x] 1.1 Create proposal, design, spec, and tasks artifacts for a documentation-only synchronization change. +- [x] 1.2 Validate the new change with `openspec validate sync-implemented-docs-and-comments --strict`. + +## 2. Documentation Synchronization + +- [x] 2.1 Update platform API route documentation to remove stale deferred/gap language for implemented metrics, config diff, file dispatch, log ingest/query, artifact chunks, browser artifact download, plugin bridge execution, and mediated AI invocation while preserving real future-work boundaries. +- [x] 2.2 Update AI provider, run artifact, frontend API, and plugin README documentation to match implemented behavior. +- [x] 2.3 Update stale handler Swagger comments for artifact and log stream metadata. + +## 3. Verification + +- [x] 3.1 Run focused stale-marker scans for the corrected files. +- [x] 3.2 Run `scripts/check-structure.sh`. +- [x] 3.3 Run `openspec validate sync-implemented-docs-and-comments --strict` after implementation. diff --git a/openspec/changes/verify-current-platform-e2e-baseline/.openspec.yaml b/openspec/changes/verify-current-platform-e2e-baseline/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/verify-current-platform-e2e-baseline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/verify-current-platform-e2e-baseline/design.md b/openspec/changes/verify-current-platform-e2e-baseline/design.md new file mode 100644 index 0000000..c4788fa --- /dev/null +++ b/openspec/changes/verify-current-platform-e2e-baseline/design.md @@ -0,0 +1,85 @@ +## Context + +The architecture queue now needs a proof-oriented checkpoint. Earlier changes added platform APIs, durable storage, run control/job/log/artifact channels, platform_web pages, plugin registry/bridge contracts, and AI-provider management, but individual completion evidence does not answer whether the current integrated platform is operational or still relying on local fallback/demo behavior. + +This change is intentionally verification-only. It should run the current system, inspect the exposed behavior, and produce a clear proof report that classifies each required flow as: + +- `real`: backed by platform/run/plugin behavior with executable evidence and no demo fallback needed. +- `partial`: some real behavior exists, but a documented missing piece prevents full operational proof. +- `demo-only`: the visible behavior is static seed/local fallback data or simulated behavior that cannot prove real platform operation. +- `blocked`: verification cannot run because of a reproducible environment, tooling, credential, or dependency blocker. + +## Goals / Non-Goals + +**Goals:** + +- Verify the required first-party frontend areas: 首页、服务器管理、插件市场、用户管理、AI 提供商管理. +- Verify platform APIs are backed by current storage and service behavior rather than only frontend seed data. +- Verify run-mediated lifecycle behavior for create/install, start, stop, job ack/result, and state projection. +- Verify durable log history survives through the current log ingest/query path and remains independent from artifact/file transfer work. +- Verify artifact download/upload and file dispatch use platform-mediated contracts with bounded transfer evidence. +- Verify game management plugin manifests, SDK bridge requests, AI requests, and file/config/log/run capabilities do not bypass platform authorization. +- Produce a proof report with command output references, browser walkthrough notes, and gap classifications. + +**Non-Goals:** + +- No implementation of missing product behavior. +- No redesign of platform_web pages or visual style. +- No new game plugin lifecycle implementation beyond verifying current behavior. +- No billing, cloud host sales, agent-provider/cloud-provider workflows, unrelated SaaS marketplace features, or provider marketplace behavior. +- No direct plugin-to-run, browser-to-run, raw host path, raw socket, raw credential, or raw AI-key exposure. + +## Decisions + +### Decision 1: Verification produces an explicit proof report + +The implementation should create or update a baseline proof report inside the change that lists every required flow, its classification, the evidence command or browser step, and any follow-up OpenSpec recommendation. + +Alternative considered: rely only on test pass/fail output. Rejected because a passing unit suite does not show whether user-visible workflows are real, partial, demo-only, or blocked. + +### Decision 2: Browser walkthrough is required for visible first-party areas + +The proof must open the frontend in a browser and walk through 首页、服务器管理、插件市场、用户管理、AI 提供商管理 plus the plugin/server detail surfaces needed to prove lifecycle, logs, artifacts, config, AI, and bridge behavior. If the walkthrough cannot run, the report must classify the affected flows as blocked with exact commands and errors. + +Alternative considered: use server-side rendered tests only. Rejected because the queue requires browser acceptance for frontend-facing proof. + +### Decision 3: Real-vs-demo classification is based on backing behavior + +Frontend pages that render local seed data, local fallback state, or simulated completion without platform/run evidence should be classified as `demo-only` or `partial`, even if they look complete. Real classification requires platform API responses, run/job/log/artifact evidence, and plugin boundary checks where applicable. + +Alternative considered: classify by UI completeness. Rejected because the user specifically needs to know whether functionality is real or demo-only. + +### Decision 4: Channel isolation is verified through concurrent or adjacent operations + +The log/artifact/file proof should include evidence that durable log ingest/control/job behavior remains independent from artifact or file operations. The baseline may use existing tests or a local smoke script if they demonstrate the isolation requirement without adding product behavior. + +Alternative considered: document channel isolation from architecture alone. Rejected because this change is about current executable reality. + +### Decision 5: Follow-up gaps become backlog recommendations, not fixes + +When a flow is partial, demo-only, or blocked, the report should identify the smallest follow-up OpenSpec needed to make it real. This change stops at proof and recommendations unless the user explicitly asks to implement a follow-up. + +Alternative considered: fix discovered gaps immediately. Rejected because the prompt requires stopping after this OpenSpec is ready and the implementation scope should remain verification-only. + +## Risks / Trade-offs + +- [Risk] Environment blockers can hide real behavior. Mitigation: record exact blocker commands and classify only affected flows as blocked. +- [Risk] Existing local fallback data may make pages appear operational. Mitigation: require API/run evidence before assigning `real`. +- [Risk] End-to-end setup may be slower than unit tests. Mitigation: tasks define a repeatable command sequence and allow narrower proof scripts when they cover the same contracts. +- [Risk] Verification may discover many gaps. Mitigation: prioritize follow-up recommendations by required first-party area and channel boundary risk. + +## Migration Plan + +1. Add the proof report structure and any small verification scripts or fixtures required to run the baseline. +2. Run platform, run, plugin, and frontend verification commands. +3. Start the local stack needed for platform_web browser walkthrough and platform/run integration checks. +4. Walk through required frontend areas and record whether data/actions are API-backed, run-backed, local fallback, or blocked. +5. Classify every required flow and list follow-up OpenSpec recommendations for non-real flows. + +Rollback is simple before implementation closes: remove the proof report and any verification-only scripts added by this change. + +## Open Questions + +- Whether the implementation should use docker-compose services or in-process test servers as the primary local stack for proof. +- Whether the final proof report should live only under this change or be promoted into persistent project documentation after acceptance. +- Whether blocked browser tooling should be resolved by in-app browser automation, Chrome automation, or a project-owned Playwright acceptance suite. diff --git a/openspec/changes/verify-current-platform-e2e-baseline/proof-report.md b/openspec/changes/verify-current-platform-e2e-baseline/proof-report.md new file mode 100644 index 0000000..06dfdb2 --- /dev/null +++ b/openspec/changes/verify-current-platform-e2e-baseline/proof-report.md @@ -0,0 +1,97 @@ +# Current Platform E2E Baseline Proof Report + +Date: 2026-07-08 + +## Classification Legend + +- `real`: backed by executable platform/run/plugin evidence in this baseline. +- `partial`: some real backing exists, but a missing integration step prevents full end-to-end proof. +- `demo-only`: visible behavior is local fallback, seed data, or simulated behavior. +- `blocked`: verification could not run because of a reproducible environment, service, auth, or tooling blocker. + +## Command Evidence + +| Area | Command | Result | Evidence | +| --- | --- | --- | --- | +| Platform | `cd platform && go test ./... -count=1` | Passed | Packages `api`, `config`, `domain`, `dto`, `model`, `repo`, `service`, and `validator` passed. | +| Run | `cd run && go test ./... -count=1` | Passed | Packages `api`, `config`, `protocol`, `runtime`, and `spool` passed. | +| Plugins | `cd plugins && npm run typecheck` | Passed | TypeScript completed with no errors. | +| Plugins | `cd plugins && npm run test` | Passed | Vitest reported 1 test file / 10 tests passed. | +| Plugins | `cd plugins && npm run validate:manifest` | Passed after sandbox retry | Initial sandbox run failed with `listen EPERM` for the `tsx` IPC pipe; escalated run printed `validated examples/dev-game-plugin/manifest.json`. | +| Frontend | `cd platform_web && npm run typecheck` | Passed | TypeScript completed with no errors. | +| Frontend | `cd platform_web && npm test` | Passed | Vitest reported 11 test files / 47 tests passed. | +| Frontend | `cd platform_web && npm run build` | Passed | Vite built `dist/` assets successfully. | +| Browser server | `cd platform_web && VITE_ENABLE_LOCAL_AUTH_FALLBACK=true npm run dev -- --port 5173` | Passed after sandbox retry | Initial sandbox run failed with `listen EPERM 127.0.0.1:5173`; escalated run served `http://127.0.0.1:5174/`. | + +## Browser Walkthrough Evidence + +The browser walkthrough used `http://127.0.0.1:5174/` with `VITE_ENABLE_LOCAL_AUTH_FALLBACK=true`. + +| Page / Flow | Browser Result | Classification | Follow-up | +| --- | --- | --- | --- | +| Auth entry | Login screen showed `本地回退可用` and `进入本地回退工作台`, proving the auth API was unavailable in this local browser stack. | `partial` | Use a local stack that starts platform API, run, and frontend together so browser auth can prove real sessions. | +| 服务器管理 | Local fallback entered `#/servers` as `Local Server Operator`; page showed `服务器列表加载失败` with `path /api/v1/jobs was not found`. | `partial` | Fix local browser stack/API routing and rerun server lifecycle walkthrough against real platform APIs. | +| 首页 | Navigating to `#/home` as fallback server admin redirected/rendered the server workspace rather than 首页. | `blocked` | Verify with a real platform-admin session. | +| 插件市场 | Navigating to `#/plugins` as fallback server admin redirected/rendered the server workspace. | `blocked` | Verify with a real platform-admin session and API-backed plugin marketplace data. | +| 用户管理 | Navigating to `#/users` as fallback server admin redirected/rendered the server workspace. | `blocked` | Verify with a real platform-admin session. | +| AI 提供商管理 | Navigating to `#/aiProviders` as fallback server admin redirected/rendered the server workspace. | `blocked` | Verify with a real platform-admin session and API-backed provider data. | +| Personal/account settings | `#/profile` rendered `个人设置`, `本地会话`, editable profile fields, palette/background controls, and logout. | `demo-only` | Rerun with a real API-authenticated user to prove profile/theme persistence. | +| Visible unsafe fields | Browser-visible fallback pages did not expose raw host paths, run credentials, direct sockets, raw AI keys, or plugin-owned transport details. | `partial` | Repeat on real server/plugin/detail pages after API-backed browser stack works. | + +## Platform API and Storage Matrix + +| Flow | Classification | Evidence | Follow-up | +| --- | --- | --- | --- | +| Auth/session API | `real` for handler/service tests; `partial` for browser | `platform` test suite passed; browser auth used local fallback because API was unavailable. | Add a repeatable local full-stack browser auth smoke path. | +| Users API | `real` for platform tests; `blocked` in browser | `platform` test suite passed; fallback role cannot open 用户管理. | Browser verify with real platform-admin session. | +| Server instances and lifecycle APIs | `real` for platform tests; `partial` in browser | `platform` tests include server lifecycle/API packages; browser server page failed `/api/v1/jobs`. | Fix local stack/proxy and rerun create/start/stop browser flow. | +| Plugin registry and marketplace APIs | `real` for platform/plugin tests; `blocked` in browser | `platform` and `plugins` tests passed; fallback role could not open 插件市场. | Browser verify marketplace with API-backed admin session. | +| AI provider APIs | `real` for platform tests; `blocked` in browser | `platform` validator/dto/API tests passed and protect raw keys; fallback role could not open AI 提供商管理. | Browser verify create/test/status with API-backed admin session. | +| Jobs API | `real` for platform/run tests; `partial` in browser | `platform` and `run` tests passed; browser surfaced `path /api/v1/jobs was not found`. | Start compatible platform API with frontend proxy for browser proof. | +| Logs API/storage | `partial` | `platform` and `run` tests passed for log-related packages, but this baseline did not prove restart-surviving log history in a live stack. | Add local durable log history smoke with restart/query evidence. | +| Artifacts API/storage | `real` for chunk/checksum/download tests; `partial` for live stack | `platform` and `run` tests passed; artifact download tests prove platform-mediated references and safe fields. No live browser artifact download was reachable. | Rerun browser server detail artifact flow with real API stack. | +| Config diff/write and file dispatch | `real` for platform tests; `partial` for browser | `platform` tests passed; browser could not reach server detail/config due server list API failure. | Rerun config diff/write dispatch in browser with real server instance. | +| Storage durability | `partial` | Package tests passed, but this baseline did not run a database restart or cross-process durability smoke. | Add explicit MySQL-backed create/restart/query proof in local debug workspace. | + +## Run Channel Matrix + +| Flow | Classification | Evidence | Follow-up | +| --- | --- | --- | --- | +| Control hello/heartbeat | `real` at package level | `cd run && go test ./... -count=1` passed `api` and `protocol` packages. | Include a live run-to-platform heartbeat in local debug workspace. | +| Job claim/ack/progress/result | `real` at package level | `run` package tests passed for API/runtime behavior. | Add integrated platform/run smoke with actual queued job. | +| Lifecycle install/start/stop executor | `real` at package level; `partial` end to end | `run` runtime tests passed; browser could not prove create/start/stop because platform API stack was unavailable. | Implement/verify real game plugin lifecycle proof. | +| Log spool/ingest acknowledgement | `real` at package level; `partial` for durable history | `run` spool tests passed; no live restart/query proof ran. | Add durable log history smoke. | +| Artifact chunk/resume/checksum | `real` at package level | `run` artifact API tests passed and platform artifact tests passed. | Add browser artifact download walkthrough against a real completed artifact. | +| Channel isolation | `partial` | Prior package-level tests passed, but this baseline did not run concurrent live artifact/file transfer alongside heartbeat/job/log traffic. | Implement hardening proof for log/artifact channel isolation. | + +## Plugin Boundary Matrix + +| Flow | Classification | Evidence | Follow-up | +| --- | --- | --- | --- | +| Manifest schema validation | `real` | `npm run validate:manifest` passed for `examples/dev-game-plugin/manifest.json` after sandbox retry. | +| Unsafe manifest rejection | `real` | `npm run test` passed; tests include rejection of direct run and raw AI key requests. | +| SDK bridge request envelopes | `real` | `npm run typecheck` and `npm run test` passed; tests cover typed bridge request envelopes without owning transport. | +| AI invocation request boundaries | `real` at SDK/manifest level | Tests and schema include `ai.invoke`; plugin docs state provider keys remain platform-mediated. | +| Real multi-instance game server operation | `partial` | Plugin manifests and SDK can request lifecycle/log/artifact/AI capabilities, but this baseline did not prove a real plugin creating/managing multiple live server instances through platform/run. | Generate/implement `implement-real-game-plugin-lifecycle-proof`. | + +## Required First-Party Area Summary + +| Area | Current Baseline Classification | Reason | +| --- | --- | --- | +| 首页 | `blocked` | Fallback server-admin browser session redirected/rendered server workspace; no platform-admin browser proof. | +| 服务器管理 | `partial` | API/package evidence exists, but browser flow failed `/api/v1/jobs` without a live platform API stack. | +| 插件市场 | `blocked` | Fallback browser session could not access platform-admin route; package/API tests pass. | +| 用户管理 | `blocked` | Fallback browser session could not access platform-admin route; package/API tests pass. | +| AI 提供商管理 | `blocked` | Fallback browser session could not access platform-admin route; package/API tests pass. | + +## Follow-up OpenSpec Recommendations + +1. `implement-real-game-plugin-lifecycle-proof`: prove one game management plugin can create and manage multiple server instances only through platform-mediated platform/run contracts. +2. `implement-local-debug-workspace`: provide one repeatable command path that starts platform, run, frontend, storage, and plugin fixtures for browser/API proof without relying on local fallback. +3. `implement-browser-acceptance-suite`: automate browser coverage for 首页、服务器管理、插件市场、用户管理、AI 提供商管理 and plugin/server detail operations. +4. `harden-log-artifact-channel-isolation`: run concurrent artifact/file operations while proving control heartbeat, job ack/result, and durable log ingest continue independently. +5. Reopen/finish `fix-env-profile-settings` task `3.5` in a working browser/dev-server session to close the older guard. + +## Bottom Line + +The codebase has substantial real backend/run/plugin capability evidence from tests, but the current local browser proof is not yet a real platform-wide E2E baseline. The visible browser experience fell back to a local server-admin session, server management failed on `/api/v1/jobs`, and platform-admin first-party areas could not be reached. The next architecture work should make the real full-stack proof path repeatable, then prove a game plugin lifecycle against it. diff --git a/openspec/changes/verify-current-platform-e2e-baseline/proposal.md b/openspec/changes/verify-current-platform-e2e-baseline/proposal.md new file mode 100644 index 0000000..730bb4c --- /dev/null +++ b/openspec/changes/verify-current-platform-e2e-baseline/proposal.md @@ -0,0 +1,27 @@ +## Why + +The architecture stream has implemented many platform, run, frontend, and plugin capabilities, but the current project still needs an evidence-driven baseline that proves which first-party flows are real, partial, demo-only, or blocked. The next change should verify the existing product surface before adding more product scope, so follow-up work can target gaps instead of assuming the platform is operational end to end. + +## What Changes + +- Add a baseline verification change that audits current functionality across `platform/`, `run/`, `platform_web/`, and `plugins/`. +- Classify required first-party areas and system flows as `real`, `partial`, `demo-only`, or `blocked` using executable evidence. +- Require API/run/plugin proof commands, frontend build/test commands, structure validation, and browser walkthrough evidence. +- Require a proof report that covers 首页、服务器管理、插件市场、用户管理、AI 提供商管理, run-mediated lifecycle, durable logs, artifact/file transfer, and plugin capability boundaries. +- Keep this change verification-only: it records current reality and follow-up gaps, but does not implement product fixes. + +## Capabilities + +### New Capabilities + +- `current-platform-e2e-baseline`: Evidence-based classification of current platform behavior across backend APIs, run executor channels, frontend workflows, and game management plugin boundaries. + +### Modified Capabilities + +- None. + +## Impact + +- Affects OpenSpec verification artifacts and may add proof scripts, reports, or test harnesses under the matching roots. +- Does not add billing, cloud host sales, agent-provider/cloud-provider workflows, unrelated marketplace features, direct browser/plugin access to run, raw host path exposure, or raw AI key exposure. +- Produces follow-up implementation recommendations only after evidence exists. diff --git a/openspec/changes/verify-current-platform-e2e-baseline/specs/current-platform-e2e-baseline/spec.md b/openspec/changes/verify-current-platform-e2e-baseline/specs/current-platform-e2e-baseline/spec.md new file mode 100644 index 0000000..0e6154c --- /dev/null +++ b/openspec/changes/verify-current-platform-e2e-baseline/specs/current-platform-e2e-baseline/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: Baseline proof report classifies current behavior +The repository SHALL provide an evidence-based baseline proof report that classifies current functionality across platform, run, platform_web, and plugins as `real`, `partial`, `demo-only`, or `blocked`. + +#### Scenario: Required flow is classified +- **WHEN** the baseline verification runs for a required first-party or channel flow +- **THEN** the report MUST record the classification, evidence source, command or browser step, and follow-up recommendation when the classification is not `real` + +#### Scenario: Demo fallback is visible +- **WHEN** a frontend or plugin flow renders local seed data, local fallback data, or simulated completion without platform/run evidence +- **THEN** the report MUST classify that flow as `demo-only` or `partial` instead of `real` + +#### Scenario: Verification is blocked +- **WHEN** a flow cannot be verified because a local service, browser, dependency, credential, or environment condition fails +- **THEN** the report MUST classify the flow as `blocked` and include the exact command or browser action and error that prevented verification + +### Requirement: First-party frontend areas are browser verified +The baseline SHALL include a browser walkthrough for 首页、服务器管理、插件市场、用户管理、AI 提供商管理 and the personal/account navigation needed to identify authenticated workspace behavior. + +#### Scenario: Browser walkthrough covers first-party pages +- **WHEN** the browser walkthrough is executed +- **THEN** it MUST visit every required first-party area and record whether each page is backed by platform APIs, local fallback data, or inaccessible state + +#### Scenario: Browser walkthrough checks operational actions +- **WHEN** the walkthrough reaches server, plugin, user, AI provider, config, log, artifact, or plugin bridge actions +- **THEN** it MUST record whether the action uses platform-mediated APIs and whether unsafe raw host paths, run credentials, direct sockets, or raw AI keys are absent from visible UI state + +### Requirement: Platform API and storage baseline is verified +The baseline SHALL verify that platform API behavior for authentication/session, server instances, users, plugins, AI providers, jobs, logs, artifacts, and file/config dispatch is backed by the current service and storage implementation. + +#### Scenario: Platform API command evidence exists +- **WHEN** platform baseline verification runs +- **THEN** it MUST execute platform tests or smoke commands that cover the required API areas and record pass/fail evidence in the proof report + +#### Scenario: Storage behavior is classified +- **WHEN** platform data is created, updated, queried, or restarted in the baseline +- **THEN** the report MUST classify whether that data is durable, in-memory only, seed data, or blocked from verification + +### Requirement: Run-mediated lifecycle and channel behavior is verified +The baseline SHALL verify current run-mediated server lifecycle, job, log ingest, artifact transfer, and file dispatch behavior without exposing run internals to browsers or plugins. + +#### Scenario: Lifecycle path is proven +- **WHEN** the baseline verifies server lifecycle behavior +- **THEN** it MUST exercise or cite executable evidence for create/install, start, stop, job claim/ack/result, and server state projection through platform-mediated contracts + +#### Scenario: Log history is proven +- **WHEN** the baseline verifies log ingest +- **THEN** it MUST prove whether log history is durable and queryable after batch acknowledgement or classify the missing durability as `partial`, `demo-only`, or `blocked` + +#### Scenario: Artifact and file channels are proven +- **WHEN** the baseline verifies artifact transfer and file/config dispatch +- **THEN** it MUST prove bounded chunk/checksum or dispatch behavior and record whether these operations remain separate from control heartbeat, job ack/result, and log ingest + +### Requirement: Plugin capability boundaries are verified +The baseline SHALL verify game management plugin manifests, SDK bridge requests, plugin page execution, AI invocation requests, and file/config/log/run capabilities stay platform-mediated. + +#### Scenario: Plugin boundary proof exists +- **WHEN** plugin baseline verification runs +- **THEN** it MUST execute plugin validation/type/test commands and record evidence that plugin code does not include platform auth storage, raw AI keys, direct run sockets, raw host paths, or direct run transport + +#### Scenario: Plugin flow cannot operate real server behavior +- **WHEN** a plugin can render or request a capability but cannot complete a real platform/run-backed operation +- **THEN** the report MUST classify that plugin flow as `partial` or `demo-only` and recommend the follow-up OpenSpec needed to make it real diff --git a/openspec/changes/verify-current-platform-e2e-baseline/tasks.md b/openspec/changes/verify-current-platform-e2e-baseline/tasks.md new file mode 100644 index 0000000..579d364 --- /dev/null +++ b/openspec/changes/verify-current-platform-e2e-baseline/tasks.md @@ -0,0 +1,59 @@ +## 1. Baseline Report Structure + +- [x] 1.1 Add a proof report under `openspec/changes/verify-current-platform-e2e-baseline/` that lists every required flow, classification (`real`, `partial`, `demo-only`, `blocked`), evidence, and follow-up recommendation. +- [x] 1.2 Define the required flow matrix for platform APIs, run channels, platform_web pages, and plugin capability boundaries. +- [x] 1.3 Ensure non-real classifications identify the smallest follow-up OpenSpec needed to close the gap. + +## 2. Platform and Storage Verification + +- [x] 2.1 Run `cd platform && go test ./... -count=1` and record evidence. +- [x] 2.2 Run platform API/storage smoke coverage for auth/session, users, server instances, plugin marketplace, AI providers, jobs, logs, artifacts, config diff/write, and file dispatch; record exact command(s) used. +- [x] 2.3 Classify each platform API area as real, partial, demo-only, or blocked, including whether data is durable, in-memory, seed-only, or unavailable. + +## 3. Run and Channel Verification + +- [x] 3.1 Run `cd run && go test ./... -count=1` and record evidence. +- [x] 3.2 Verify run control hello/heartbeat, job claim/ack/progress/result, lifecycle install/start/stop execution, log spool/ingest acknowledgement, and artifact chunk/resume/checksum behavior; record exact command(s) used. +- [x] 3.3 Verify or classify whether artifact/file transfer remains independent from control heartbeat, job ack/result, and durable log ingest. + +## 4. Plugin Verification + +- [x] 4.1 Run `cd plugins && npm run typecheck && npm run test && npm run validate:manifest` and record evidence. +- [x] 4.2 Verify plugin manifests, SDK bridge requests, plugin page execution requests, AI invocation requests, and file/config/log/run capability payloads remain platform-mediated. +- [x] 4.3 Classify whether current game management plugins can perform real multi-instance server operations or only render/request demo or partial behavior. + +## 5. Frontend and Browser Walkthrough + +- [x] 5.1 Run `cd platform_web && npm run typecheck && npm test && npm run build` and record evidence. +- [x] 5.2 Start the required local stack for browser verification. Record the exact command(s), such as `cd platform_web && VITE_ENABLE_LOCAL_AUTH_FALLBACK=true npm run dev -- --port 5173`, plus any platform/run service commands needed for API-backed proof. +- [x] 5.3 In a browser, walk through 首页、服务器管理、插件市场、用户管理、AI 提供商管理, and personal/account navigation. Record for each page whether it is API-backed, local fallback, seed data, inaccessible, or blocked. +- [x] 5.4 In the browser, exercise or inspect server lifecycle controls, plugin marketplace actions, user create/status actions, AI provider create/test/status actions, config diff/write dispatch, log history, artifact download/file transfer, and plugin bridge actions where available. +- [x] 5.5 Verify visible UI state does not expose raw host paths, run credentials, direct sockets, raw AI keys, or plugin-owned transport details. + +## 6. Baseline Classification and Queue Handoff + +- [x] 6.1 Complete the proof report with command output summaries and browser walkthrough notes. +- [x] 6.2 Identify follow-up implementation OpenSpecs for every partial, demo-only, or blocked required flow. +- [x] 6.3 Run `scripts/check-structure.sh` and record evidence. +- [x] 6.4 Run `openspec validate verify-current-platform-e2e-baseline --strict` and record evidence. +- [x] 6.5 Update `openspec/changes/architecture-delivery-stream/delivery-plan.md` and `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` only after implementation evidence exists, then stop without starting another backlog item. + +## Evidence + +- 2026-07-08: `cd platform && go test ./... -count=1` passed; packages `api`, `config`, `domain`, `dto`, `model`, `repo`, `service`, and `validator` reported `ok`. +- 2026-07-08: `cd run && go test ./... -count=1` passed; packages `api`, `config`, `protocol`, `runtime`, and `spool` reported `ok`. +- 2026-07-08: `cd plugins && npm run typecheck` passed. +- 2026-07-08: `cd plugins && npm run test` passed; Vitest reported 1 file / 10 tests. +- 2026-07-08: `cd plugins && npm run validate:manifest` initially failed inside the sandbox with `listen EPERM` for the `tsx` IPC pipe, then passed with approved escalation and printed `validated examples/dev-game-plugin/manifest.json`. +- 2026-07-08: `cd platform_web && npm run typecheck` passed. +- 2026-07-08: `cd platform_web && npm test` passed; Vitest reported 11 files / 47 tests. +- 2026-07-08: `cd platform_web && npm run build` passed; Vite built `dist/` assets. +- 2026-07-08: Browser walkthrough required `cd platform_web && VITE_ENABLE_LOCAL_AUTH_FALLBACK=true npm run dev -- --port 5173`; sandbox run failed with `listen EPERM 127.0.0.1:5173`, then approved escalation served the frontend at `http://127.0.0.1:5174/`. +- 2026-07-08: Browser auth page showed `本地回退可用` and `进入本地回退工作台`, proving the local browser stack did not have real auth API backing. +- 2026-07-08: Browser fallback server workspace at `#/servers` showed `服务器列表加载失败` with `path /api/v1/jobs was not found`. +- 2026-07-08: Browser routes `#/home`, `#/plugins`, `#/users`, and `#/aiProviders` rendered/redirected to the fallback server workspace for the server-admin fallback user, so platform-admin first-party areas remain browser-blocked in this baseline. +- 2026-07-08: Browser `#/profile` rendered `个人设置` as `本地会话` with editable profile/theme controls; this proves local fallback UI only, not API persistence. +- 2026-07-08: `openspec/changes/verify-current-platform-e2e-baseline/proof-report.md` records the classification matrix and follow-up recommendations. +- 2026-07-08: `scripts/check-structure.sh` passed. +- 2026-07-08: `openspec validate verify-current-platform-e2e-baseline --strict` reported the change is valid; PostHog telemetry flush failed due restricted DNS and did not affect validation. +- 2026-07-08: Updated `openspec/changes/architecture-delivery-stream/delivery-plan.md` to mark `verify-current-platform-e2e-baseline` complete and `fix-env-profile-settings` blocked; updated `openspec/changes/architecture-delivery-stream/NEXT_CHANGE.md` to point at generating `implement-real-game-plugin-lifecycle-proof`. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bb3cd3f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1374 @@ +{ + "name": "browser", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.199" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.199.tgz", + "integrity": "sha512-fET9rfYR2DgjVG4Yri02Q6YL+SWBlcrsd3Dx96CpZSM50W4Jqh1sEC06hXIEHL0uDZ/kS//Y2uPpCQ9GQqh8Bg==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.199", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.199", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.199", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.199", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.199", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.199", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.199", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.199" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.199.tgz", + "integrity": "sha512-0813IEsPlA3GQZ86CGmRPh/2DY/iEuBkXV7CRAj55sQ30oIm+N/BbXzI/jH7WiWsHh3pCsRx65dbswf6jA5Uuw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.199.tgz", + "integrity": "sha512-iUenoE7UbWFfYjdfaeJkPl4qpXajPcRw8SzMYn0PK2LsBE5SJTfEdZyvtypNaKiWzsCCaU4MV1yzR8S7i/wZQg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.199.tgz", + "integrity": "sha512-wJ4GJCwrdVv4TWTiEwh2gUjrddIg+oMhoUcfXhtZgZeqXfGzxVWJa3HudX5xsVq/wktvM65CfdYly2blVBVG8Q==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.199.tgz", + "integrity": "sha512-mcqHuNHsA2V589rt0JpCsweS8kZ8LkKZi0qmMLqN3oZz+ar4DsuDjLDaNI1C5f+W/nz5G/st+2W3ZCJubeOqVQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.199.tgz", + "integrity": "sha512-nNLk8KcM33AYQ0P1cNK72kE14iTIuM5RTr0CrQm40FwdrPuVZ/se6KggpE3eje8hFYJu/1WrJqcZxtJSL6qnPg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.199.tgz", + "integrity": "sha512-wr7IIQ9d9H7Tt79Mn4ga8iNLzsvoPnAbBtsnUVBjgcAxKWrJ+QV2wCsowhhSc7DWNueiurdxNsLIwd07BzhfCA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.199.tgz", + "integrity": "sha512-EQFMATdpp6XXYXw6Ih83BFCDj2PXqMYkM6nq3OrOVUF19xTe6o5dWhGzZ6TQfauvWf2BHFIfBDzZz82m8ffV8w==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.199", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.199.tgz", + "integrity": "sha512-6W2djU/NnOCY4NEdDjtZ3LIzFFQMT2OH9m5y5Hc4bko3KIXr0WHLrCJdgGiVycOWEd03OEsC4uYIIYIeueBAHw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmmirror.com/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6beb461 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.199" + } +} diff --git a/platform/.env b/platform/.env new file mode 100644 index 0000000..00fae5e --- /dev/null +++ b/platform/.env @@ -0,0 +1,20 @@ +PLATFORM_ADDR=:8080 + +# Platform metadata storage: +# file -> local durable JSON snapshot at PLATFORM_METADATA_PATH. +# mysql -> MySQL metadata snapshot table using PLATFORM_MYSQL_DSN. +# memory -> tests/disposable local runs only. +PLATFORM_STORAGE_BACKEND=mysql + +# To use local MySQL metadata, change PLATFORM_STORAGE_BACKEND above from file to mysql, +# then uncomment and adjust: +PLATFORM_MYSQL_DSN=platform:5SZGTpX68YryfmHH@tcp(bt.npc0.com:30306)/platform?parseTime=true + +PLATFORM_DATA_DIR=.platform-data +PLATFORM_METADATA_PATH=.platform-data/metadata.json + +# Log bodies are intentionally separate from metadata. +# MySQL metadata still defaults logs to segmented files; do not put hundreds/thousands +# of server log lines into MySQL rows. +PLATFORM_LOG_BODY_BACKEND=file +PLATFORM_LOG_DIR=.platform-data/logs diff --git a/platform/.env.example b/platform/.env.example new file mode 100644 index 0000000..2392e69 --- /dev/null +++ b/platform/.env.example @@ -0,0 +1,20 @@ +PLATFORM_ADDR=:8080 + +# Platform metadata storage: +# file -> local durable JSON snapshot at PLATFORM_METADATA_PATH. +# mysql -> MySQL metadata snapshot table using PLATFORM_MYSQL_DSN. +# memory -> tests/disposable local runs only. +PLATFORM_STORAGE_BACKEND=file + +# To use local MySQL metadata, change PLATFORM_STORAGE_BACKEND above from file to mysql, +# then uncomment and adjust: +# PLATFORM_MYSQL_DSN=platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true + +PLATFORM_DATA_DIR=.platform-data +PLATFORM_METADATA_PATH=.platform-data/metadata.json + +# Log bodies are intentionally separate from metadata. +# MySQL metadata still defaults logs to segmented files; do not put hundreds/thousands +# of server log lines into MySQL rows. +PLATFORM_LOG_BODY_BACKEND=file +PLATFORM_LOG_DIR=.platform-data/logs diff --git a/platform/.platform-data/metadata.json b/platform/.platform-data/metadata.json new file mode 100644 index 0000000..250dc7e --- /dev/null +++ b/platform/.platform-data/metadata.json @@ -0,0 +1,64 @@ +{ + "users": [ + { + "ID": "user-admin", + "DisplayName": "Operator", + "Email": "operator.local@example.test", + "Status": "active", + "Roles": [ + "platform-admin" + ], + "PasswordHash": "pbkdf2-sha256$120000$p2N32pbu2Z5GlVjTsOWWRQ$sF13RAUSaCit7pxoe2pIXUVrXA6Eimj1tvZTjDX8gjg", + "Profile": { + "AvatarURL": "", + "Phone": "13148740782", + "QQ": "602269287", + "ContactNote": "local development admin" + }, + "Theme": { + "UserID": "user-admin", + "PaletteID": "lemon-parfait", + "BackgroundPresetID": "aqua-aurora", + "BackgroundImage": "data:image/png;base64,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OUB9q4iM4cH3rtITmJT7VhV3O7DP3WS0UUVidIUUUUAFFFFMYUUUUhBRSZA6mloAKKKKBhRRRQIKKKKACiiigAooopjCiiigArJvR+8rWrLvh84+lNbky2M+iiitCAooooEFFFFAH//R2HgYsCOgqIxyA8jIrQpadxcqMwZzjFOyRuU/hWjimmNG6incXKUBuA2+lXN+VwtPKKVxiqQjbJwSAKAs0PlB4UnJqzCWxtY81WeOTIkXmp4o23b24NDBXuWqKSlqSwpaSloAWlptLQA6lptLQA6mTHETfSnVFOf3RoQMzBTsUoFOxVXJGYpcVIFNKENFwsRYpcVOIjUqwE1PMiuVlTFLtq+LY1KLdRyaXMPlM0IaeImPatJUiqUADpSuwsjOW2Y1Otr61booC5CIEFShEHQUtFFguFFFFAgooooAKKKKACiiigApaSigBaKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKYBRRRQAVFP/AKo1LUFx/q/xoQESfcH0p1Iv3R9KWgAooooAKayhxg06o2kCttNMA8tTwRQY1K7e1J5qZxSiRSM0ABjU9aTy14PpQJUIzS+YuAfWgBBGA24UojG7d60hlUHFKJVoAQRAZ560eUMbc8Cl81OtHmr0o1ADED1PSjygTnNHmr0pTKqnHrRqGgixAZ5oEQXIz1o81fypfNWjUNBPKGMZ6UGIHv0o81aBKM4o1DQPKGc5pBCAc5605pFU4pRIpo1DQb5IxjNIYc4ANO81RxS+aoGaNQ0GeTz1oERBOaeJVwTQJVbkUahoR+S233pfKan+agGaBKho1DQj8psmjym2Ed6l85M4pPNSjUNCHyXp3lNu9ql81KXzFzii7CyK/kt+tHlNVgyKOtL5i9aLsVkV/Kbr7UgjbFWDIq9e9AkX8aLsLIq7GBJxRhs49Kub1OR6UgdT360XCxT5680pLcAk1dxSbQetFwsVNzk4B6UodueatbVznFJtX0ouFipvfHU0GR8Dk1b2L6UuxPSi4WKnmOT1o8x89at7E9KNielFwsVN7560b39at7E9KNielFwsVPMk9aXe/wDeNW9ielGxPSi4WKZd/wC8aTfJ6mruxPSl2L6UXCxS3Sepoy57mr21fSjavpRcfKUct70fN71f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OEKelV7CQvaopbiOlIck5NX/ACo/SnBFHaq+rvqxe18jO57Uu0+laWBRT+rruL2pn7G7ClELntV+iq+rxF7VlIQPVxRgYpaK0hTUdiZTb3CiiitCBG4UmuPlOZX+tddKcRMfauPfl2PvVxJkW7H/AF34Vt1jaeMyk+1aryIn3jikykSUU1XV+VOadSAKKSigBaKSloAKKKKAFopKWgBaKSlpAFLSUUDFooooAWikpaAFopKKAFpaSigBc0ZpKKAFzRmkooAWikpaACiiigAooooAMCjiiigAwKOKKKADA9KOKKWgBMCjilooAOKOKSloATAowKWigBMCjA9KWigBCqntRtB6ilooATA9KXC+lFFABxRgUUUAGBRxS0UgAAelPplLnFAD6KByM0UAFFFFABRRRQAVys5/fN9a6o9K5OX/AFrfWtqRyYnoTW0HntjOAKtTWIRCynpVO3uGgOQM5qWW9eUbQMCradzNShy67lSkooqzEKWkooAWultOYFrmapXup3kBEUT7VrKpG50YeVm0d0WRepA/Gqst/ZxA73rzeS7uZv8AWOTVfnuahROpyOubxHDACka7+evSqMnie6b/AFabfxrmz1pKdkK5sSa7qL9JCKpvf3kn35Cap0U7APZ3b7xJpmKWigQYFFFHXgUDFGScCuq0fRTJi5uRx2FLoujb8XVyOOwrsQABgdBUtlJAAFG1eAKWiipGFFFFABRRRQBR1L/j0f6GvMO9enan/wAeb/Q15j3q1sY/aYtFFFMoKKKKAClpKWgAooooAKKKKALlkP3v4VsVlWI/eE1q1yVfiPYwi/diUUUVmdQUUUUAFFFFIAooooAOxrn5f9a31rfP3TXPOcu31roodTzsc9kJRSUV0HnBS0lFAC0UUUAFFFFABRRRQAUUUUAFLSUtABRRRQAUUUUALRRRQAUtJS0AFFFFABRRS0AFFFFABS0UUAKvUV2Gmf6gfWuPX7wrr9O4gH1rOZ0UvhZ1CfdFOqOI5QVJUoh7hRRRQIKKKKYwooooAKKKKACiiigAooooAKKKKQBRRRQAtFJRTAWikpaACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApobJI9KdRSAKKKKYBRRRQAUUUUgCoZ54raMyTHAFV77UILCIyTHnsK5D/Stam825ysIPyr61SX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WRBdmc8npQI0aKOKKQw5pOaWjNACfNSfNTqKBDfmpRmnUUDGnPaolJPWp+1QL1P1oEW4vu1JUcf3akoGwooooAKhn4iY1NVe6OIGoEylZEmL8TVvpUFqP3I+tWKYEZfHajzPas6S5njJyO9N+3TFMheaANPzPajeveqX20qAGXk05rpwQCnJ5FAFvfHRvSqP2ojqlKl4jOEKYJoAvb46N0dRmP8A2aPL/wBmgB+5DRmOozH/ALIoEQ/u0ASlo/Wk3J61H5S+lBiUckUASbkHemF09aPLRu1J5cfpQAu9KTzEFBjjXqKCkeASKAE81KPNWgrHSgRZoATzQOAKYbgelSZjWkzEaAJlbcoanUgAA4paQyje9FqlHneMc1evfuiqCffFWtiXuW5y2ce1W4OI+aqXHHT0q1b/AOqFJgtyamv9xvpTqa/3G+hpIb2OXPU0lKeppK60eQFFFFAgooooAKWkpaACiiigZatDida7EdBXF25xMv1rs1+6K56u56GH+AWiiisjcKKKKACiiigAooopgFFFFABRRRQAUUUUAFFFFABRRRQAUtJRQAtFFFAxG+6a59/vGugPQ1gyjDmqiRIioooqyAooooA//9bc8pNp28E1AY3RCqc1Y2DtRyBTArElE44NPErJjdzUx5X3pFI3hWFAFsdKKKWkMKKKKACiiigApaSloAKKKKACo5f9WakqKb7lAF6AYiFS0yLiMU+kNi0UUUAFFFFABRRRQAUUUUAFFFFABS0lLQIKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKWkooAWikooAWikooAWikpaACiikoAWikooAWikooAWjpzSUjnCE+1AHI3BzO596s2A+YmqUhzIx960LAfITWj2IjuW5nWNNzHAqi17bqCAx6elaRAbhuaZ5MP92oLKdnLDKcqfmrQpixRqdyjBqSmBDM/lRl/Ssm8vt0RZTgjqK1Z4VnTy26GsmXRY3+6+KEIzY7tC77jjjinCdPsbLnDc1M2gv1WT9KhbQ7ofdOaegtRm+JrLbu+bNKs7K0YRuB1qNtIvl/hzUTafer1joDUuSyFJ18s9TzTZ8Jcq6Hk96zzbXK9UNM2yr1BosO5tTzyNOob060LISz7jkjgVib3U55zS+fLnrRYVy5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AFFFFABRRRQBJbf601eqjb/66r1SUwooooJCiiigApaSloASilpKACiiigAoopaAEopaKAEooopgLRRRQMKKKKACiiigAooopCCkpaSmAUUUUAFFFFAC0UlFAC1FMMxmpaZJyhoBnPnrSU5utNrRGQdOtcpqFx585A+6Olbmo3HkQED7zdK5Trz60xn/1LtLSUtaEhS0UUAKCRyKspcMOG5qtRSsFzTSVH6GpayOnSp0ndevIpNFXNCiokmR/rUtSMKWkpaACiiigCOY4jNT2Y/dVWn/ANWauWoxEKTGtixRRS0CCiiigYUUUUAFFFFABRRRQAUUUUAFFFFABS0UUCCiiigAooooAKKKKACiiigAooooAKKKKQBRRRQAUUUUAFFFFABRRRQAUUUUwFFcvqB/0k11Arlr7/j5atKe5zYnZFSiiitziCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKzLj/WmtOsu4/wBaaTNaW5FSUUtSdAUUUUAFFFFABRRRQAUUUoVj0FMLiUVKIJD2qUWrdziglzRVoq8LVe5qQW8Y7UWIdVGbS4J6CtQRoOgp2BTsT7UyxG56CpBbyHtWjRRYXtWURav3pwtfU1cop2J52VRar3NO+zJViiiwuZkPkR+lL5EfpUtFAczIvJj9KPJj9KlooFdkflJ6UeUnpUlFAXZH5SUnlJUtFAXZF5S0eUKloosO7IvKFJ5XvU1FA02Q+VR5dWRGxqVYgOvNS2jWMJMpCMnpUq25PXiroAHSlqbmyprqV1t0HXmpQijoKfRUlpITFLRS0FCUtFFABRS0UAFFFFABRS0UAJRS0UAGKKWigBKWiloASloopAFFLRQAUtFFAwpcUuKeqk0mxpXGhc1ZihLGpYrctyelX1UKMCs27l2sNRAgwKfRRQIKKKKBBRRRQAUUUUAFQT/6s1PUM3+rNNAzJNJTj1ptaGZUi/4+3+grQrPTi8b6CtCvJxf8Rno0/hQUUUVylhRRRQMKKKKACiiigAooooAKKKKAFopKKAFooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigArNvEIYP61pUx0Ei7TWlOfLK44uzuYdLUksTRHnpUVd6aeqOpO+pNDKYm9q1lYMNw71iVatpth2N0NY1qd/eRlUhfVGlUTmpKgkNcsTnZAx5qP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oooAKKKKQBRRRQAUUUUAFFFFABRRRQAUUUtMArkbn/Xv9a609DXI3H+vf61rSOXE9CKiiitjjCiiigAooooAKKKKACiiigAooooAKKKKACiiigAopKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiloAKKKUDJwKBpX0EpwQnpUyxgdalxUOR0xo/zESxAdalAA6UtFQbpJbCUtFLQMSilooASlopaBiUUtFACUtLRQAlFLRSAKKWigBKWlooASilooAKKWjFAxKKdijFACUU7FGKAEoxTsU8ITSbGkyPFOC5qwkDN2q2lsBy1Q5diuXuUVjJq3HbHvVtUVegp9Tq9x3tsMWNU6CnUtFMQlFFFABRRRQAUUUUCCiiimMKKKKACiiigApkn3DT6Y/3TQhMyX60ypH61HWhmUn4u1rRrOl4uYz71o15eM+M9Cj8CCiiiuM1CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAWiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKSgAooooAa3SqUpq63SqMprWmZzK5pKKSukgKKSimIKKKSmA6ikorvhsjkluFLSUVYh+aXdUdLQBDer5tqy/jWbaPvt1NbB5Uj1FYVp8jyQn+E0Gci5TxTKcKZmPp1NFOoGFLSUtJ9xpXdkaemweZLvPQV0NUrCLyoB6nmrtYeZ2S7IilhSYbXqo2np/Ca0KKaZFjElspYxkc/SqVdRWLeQMkhdRwatMTRRpKKKogZJGkqlHGQapQyy6a+1yWiP6VoU11V1KMMg0D8jRSWN0Ein5TzmqE+rW8Pyr859qr+QVTy1Y7ar3EMaINo+ahEOHUY+s3TH92mB7im2+qXkk21sbR14qrJlRtH3jQf3EexeXanZEb6IfcyS6hdCNOfT6V1FpbJZwiJOvc1U0yyFrF5kg/eNyfatI1I5O3uoKKKKDMKQ9cUE4GaROfmPegCSiio5JBEhdu1AN2M/U7sQRbQeTXJQobqUzSf6tf1qW8ne8uPKB4PX6VPtVVESfdX9aqxcVZXHM3mHA4UdBRSUtMYVLFDJO+yMZqe2s5Lhs9F9a6BEis4SVHQdaTYm7HP3MKrKljHyzfeNdNGixoI14AFc/pKm5uZb1/XiujqPMqemglYU4N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s560AIJgwJAPFRfalIyAcVPs5600RADaDxQARyrIOBioBPKxZQoyKsogTvmkEaKxZRgmgCOCbzhyMEHmoJp5EmVB3PSraqiH5BjPWkaKORw7DJHegCb60lLSUAOX74rRrNH3hWlSGwooopCCiiigAooooAKKKKACiiigAooooGFFFFMAooooAKKKKACiiigAooooAKKKKACiiikAUUlLQISiiimMKKKKACiiigCpdj5RXnOoDbduK9IuhlK881UYvGq4mT+IzqKKKoD/0dOlpcUYqxCUtLRQAlFLS0gEopaKACilooAKKWigAoopaAL9kOpq/VOzGEJq3WZpLcinOIGPtXL9DketdRccwP8ASuX71Ezsw3ws37G581NjfeFX65WORonDr2rpYZVmjDrVRdzGvT5XdEtFFFUc4UUUUDCiiigAooooEFFFFAwpaSloEFFFFABRRRQAUUUUgCiiigAooopgFFFFABRRRQAUUUUgCiiigAooooAWiiimMZJ/q2+lce/3z9a6+X/VN9K5BvvH61tSOPE7oSiiitTkCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooASilooASilooASilooAKKKKACiiigAooooAKKKKACiiigAopaKACiiigAq9GMKKpAZIFaSrgCs5s68Ot2NxS4p+007YazujpsyPFGKlEbU8RE0uZFcjIMUuKtC3Y9qlFq3ep5x8hRxS7TWkLUdzUgt0FHMw5UZew04RMe1awijHanBVHQUrsNDLWBj2qZbVu9aFFKz7juVBajuakECCp6SiwXGiNB0FO6dKKWmIKSlopgFJS0UAFFFFIQUUUUDCqbXsKSeUx5FW+1cpdf8fD/WrhG5jVm42sdUrKwypzS1zVpdvA2CcrXRo6yKGXoaJRsOnUUkOpaSlqTUKKKKACiiigAooooAKKKKACiilxQAlFFFABRRRQAlFFFABRRRQAUh6UtIelIRmS9agqxL1NVzWiIZUufuira/dH0qGSF5l2rVxYWCgHsK4cZHmasdWHdk7kdFOZCvWm157TW51XCs+9+8K0Kzr37wrpwf8RHLjP4bKVFFFe2eMXrR+q1qwxmRvasazUtLx0ro4MAYFeVUpx9tY9ajN+yROAFGBS0UV1kBS0lLTAKKKKACiiigAooooAKKKKQFC5j2NuHQ1XrSmTfGRWYp4xXDiqVvfRtRnrysWlpKK4jqFooooAKKKKACiiikAlLRRQAUUlFABRRRTEFNIBGDTqSgZWMMafMBVN+taEnSs5+tdNNt7mU3qR0lLSVsQFJS0VcVd2Jk7K4UUVl394Yx5MPLt+ld6VlY5G7u4l7fFT5Fvy56n0qjHCEO+T5nPeliiEYyeWPU1LmqS7mMpX0QUlFFUQNIDDBqq6lTirlMZQwxQNOxTIB4NRGJc7k+U1MQQcGkpWNEyKOSSK5WSQ5966I881zky5TjrW5ayebbq3ep2dipaq5PRSUVRkPBqpI26T6VYJwCfSqAOcn1OaTWhUHaSZ3+nnNqv0qK9n2/u1P1qDTpwtju9Kpu5kYse9YwR2VfiGk0lFJVmQ9JXT7pxUpu7g/xGq1FADmYucsc1m3l35Y8uP7xqW6uBCuF+8ayYkLsZHq0jOUug+JfLUyP1NSxIWPmv+FNA818D7oq0cAewoZmRvliI16scV0dvCLeERjr3+tZemQeY5uX6DgVsk5NRuypaKwlFFFMzFoopOnNAivcMcCNerVbRQiBR2qnAPNmMx6L92r1IqXYK5vXrkhVtUPLda6JmCKWPQVw7yG7vHmPQcD8KYQV3ckiQRxhRW1YWe4+dKOB0FQ2NoZm3v8AdFdCAAMDoKGW2FJSA5OaWgzCiiloAxtafECxD+Ij+dRWI23Kr6Cmam2+8jj9AadaH/S1oexrDex0NFLRUmZWuxm2ce1Fsd0CmpZhuiYe1VbA5t/oxphH4WXKWkooADSUppKACiiigAooooAKKKTIHWmIKM0UlAC5optLQAtFJmjNAC0UUUAFRSZAPGR3FS0tICgjm3+aP5oj1HpWgrK671ORVOaN0Pmw/ivrUMMoT97Byv8AEvpQVa5ZvLVbqEoevaq2j3jIxsZ/vL0rURllXenSsTUrdo5BeQ/eXr+FJq5VGdnyM6nHaqssP8S0WVyt1brIOverdQmaTh0MqpoyD8rVPNDn5kqlyDV7mOsWTlSp5qxImAHWmoRKvPUVYXldpqGzZJWGq24ZpahH7t8dqmoBMKKKKBhRRRQIxtRsmY/arfiReeO+KLS6W6j54deCK18g9Oa53ULZ7WX7da/8CHt3pho9GalFQ286XMQlTv1FTVZi4tOzHA5p3DDDVXdGHzRnn0ojnDna3DelAWuK6FTTBVwEEbW6VBJEU5HIouS0Uri2S4X0YdDVeC4khbyLn8DWiDUM8CTrtbrTBSto9ib3FMk6VnQzvbP5E/3exrRkwVyOlANWK9JRRTEZGrf6pf8AeFQ25xuqXVv9Wv1FMsU82Qx+pqW7O500480OVdTb0awCyG5YfSupB4qKKJYowi9qbcSiGFnPYVlq3qa1ZKMNOhxuqy+dfhB0Soaqo5mneY9zVmtGZwjyxSK9xAJBkdayCCpwa6CqVzBuG9etNDMyijGOKKYiW2uGtJxKvTvXbxTJcRiWM5Brgz0q/pl1PavwC0ZPNS9GEqfPHzN28+S6ietw9aw9SIKxSr0yP51tKdyg+tMyf8OItVJ/9bHVuqkvM8Y+tDFT+JGv6VBL96pqhl+9UoGR0UlFUQLUR4NSUjjvQBIhyKhuYxNA0Z+opUODU1IpPQ5qH99EbeT76cCtrSr5lP2O4PI6E1jaijWt0s6dG61LIPOQTxfeWiSvqjelPlfI9mdrRWZpl8LuLa3Dr1rTrNM2lFp2CiikoJCiiigAooooAhuP9S30qnpf+pP1q5cf6lvpVTTR+5NU9kKO8jRoooqRhRRRQAtcteH/AE6uprlbzi+qujFH+JE6dPuL9KdTU+4v0FOqRvcKKKKYBRRRQAUUUUAFc4wB1UGujrnz/wAhMfWn9lhD4zRudMtLo7nUBvWqK+H7UNu3ZHpW7RRdisNRQihF6DinUUUhhRTXbYpY9hXP/wBpzrKT1XPSmot7Gc6ijozoqKr29zHcrlTz3FWKTVi009UFFFFAwooooAKKKxL++YN5UR+ppxjcznNRV2aclzDHw7VGL62JwGrlySTkmkrb2aOV4mR2SsrjKnNLXLW91JbtkHjuK6WKVZoxItZSjY6adVTJKKwrq/l80pEcAUyLUp1Pz/MKfs2T7eN7HQUtQwTJOm5DU1QbJ31QUUUUDFqmn/H2fpVuqg4uvwoHHcuUUtJSEFFLRQBHL/q2+lcr3NdXJ/q2+lcr3NaR2Mn8QUUUVYwopaKQCUUtFACUtFLQAlFLRQAlLRRQAUUtFACUtFFABRS0UAJRS0UAJRTqKAEopaMUAJS0tGKAEopcUuKAG0tLijFACUU7FGKAG1pWXRqz8Vfs+9TIuHUvUUUVAxR1rAsiBqUoA7Ct6sW1kzqMseOwpoRsGsPVfvJ9a3ayNUcJsyO9JDZhxKWL8dqhAJU8GtaxniVJN3HWnQ6hbIGDLmquSMnD/wBnptB6YxVu0Xdax5GCBip2u4oYFnPzK3QU+KeK7G6MYC9RSGZrW8v2/cB8oPWn3VjK8zSoMg06fVBHKYynAOM1NJqIQhVG7NGoaDdJhlhR1lGKZqFpPcSqYxkCrdlereB9oxtqWa8htmCSnG6jW4dDANjdK6Nt6Guo/hGfSqR1C2RdzNxVwnOCOhGaTGjG1lHkiQIM81z/ANnnx9w119zew2mBK2M+1V/7Vs8Z3Z/CqTYmkZ+iK63LBgR8tX9Z/wCPUf7wqzbXttcyFIjlselVtZ/49R/vClfUHsc3F/rU+oruh0rhYf8AXJ9RXdDpTmKOwtFFFQUFRyuY4y4GcdqkpKYGYL+TvGKcL4nqgGKvGKNuoqM20J7UCKv9oLnBWlN/EBuIP5VP9mTtxTTbMRgP+lAEQv4Su45/KnC+gPr+VKbaTGN2aXymUDjOKYDft9v3J/Kl+22/qfyphSTJJQGo2U946ALH2y3xnJ/KgXUB7n8qrJs84IU4NT3KxomQKAHi4hJxmnefF61mvsIJAOaehgCjg5oA0BLG3ANIZY1OGOKzyYkmBANNuyrSBhk0AaImiJwG609nROWOKyU8hSpIOc0t8wYbRnpmgDVDoeQaTemcZrHWQCBQc5qeBkacAA9KANLvRRRQA0sB1NN3r6imzW6ykMT0qJ7SMoQo5oAn3L6ijep6EVWeBEUHFQRw/LyvfrQBo5HXNJlT0NQSqoAPamLauDlWwDzQBZ3KTgHkUgkj3bM81GLba5fOSRg1ELd/OBPQd6ALtLRRQAD7wrSrN7itKkx9AooopCCiiigAooooAKKKKACiiimMKKKKACimlgKb5h9KAJKKjEg71ICDyKACiiigAooooAKKKKACiiigAooooASilpKQhaSiimMKKKKACiiigCC4H7s15/rK4uc+tehzDMZrgtcX94rVUTKXxGHSUUVYH//S18UYq2YR2qMxEU7hYgxS4p22jFFwG0U7FGKYDaWlxRQAlFLiikAUUtFACUtFFALc1LYYiFWKihGIxUtSU9xHXchX1rlXG1yPeusrB1GDy5PMHQ1EkdOGlZ2M+rtlcGGTafumqVFQmdco8yszrsgjI70Vl6fc7h5LnkdK1K1TuebOLi7BRRRTJCiiigAooopCCiiimAtFJS0AFFFFABRRRQAUUUUgCiiigAooooAKoJNcNOVI+WtCjigpSsFJRRQSFFFFABRRRQAUUUUALRRRQBHL/qm+lcg33jXXzf6pvpXIH7xremceJ3QlFFFanKFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFLQAUUUoUmldDUWJRUgjc9qeIHPapdSK6lqlJ7ISFC8gAreW0bvVSytiH3EdK2655S5mehSjyRsyqLVR1NSCBBU1FTY0uxgjQdBT8AdBRRTsIKWkooAWikopgLSUtFIQlFLSUAFLRRQAUUUUAFFFFAwooopgFFFFIAoqCS4hiOHbBp6SxyfcbNOzJ5lsSVy16u25f611NYOpxbZBJ61dN6mNdXjcy619NnIbym6HpWRU0LFJAwrWSujlpys7nV0UgOQDS1znpC0UUUAFFFFABRRRQAUUUUAFKDSUUAFFFFABRRRQAlFFFABRRRSEFIelLSHpQBnS9TVc1Yl61XrREMsxfc4qSq8TgfKas1wTVpNHTF3QhGRg1UYYOKt1WkILcVzVlpc2pjKz70cg1oVSvR8gNPCu1REYtXpszqKKlgTzJAte1OXLFtnjwjzNJGlaRbI9x6mtCJsNUIGBgUZxXh+0fNzHuKCUeU0xVW7ultl9zU0TbhWNqsb+YH7V61NqWp51duMdCo99cschsU+PUblDyciqFFdXKjz1Ulvc6KDU4n4kG01pKyuNynIri6t211JAw2nispU+qOmniOkjq6KihlEyBhUtYnYwooopgFFFFAAeRWM3yzFa2qxrni4qKkeam0SnacWLRRRXjHpC0UUUAFFFFABRRRQAUUUUAJRRRQAUwyIDgmklYqhIpIolKZPeumjQU1zSZz1Kri+VEnB5FFMUbWKU+sJx5ZWNoS5lcgl6Vnt1q/LVButb0jOW4ykpaStiQpaKjkkWJC79BXVQj9o56suhWvboW0eR949BWRFGQTLJy7UAtczG4k6fwipjXUl1OWUuiCkooqjMKKKKACiikJAGTQA1kD/Wq7RstK93CnGc1A1/nhVqbo0UZDiOxqfTJNrNAfwrOa6kPQUkE5S5WQ8etS2axi7WZ05pKU84PrSVZgxsn+rP0qmOlXTyCKo9CRTQI0La5kLCDPy9a1DXOxttkU+9dDnPI71la2h1c3MkxaSkJA60tABUU0ohQsevapGYKCx6CsC4ma4kwv4U0iJOw0lriQknjuamY8bFoAEa7RUkKZPmH8KsyehNGmxcd6awMjrAvVjg1KSAMmrWlw7i1y/0FTIIdzWjQQxrGOwopWNIOlSS3cWiiimIWq1zIVTYv3mqx71UhHnXBlPROBQOPctxRiKMIO1SUUUiWzJ1m58i0KL95+BWLp1qZMIO/JNO1OQ3d+Il5VK6OythbxDP3jTZqvdiWURY1CL0FDHt607pyaYg3Zc/SkQOAxxS0UUxCUtFIThSfQUAczM2/UJG/unFPt2xcKarRHdLNJ6mnqdrhvem9jWPxo62ikU5UH2paghiHkYqhY/Lvj9CTWhWen7u9Zf7wFMUd2i9RRRQIKSnU0jBoGFFFL2oASiiigAprjKkCnUUCI423pn8KdVZD5VwYz0bpVsigpoZRS0lAgooopiClzSUUALS02loAWqNxbsrfaLf7w6j1q9Sg0hmfbXAJLx8EfeWtA7J0OOh6iqN3Ztn7TbcOO3rTLW4E+SnyyL95fWgppSV+pFaO2n3nlN/q36fU11HBGRWBcxrdRHHDryPrVzS7kzReU/304rOStqbxfNHzRqiq00O75lqxS0JkuNzLRjG+a0lIIyO9V54cjctMt5P4DTeupEHZ2ZakXcvvTI2yMHtU1V3Gx9w6GkuxctNSWikzgZpSR1oC4VXd88CiSTsKhqkjOcr6CBzEdw6d6sHbIvqDVeo97QHcOUPUU7Ep9DHmjfSrjzo+YnPI9Ca2UdZEEiHINTukdxFtblWFYELPp1x9mm/1bfdNI1Xvq3U2qiliWUehHQ1LRVGGxTW4eAhJ+nZq0kkBHqDVZ0WRdrjIrPJlsmyPmj/AJUi00zWeAN8ycGq5BB2twalhnWVdyHNTELIMGhMlwM2aFZkKtVKN3gPkS9OxrWeNk9xVWaJZVwfwq9yVK2jI6KgjYq3lSdR0qegbRjav91PqK2tDsxk3DD6VnXkQmkRD0JFdjbRLBAsadMVlPVnbRsqd+rJ65TxFfbE+zRnlq6G8uUtYGlY4xXmE9y93O00nc046amL9+VuiL8A2qB61aqnCT949Kc91GvvTNdy3S1ltet/CKZ9okPU0BYmuoMfOtUK04pd42v3qncReW3sapElduhrsdCCGzJIB5rjuoNdX4ffNs6ejVMhS+F2J9ZX/RwR2YVftW3W0beoqpqo3WZPoRUmnNus09hQjP8A5dIvVTfm6QVbqp1vUpvYin8Rr1BL96p6gk+9UociOiiiqICl6jFJSjrQMh6GrAORUUgwc0sZ7UMSK1/AJ7dl7jmufsp2jOxu3FdaRniuTv4vs91uHRqaKjr7rL7FrWYXcHQ9RXW286XMQlQ1x9tIJEMbc1ZsbhrC42N/q2rOas7o66M+dckt0dbRQCCAw6GipAKKKKACiiigCK4/1TfSqunf6k1an/1TfSq9hxEab2Qo7yLtFFFIYUUUUALXK33F7XVVy2of8fv5VS2Yl8cTpY/9Wv0FPqOL/VL9BUlIp7hRRSHPagQtFNw1KM96AFooooAK59+NTH1roKwJeNSX60+jCHxo36KKKQBS0lFACOu9CnqK5OeJoZCjCuuqvPbx3C4cVcJWMatLmWhzEUrwuHSunt51uIwy9e4rEn06aM5T5hUFvNJaygkcdxWkkpK6MKblB2kdTRSKwdQw70tYHaFFFFABWBe2Loxlj5Het+jGeDTjKxnOCkrM4qity908f62EfUViYwcGumMrnnzg4uzErd0li0bxmsOtvSVIDNUVNjSh8RmXClZ3B9ahrfvrPzv3iferCZGQ4YYNOEk0KrBxZd0+QpOF7GuirB0+3cyCVhgCt6sqj10Oqgny6hRRRUG4VV/5eRVqqrf8fAoBblyijvRSAKKKKAGP9xvpXLH7xrqn+4fpXLt941pHYze42iloqgCilooASilooASlpaKAEop1FACUUtGKAEop1GKAEoxS4ooATFFOxRigBtLS4pcUANxRTsUuKQDcUYp+004Rt6UuZD5WR4oxU4hY1Mts57VPOiuRlPFLitFbNu4qdbMdzS5+w+XuZAQmniJj2rZFsg61IIox2pczCyMUQMalFq55xWwAB0FB6GjUHY5512nFWrP7xFQS/fNTWn3zVsmHUv0UUVJQtY1sV/tGVQOcCtmsyKzkjvnuS3ysAMUIDRrI1cDy0J9a2Kz9Rt5LiNVjGSDmhAzl4uN5HpUCfMTweldVpto8AZZ14NXxbW6tvVRmquTY52dJDYxhRnGOK0NNUmFsrg5rXAA7ClpXHY4+7jZrluD1oyUlJYHgV15VT1ApCiHqBRcLGLo6YWQgYHFVtXYLOm6ukAA6DFMaKN/vjNK4HFbww2npXaoP3a/QVC1nbNwVqyAAMDoKbYI5vXcApn1rDDLtIru5beGfHmrnFVzptmf4KakJow9Cx9qb/drT1r/j1H+8KvQWdvbMXhXBNU9Y5tP+BClfUeyOah/1yfUV3PauHt+Z0H+0K7RZo2laFfvL1pz3FDYkopaKkoKjl8zYfK+9UlFAGYZtQX/ljn8ab9rvR1t/1rVooFYyf7QnH3oMfjR/amPvRkVrcegpML6CgLGUNVh7gin/ANp2p6kj8K0diH+EflTTFEf4RQFij/aNof4v0pwv7X+/+lWjbwHqophtLc/w0AR/bbU9G/Sl+1QH+IUpsrY/w0w6faH+E/nQBJ50P94UnmxHoRUf9nWvofzpP7OtvQ/nTAl3x+opd8fTIqD+zrf0P50p0+344P50ATbo/agFDySKh/s+2znB/Ol+w247H86AJcp7UoC/w4zUP2G39D+dPhtYoGLJnn1NAEmKp3k8tuFZE3r3q/Scd6AIom8yMPjGe1SUtJSAjeMSde3SqwgnAK7+D04q5RTArXCymILGMnvVhchFDdcUtFABRSUUAFFFFACdx9a06zD1H1rSHSkx9BaKKKQgooooAKKKKACiiigAooopjCkJwM0tBGRigCJBn5jUmKYnHympKQBgVGRsOR0qWkIyMUAHUZopielPoAKKKKYBRRRQAUUUUAFFFFABRRSUhBRRRTGFFFFABRRRQAyQZQ1w+urwp+td03KmuL1tcxZ9KqJlLdHLUUUVYH//0+opaKWgY0opqMw+lT0tICkYyKbir1MZFxmmBTxRircUQdM0NbkdKVx2KlFSshHWmYp3FYbRS4opiEopaUDLCga3NdBhAKdQOgpakbCo5I1lQo3epKKATsc1cWslu3qvrVautIDDDDNZc+nA5aI49qzcex2U8R0kZCsUYMvUV0ltOJ4w3fvXPSRSRHDjFPtp2gkDDoetKLsXVgpq6OmopqOHUOvQ06tTgCiiigQUUUUgCiiigApaSlpgFFFFABRRRSAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBaKKKAI5v8AVN9K5A/eNdfL/qm+lcg33jW9I5MTuhKKKK1OQKKKKACiiigAoopaAEopaKAEopaKAEopaKAEopaKAEooooCwUUuKMGldD5WJRTtjelOEbntRzLuVyS7EdFS+TJ6U4W8h7VPtI9xqlN9CCirQtZPSnCzel7WPctYefYp0VoCyY08WDHtU+3iV9VmZlFa408+lSjT/AGpe3XYf1WXcw6MGuhGnipBYqPSl7d9EUsL3Zzm1j2pwic9q6UWaD0p4tYxS9tLsWsNHqzmRBIe1PFtIe1dMIIx2pwjQdqXtJlLDwOaFm5qVbFjXRbVHancVPNJ9SlSguhgrp7elTLp3tWxRSs+rLUY9EZq6eKlFkg61doo5UO5WFpEO1SCGJegqWijlQXYgAHSlooqgCiiigAooooAKKKKACiiikAtFJS0CCiiigAooooAKKKKACiiigAooopjCk6jFLRQBzF2jpM27ueKhV2Q5U4rpZ7dJ1w3X1rBmtZYTyMj1raMk9Dhq03F3RpWl8H/dy9fWr1xCJ4iprlxwcityxuvMHlP17VMo21RdKpzLlkYkkTwttYUsKGSUKK6iSKOQYcUyO3iiOUFHtNBrD63JQMACnUlLWZ0hRRRQMKKKKACiiigAooooAKKKKQgooooAKKKSgYUUUUCCiiigAoopKAM+brVc1Zm61XNaIhkMuQuR2qZZG2jmoZv9Waev3RXnYzSSZ2YfWJIXY02ikrhep0BUFwu6I+1T0jDKkVdOXLJMmpHmi0YVaNinBes9xtYr6Vq2Y/dCvVxkv3a8zycEv3jv0LlJRRXknrk0L7WxVxlWRdrDINZ1XoX3Liu3DVPsnPWh1Mi501gS8PI9KynR0OGGK7Oo2ijf7yivRjUtuedPDJ6o42lFdM2nWzc4NMXS7cHPNW6iMlhpXJNPUiHJ71epFUKoVegpa5zvCiiimAUUUUALWPecTCtise9/1tNbMznugHSigdKK8Jnpi0UlFAxaKSigBaKSigBaSiigAooooAZKu9CtMhlUDY3BFTU0qp5xXTRr8is0c9WjzO6YmQX3D0p1GAOlFYzlzSua048qsV5aoN1q/JVIitab0JluR0lNL4OCDShlbpXRGLbM5OyuKeKwryU3MvkJ9xetXr258mPC/ebpWbGnlrjuetejGNtDhlLqPwAMDoKSlpK0MAooooGFMeRYxljinMdqk+lURH5qmXOT6Umyoq+4j3bdEGKjjKyf6w803FRspHIqLm/KlsW3gRhgCqJQocGrcMu75W61LJGHFPcm9tzPpjjjIqVlKHBpvWpLudDaS+bbhu44qesfS5MM0R/Ctg1UWY1FZhVSYbWz61aqOVdy/SqM0VD0rZhnAtBITyox+NYv1pm5gCmeCc0NGsZWVh8k8kr+YTj2rftn82FWP41zdXY7sw2zIOppNFRZLf3WT5KdO9QxJsXcfvGoLdC58xqtMcDNMhu4gG9to/GroAAwKihTauT1NS0GbdyJwZGWFerHFdLGgijWNewrI0yLzJWuG6DgfhWu7AZY9qz3Zb0Vhucvtp9VbZi4Zz61apkC0UlLQBXuZPLjwOp4qeCPyolTv3qog8+63fwx8Vo0inorCVBczCCB5T2FWKwdclOxbZer9aZKV2kU9HtzPK1w/rXVVTsYBb2yp3q3SLm7sY/JCDvVnbhcCoYRuJkP0qxSYWK5oqVlzzUVMkKjmO2Fz7GpKq3zbLVj+FMRzlr/AKot60ppbZT5CqOtDDBIPaqjqipO0kzprR99upqzWVpcmYzH6Vq1ki6itISqF0Nk8cg9ea0Kq3iboCR1XmqM07NMsdefWkqOFt8Sn2xUtANWdgpXHANCjJqdkymKVxpaFWnqM0yp4l3ZoY4q7ISMUlXPLzwaYYPSjmBwZWoqfyTSrCe9HMLlZl3akBZh/Ac1djIkQMO4p9xADGV9RWbp8pw0LdVJoGtrPoaRjamFTV6M5WnYFTzFcpm7aTbWkVU9qb5a0+YXIUNpo2mr3lLQI1FHMHIUdppMEda0sCkKqe1HMHIZ9FW2gU9KgaJlppkuLQsbc4rNv7Fg32u14decDvV3pVqNg4oYRMe2uEu13D5ZF4YVC5a2uFuU6HhqfqFk8D/brXqPvD1psc6XcWe/Qj3p2uilJwdzpY3WRBIvQ0+sXS5ijG2f8K2qyN2uwtUJ4ijeYtXqCAwwaaZEo3I4nDqDTnXcuKqLmCTB6GrvXkUMIu61K6cqVPUVA2QcVO/yPu7Go5RzmqRnIipKKKogKDg8GiigRW3PavuHMZ6j0p93bx3tvjv1U1LweD0qoS1m+RzG3X2oC7WqK9hcPk2k/wB9OB71pVn6hBvVbu3Pzr6dxVi1uFuoRIvXuKRpO0lzos00gMMHkUtFMyMmWCW1fzrc8dxV61vY7gbej+lWDg8Gsa7sip86Dg0WLU+jOgDdjUUkIPzJ+VZFpqOSIrjg+tbSuD3pXsU4pmVPDvHPDDpUMchz5cnDD9a3HRXHNZdxbEnPcdDVXuZax9CrNxNGT6iuuU4WuJmZsqG6qw5rfvr5bbT/ADc8kYFZtPmOpS/daHMeItQM032ZD8q9a55MDk00s0shduppTVMVOPKrEzzM3HQVDmm0ZpGg7NPU1Gqs5woq9HGkXzSHmqSJbJYImJDNxVqZBIhXuKrifdxGKmQnvTEjKxg4PatLSrwWc5D/AHHqtcBFO7PPpVNmycD8KUmXGN15Hb3k1vJbMokBzyKZpDZtMelYNlpM0372clV7A1uaWNnnRf3WFIyaiouETWNVE5vR7VaPSqsHN6fam9jKPxGsKrv96rFVj1pIchKKKKZAUUUUDHMNy1XBw1WF9KgcbWoEyx15rJ1WDzYd46itSM5X6UkqB0K+1CCXdHH20pGCOorWIWdMGsWVDbXJU9KvwyYPtVW6M0fScTodIvic2k33h0PrW7XEPkMJY+GFdVY3a3cOf4h1FYWs7M7Lqa518y7RRRQQFFFFAEU/+qb6VBY/6o1PP/qz9Kgsf9VTeyFHeRcooopDCiiigBa5bUeLz8BXU1y+p8Xf5U1sxfbidFB/qV+lS1Db/wCpX6VNQipbhRRRQSHakBzRQOOKBi0UUUAFYE//ACEk+tb9YFzxqCH3p9GEfjR0FJR2FFIAooooAKKKKACmmNG6qKdRQIAMcCiiigYUUUUCCiiigArNutPSY7kO01o0U02tiZRUlZmENJkzy1a8ECwJsWp6Sm5N7ijTjHYKaY0bkgU6ipLAAAYFFFFIAooooAKqv/x8LVqqsn+vWga3Lh60UGigAooooAa/3D9K5hvvGunf7h+lcy33jWkTOW42ilpaoBKKWloAbS0tGKAEop1GKAEoxTsUYoAbilp2KMUrgNpcU/aacIyaTkkNRZFilxVlbdz2qwtmx68VPOiuQz8U4ITWstmB1NSi2jFLmY+VGMImNSLbsa2hGg6CnYHpSuw0MpbRj2qZbP1rQopWHcqi1UdakEEY7VNSU7ILsQKo6CnUlLQIKSlopgFJS0UAFIeh+lLTW+6fpQJmFL981Naf6yoZPvmpbX/W1bFA0KKKKgoKKKKQBRRRQAUUUUAFFFFABRRRQAUUUUAFJS0lAC0UlLQAVVvLY3UHlA45zVqimBzi6HIrBhIMg5rZt7cxMzudzMetWqKG7gtAooooAKKKKACiiigApKKKACiiikAUUUUwCiikoAKKKKACmMrEgg8U+koAKSlpKACiiigBKSlpKAEooopiEopaSgAooooASilpKACiiigBD2+taQ6Cs0/1rSX7opMfQWiiikIKKKKACiiimMKKKKACiiigAooooAay55HWkD9jT6QqD1pALkUZAqPy/Q0bPU0AKnc0+jpRTAKKKKACiiigAooooAKKKKACkpaSkIKKKKYwooooAKKKKAA9DXI6yuYG9q66uZ1ZcwyCnEyn0OHooorQD//U6qlpKWgYUtFFABTW+6adTX+4aAJLf/V/jU1RQ8RipaQCFQ3Wq0kHdat0fWiwXMsrim1ovGjjgjNUmQqeaB7kWKcgzIBRinwjMy0XCO5q0UUUCCiiigAooooAa6I4wwzWbNpqtzEcfWtSik1cuM3HYybRpbd/JlB2nvWtQVVuozRQkE5czuFFFFMgKKKKACiiikAtFJS0wCiiigAooopAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUtJRQA1xmNh7VyLg72+tdj1GKptYwkk461cZ8plUpc5zGDRg+ldN9ghpfsMVV7V9jP6su5zO1vSjY3pXT/YoaX7HFS9rLsP6tHucx5b+lL5b+ldR9lipfs0VL2sh/Vo9zl/Kk9KXyJPSuo+zxUvkx+lL2kh/V4HMC3k9Kd9mkrpvKj9Kd5aelLnmV7CmcwLR6eLN66XanoKML6Cjmn3H7KHY5wWT08WDehroePSileXcfs4djBGnn0NPGnn0rczSZpa9x8sexkDT6kGnitOijl8x2XYzxYL7U8WSD0q7RS5EO5UFnHTxax1Yoo5UPmZCLeMU4QxjtUlFPlQXY3y0Hal2r6UtFOyFcMD0paSigBaKSimIWikooAWikopALRSUUwCiiigBaKKKACiiigYUUUUAFFFFABRRRQAUUUUAFFFFABRRRSAWikpaBBRRRQAUUUUAFFFFABRRRQAUUUUxhRRRQAlBAYYIzS0UAUZLCCQ56GkhsY4X355FX6KfMyOSN72CkooqSxaKKKBBRSE4BNUjcv2rOdRR3LjBvYvUVnGeQ03zZPWsniYl+xZp0nFZnmP60m5vU1P1pdh+x8zUyPWk3p61l5b1pMn1pfWn2H7HzNTzI/Wm+dH61m0VP1lj9ijR8+P1pv2iOs+il9YkP2SL/ANpT0pPtS+lUaWl7eY/ZxLn2oelJ9q9qqUVPt59w9nEtfaj6UC5YnGKq05PvCnGrNvcHCNtjTzRSUteicpRm61Wq1N1qsa0RmyGX/Vmlj+4KWT7hpsX3K4Mb0OvDbMfRRRXnnSFFFFAzIu12yZ9auWLZjx6VHfJlQ47VDYvtk2+tepL95QT7Hkw/d4hrubFFJS15p6gVJG+1qjopxlZ3QmrmqDkZpar277lx6VYr1YS5lc4pKzsFFFFWIKKKKACiiigAooooAWsa8/11bNYdwd09VtFsznukPHQUtJS14TPUCiiigAooop2YgopcH0pdr+hp8j7BdDaKf5ch7GnCGQ9qpUpdieddyKipxbyGnC2buatYefYXtIlairYtvU0v2ZfWqWGmT7WJTppqeWMRnAqA1jODi7M0jJNXRWkqoatPVVq0gRIZTHIVSTwBTzWRqExJFtH1PX2rroq7Oeq7IpFjcTGc9B92pKAAoCjtSE16SVjgbuwooFLTEJRS0lAC1RnEcJ3o3PcUXF1t+SPk1RCZOX5NS32NIw7k24P8w70U1QB0p9SbELIR8y1bglD/ACt1qKo2Q/eXg0A1ctyxhxjvVAgqcGrsMvmDaetE0W4Z70yNtCnC/lTq/vXTZyAfXmuUcHv2robOXzYAe44pLcdRXjcs0lLSVZzlOVdrfWoTV2Vdy/SqVMpMSmYLuEHenk4Gamtk4Mh6npQUWVAUBR0FCLvf2FIxwOOpqzGmxQKRDZJUUpO3aOp6VLTraPzroDsnJpN2HFXZtW8QggCD0zVHUJvLi2Dq5xWlIegFczdy+ffKg6LUrRXF8U7G9bLthUewqxTE4QfSnUCe4tQ3EnlxE9zwPxqaqgBuLsJ/CnX60mVFXZatYvKhAP3j1qepdhPNIVIpJg77kfvXNIPt2qljyqVvXsogtmkPpWbo0O2Azt1c03sOHWRs1HJyAg6tUlNiG9y56DpQJblhQFAA7UtFFSUFRsMc1JSHmmJjQoxzWZq7bbMj1IrWrC1xv3Kp6kU0KxW06PfKi9gKbqEXlXLY6HpWhpSYBk9KXVotyCUdqIPUKqM/TpPLnAPRq6SuNVijhh2rrYZBLEHFKSsy370UyWkI3Aqe9LRQZNaGfaEqXhPYk1dqjcfublZR0bg1e+lBUtUmSxLk1bxUUA4zU9Q2awWhnSLtc+9TwdDRcr8u6ktjnNU3oTFWlYs0UUVBoFFFFADHGVrmpf8ARtQB7PgV1Fc7q8Z2iUdVOatbGe0l5mzA2eKsVlWU3mRK9avXmkxrsFFFFIoKKKKACiiigApKWkoAjaJWqowaFs9qv01lDDBppkOPVDEcOueoPWudvrR7OT7XbcqfvLWv81vJg/dNWjtdcHkGnsLc5wSh9tzF2611EEomiEg71yd1A1hNleYnP5Vp6ZcbJDCx+VuRRJdUa03dcrN6iiioGRyoJFx3qOBzjY3UVYqtMpU+Yv4012IejuTSruQ+tVwd6VYVw65qrjZIV7GmhS3uRGinuMGo6syYUUUUCCkIBGDyDS0UAUPmtH2nmJv0qo//ABL7kSrzFJ19BWyVV1Kv0NZkkYXNnPyjfcb6UFQlZmkCCMjoaWsqwldCbOb7ydD61qUClGzsLSfWiigRlXlkGzIlVLe8ktj5cvK+tdBWXeWgOXQcdxT33Em46o04blJACDkGrDAMK5GN5Ldsr09K3bW8WQcHn0NJqxqmpDbq1Ein1rltUnuCqWsg+VBj613WQwrLv9PS5jPHzDoaE7k6x9DhBwKQmllRopDG/UU9IXcZPA96VjourEPJ6U8ADlqexVOEqIbnPHNAyXziowlPjjeU5bpU0Nr/ABPV5U/hUVSQr9iFVCjAqOW5WMbV5NNupxF8inms1MvIAe9JvsUoq+pMoeZwByTXU2GmJCPNm5Y9qnsrCC2jV1+ZmGcmtGhLqZVarfurYB6VQsjtvJl9WrQrMt+NRcetMzhszZNVbPm5kb0xVg9DVfTuS7+tS9iYbs1DwDVepnOFqGhDkFFFFMQUUUUAFLKMruFJT15G00AQRthsVYNVD8rVbB3KDSY466HOaxb8iVay4ZOBmusuohNCyHrXKW8W+Rrc8E9KvoOl1iaMT5GO9Sw3D2NwJl+4x+YVnxsynB4Iq4Csi7W6Gpkro1pT5JHbI6yIJF5BFPrlNN1D7GWt7jJHUVotrcYOEhkP4VkbyjbbY2qK586xcN/q4H/EUw32rP8AciA+ooJN6b/VmobP/VVj7tZkHz7QK2LIMsADdab6IUV8TLdFFFIYUUUUALXM6sMXINdLXPavFK0gdFLfSmiW7NM2rY5gX6VPWJbaj5UQR4nyParH9pp/zyf8qSLluadFZv8Aacf/ADyf8qT+00/55P8AlTEaVFZv9pp/zyf8qcNRU/8ALN/yoA0aKz/7QX/nm/5U4X6n/lm9AF6ufvTtvoz71sLcq38LCsbUlkaVJYkJweaa2YlpNM6AdB9KWs6PUI9oDqwI9ak+3we9SMu0VT+32/rS/brf1pgW6KqfboPWl+2wUAWqKq/bIfej7ZF6GgC1RVX7ZH6Gj7Wn900AWqKqfbF/utSfa/RDQBcoqn9qbshpPtMnZDQIuUVS8+c9Fo825PYUDLtLVHfdH0pP9KPpQIv0lUdlye4o8qc9WoAvUVR8iU9X/Wj7Mx6ufzoAu5HrSb09ap/ZR3dvzo+yp/eb86ALfmR/3hVaR0aZNpzzSfZo/U00xpHIm31pDjuaNFFFABRRRQA1vun6VzbfeNdK33T9K5th85rSJm9xtFLilxVAJRinYpcUrgMxS4p4UmniImk5JFKLIsUuKtrbOe1WUsz3qOfsVydzNCmniJjWutsg61MERegpXYWRkrase1WVs/WtCilbuO5WW1jHWpliRegp9Q3EvkxGT0oskCu3YnxjoKSuY/tC437s1q2t+s3yPw1JSTNp0JRVzSoooqjAKKKKYBRRRQAUUUUhBRRRTGFFFFIQUUUUxhTW+6fpTqa/3D9KBMwX+8altf8AWioX+8altv8AXCrYoGnSUUVBQUUUUgCijIooAKKKMUAFFLg0YNMBKKXFJikAUUYooAKKKKACiiigAooooAKKKKACiiimAUUUUAFFFFACUUUUAFFFFABRRRQAUlLSUAFFFFABSUtJQAUlLSUAFJS0lMApKKKBBSUtJQAUUUlABRRRQAUlLRQAlFFFACGtFfuis41or90UmV0HUUUUiQooooAKKKKYwooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApKWkoELSUUUgCiiigYUUUUwCiiigArA1Nco4rfrH1Bc7hTiZVNjzo9T9aSnOMOw96bWgH//1eqpaKWgYUUUtACU2T7hp9Ry/cNAE8X3BUlMT7gps0gijLnsKEhSdtSC7vEtl9W9K5+W+uJTncR9KgmlaaQu3eoq6YwSPNqVXJkwuJxyHP51bh1B1+WXkVnUU3FMmNSUdUzo0dJRuQ1Pbj98K5mKV4m3Ka6DT51mfPeuecLHoUayno9zYoooqDUKKKKACiiigAooopAFFFFABRRRTAKKKKACiiikAUtJRQAtFFFMAooopAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFLSUUALRSUtABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUwCiiigAooooAKKKKQBRRRTAKKKKQBS0lFMBaKKKBhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lFIBaKSloEFFFJQAtFFFABRSUtAwooopgFFFFIQUUUUAFFFJQAtJRRQMa/wBw1l1pyf6s1l1xYndHRR2FopKXn0rmsahRS4PpRtb0NHK+wXQlFO2t6Gjy39KfJLsLmQ2in+VJ6U7yZKfs5dg50RUVN9nkpfs0ntVexn2Fzogoqx9mel+yt60/YT7C9pErUVa+yn1pfsvvT+rzD2sSrTk++KnFurEjPSnfZcHINVHDyTuS6isWKWofKk/vUnlSf3q7jnIputVTVw27nqab9kb1qrktFF/uGo4vuVom0YjGaYliUXaDXNiYOaXKbUZKN7lairf2NvWj7GfWuP6tM6PaxKlFXfsY9aUWa9yaf1aYe2iZ0ih0KmsHJik+hrsfsiepqF9NtpDlhXXh4SgnGWxxYmKm1KO5ShlWVAwNS5q5FYwxDalTCCMdqxeFd9GdKraambS4J6CtPyox2FOCKOgoWE7sPbFW3VgSTVulpK6oQUVZGMpXdwoooqyRM4ppfHY1JRQBCZf9k0nnHspqxmjJoArebIei0b5+yirOaKAK2bg9hVQ2UjPvYitOih6qwtL3KQtD3NOFqvc1borL2MOxp7SXcrC1T1NOFvHU1FV7OPYXO+5GIYx2p2xB2FPpKpRXYm7E2r6ClwKKKYBRRRQAUlFFACUUUUwKdz1FUjVy561SNebif4jOqj8JXeq7CrDdaZjNZxlYcinM4ijMjdqwEy5Mz9Wq9qMplmFuv3R1qqcDgV6uGhaPMzzq87uyGmmjk0MewpwGBXUc4UUUUDCs26uj/q4vxNOurg58qP8AE1TC4FQ2awj1ZMLSZYvO6g0wHNathdrt8iToaivLIxHzYuVPasYzs7SO6dFOPPAoCn1GDmnCtjlTHUox3pCPSm4pDuKyj7yHBFSx3Ab5ZODUGKQqD1oE9dyS4i43rUulyYZozVXc6cZyvvTIX8q4DDpmk+44rRo6WijOQD680laHKwqhINr4q/VW4GPmpjiVsF2CD61oAADA6CqtuuSZD+FTsSSFXqaRT7EsS733noOlWqaihFCinUGbENaOmx4jMp6txWXJkjaOproEURRKg9KiW5otI3IbiUIjP6CuatQWl8xupatHVJcRiIdXOKqQrs2CqexEO504+6PpS01fuj6UtSAyV/LjLflT7CPbFvb7z8mqk2Zp1gHQcn8K1RgdKTNForlscCmP0qISEdaGfNSkEpqxg605cJbL1c1qQxiKJYx2FY4/0rVc/wAMdbtU9xbRSGSHC4HU9KnjUIoAquo8yb2WrdJgtgooopDCkHrQelKOlMQVz2tnLxr7V0Nc1rBzcxj2oBbo1tPXbbKfWrciCSMoe9R2y7YEX2qxUoc9bnFyoUcoeorZ0qfKmI9ulRapBtfzR0brWZbymCYMK0nqrioveDOxopqOJEDjvTqlCasV7qPzYSB1HIqO1l8yIZ6rwauVl4+y3WP4H/nQEdmjbgPGKnqjE+1quZqGawd0JINyEVTtjh9tXM1RHyT/AFprYH8Vy/RRmkzUlC0UmaTNMQ6s6+j8yJ19RV7NQy4Iqo7mdXa6Od0ubaxhbscV08bZGK41821+R2PNdRbyBlDUNFN3tJF2im71pPMWpsO6H0VF5q03zhTsHMieiq/nGm+a1HKLnRZpMiqu9j3pu4nvT5Sectll9aaZFqrRTsLnZJIVkXaaghkKN5T/AIU+oZk3LuX7w5osK5YnhS4iMTjrXML5ltMYX4Kn5T7Cujgm8xeeo61T1S0M8fmx/fTn8KEug1LldzYtZxcQh+/erFcvpN3tk2t0fr7GuoPFZ2todEu4VGzLjBqJ5OwqAnNUkYyn0Ho2xsdqW46CQdqiqQnehQ96ZKelmI3IzUVJC+5CD1BxSnrVITCkoooJClpKguJRDGWPU8CgClcStNeLao21e5FXZ4lMGzOSOhrGlBheO4J56N+Na/liOcOxJU9B2oehW6uZlwsyotzjEkfX3rWhlWaMSL3qneXHmymJeife/Gq2lsyl4jyF5FGxS96JtUUisrDKnNLQZhSfWiigDLu7X/lpH+NZfzI25eDXUe1ZV3a4+dOlUn0J22H2l9u+SXg+ta/Vc1yPQ1qWd9s/dy8g96Tj2NVK61MW5WI37l/WqNw5Y4HA9BVrUUH23cDwx61dWyiQ7jz6Ub6GsLKNzDitJZT0wK04rVYx71oBCeFFOPkQjfMw+lGiKSlIrrCW9h61n3d7HCPKg5Pc0+6vHnGyL5VrJ+yvnORSbbHtsVySx3N1pyHDA1MbWXtg1GYpVPIotoSnrc7y0bfbIfarVZGjy+ZbbO4Na1C2MqitNi1mpxqf1zWlWY3Gpr9DTYQ6mrI21CfamaWP9H3etR3bbYSas2K7bZRSkTS6ssue1R05zlqbSQmFFFFMAooooAKUHBzSUe1AEcw53DvUsDZG2mn5kK9xUULbXoewLRliUbTXLalE1tcC4j9a6+Vdy5rKurcXERQ9e1CehTfLNMrX1qJoU1C2HUfMB7VmI/cVsaLdBQ1lN27GodS042zedFyh6ihM1nHVlGZd4Eq/eTmuq02eC7gBCgMODXJI+OlWLW4ayuBIv3WOCKmcbe8jShPmXs2dttA6ClzTI3WVA69DT6goZJ90023/ANXTpPumm2/3KBLqT0UUUwCiiigAo+tFJSEIVQ9QKTYn90U6imAmxP7oo2J/dFLRQAmxP7opiBdx4FS1En3mpDRJtX0FGF9BRRTAMD0owPSiigBhjjPVQaTyYf7i/lUlFAEXkQf3F/KjyIP7i/lUtFAEXkQ/3B+VHkw/3B+VSUUAM8qL+4KPLj/uin0UAN2R/wB0UbE9BTqKAE2r6CjA9KKjmlEMZkboKAJOKKpLcyOu5F4NNe5lXGVxmgC9RVGG6kdyjAcDNS+dJ2WgCzS1V86T0pPOk9KALVFVvNk9BR5kvoKALNJVbzJicADil3T+goAsUlVmedBuIGKnVtyg0AOqCT/WJ9amqGT/AFifWgcdy9RRRSEFFFFACN90/SueYfOa6E9DWSIGdjgVSdkTa7KYWnhCa00tP71WFt4196XM+hXKupkrAx7VZS0Y9a0gqjoKdU6sd10Ki2ijrU6xIvQVJSU7Bdi9OlFFFAhKWiigAooooEFVbxDJbso61apKGiouzucb0OKUMVIYdRW1dacWYvD37VTTTp2bDYArHldz0VWi1c3oH3wqx9KlpkaCNAg7U+tjznuLRRRTEFFFFABRRRQAUUUUAFFFFABRRRQAUx/uH6U+mSf6s/SgTMBupqW2/wBcKibrUkH+tFW9hQNSiiioKCo5lLxFQcZ71JUFzL5MLSelAGedLc9J3/Om/wBlTDpO/wCdaazIVDHuKd5qHoad2Iyf7Muu07fnSf2dejpMfzrZ3L60bhRdgY32DUB0m/Wk+xamOko/OtvcKTdRcDF+zaqP4x+ZpfJ1YfxL+ZrZ3UbxQBildYA/h/Wm7tZHZf1rdMgAzR5y+tAGD52rjqi0fatVXrGK3jKvTJo3gd6LjMH7fqQ6xfpR/aV+OsJ/Kt3fnvS5ouBh/wBq3Q6wn8qBq8veFvyrcwOtJtX0FFxGL/bJ7xN+VL/bSd42/KtnZGeqim+TCeqLRoBlf21D3RvyrSt51uYhMnAPrQ0EABPlr0Pam2gAt1wMUhlmiikoAWiiigCKWVIU3ycAVTTVLR2CgnJpNUGbQ1zUUa/I/HBppEt6na5zzUUk8cRw9OjOY1PtSPHG/LqD9aRRD9tg96PtsPvT/Ih/uCjyIf7gpiI/t0HvS/brf1p32eH+4KT7NB/cFACfbbf1o+22/wDeo+y2/wDcFJ9kt/7goAX7Zb/3qX7XB/eFM+x2/wDcFJ9jt/7ooAk+1W/94UfaYP74qL7Hb/3RSfYrf+6KAJxPCf4x+dS+9U/sVvnIWrQAAwO1AC0lFJmgBartdQLwXFTEjGKoNawZzsB/CgCU39qP4qb/AGhbetRfZrf/AJ5j8qd9mgH/ACzH5UAO/tK296Z/adt70vkQf3B+VIILf+4KAD+07bvmrkMyTpvj6VTNvbHqq1agjjjTbGAB7UATUUUUAIa0U+4KzjWin3BSY+g6iiikIKKKKACiiimMKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKSlpKACiiikIKKKKYwooooAKKKKACsy/FadZ9+OBTRnU2PNpxtmYe9RVavRtuWFVa0Etj//W6ulpKWgYUtFFABUUv3alqKXoBQMtr90fSsfVpiqCIVsr0FcvqT77k+1XTWpzYmVo2M+iiiuk84KKKKACpYZngkDp2qKik1fRlRdndHZ2twtxGGHXvVmuPsrpraQHsetdajrIodehrllGzPTp1OdXH0UUVBoFFFFMAooopAFFFFABRRRTAKKKKACiiigAooopALRSUtMAooopAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lFAC0UlFAC0UlLQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFMYUUUUhBRRRQAUUUUAFFFFABRRRTAKKKKACiiigAooooAKKKKACiiigApaSloAKKKKBhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRSAKKKKBBRRRQAUUUUxhRRRQAUUUUABAIwaj8qP+7UlFJpBcZ5cfpS7E9KdRRZBcTavpS4FFLQAlLRRQAUUUUAFFFFACUUtFACUHpS0xzhCfamhMjh53H3qeoLf7manpDCiiigQlLSUUAFFFFAwooooAKKKKBBRRRQAUUUUAFJS0UDEopaSgAoopaACiiigAorJ1PVI9OUZGWPQViR+KWLfvIwBTswudjRWbaaraXgGxsN6HitKgAopKWgAooooASiiigAoopaBCUjMqKWc4Aqjd6la2RAmbr6c1x2r6w91IYrdiIv500rg2at/4jWNjHaDcR3NVbLxFOZAtwMqa5WpIgWkAHrTkkkVS1lZnrCsHUMOhFLUFqCtuit1xU9QD3CikopiKVyfmqk1W7k/NVJq83E/xGdNH4SJqrXMohhZz+FWD1rB1GUyzC3XoOT9RSoU+eSRNafLG5SjyQZG+83JoY96kPpUDcnbXuJWPLFUfxGn0lFABUU7+XEWqWq14CYDjtSY1uZa8/Mepp1NQ5UU6szoDkHIrYtL8EeVPyKyKKUop7mlOrKDujVurD/ltb9PSswHnDcGrltevD8r8rWhJbW96u+M4as1Jx0kdEqcavvQ3MaginSwzW7bZBketNBB6VsnfY5JJxdmXGmtmi2bQG9aoGriSWxXDjkVTPXihA2JUEoxhhU9McZWhrQSdmbltJ5kCn04qesvTH4aM9q1KqL0Maiswqtdf6sAdzVmoXG+QDsKolbgiiNAPSpYEz+8b8KiwZHCDp3q8AAMCkDClpKWgkfbp5lyo7L1rZkPas3Tly7yVcnfYhY9qiOrKqu0bGBdv515jsvNOH3hVaDLFpT3JqxVha1kdIn3FPtQzBFLHtTIjmJfoKr3bnCxL1Y/pUCtd2JbFd26dupPH0rQqONBHGqDsMU+kipO7Fpkj7I2b0FOrP1KXy7U46kgU0Ra+hBpKbhJcHq5rXZtilvSq1lF5NqiVJL8zLEO/WpNZauxNApWPJ6mp6QdMUUgFopKCcDNADSctin1HHzzUlNiQtcvqJ3agi109cvP8+qgUug4/GjpUGFAp9IKWgCC5hE0JSuPlQqxB6iu3rA1S22t5qjg9a0i+hlO6fMh2k3W4eQ56dK264hJGhkEi9q7C2nW4iDr+NZtWdjol70eZE9VbqHzosD7y8j61apKow21KVrN5keD95eDWkj5HNYtwptZxcJ9xuG/xrQjcMAynIPSk0X5oubqqTnawepM1FMN0Z/OlYfNqWi5wCKZ5jVFG2+FTTqaFK9x/mNSb2ptJRYm47caTNJRQBz+rpskSYeuKvafNkeWe/IpNVj32xPpzWXaSFVVx2pyXUqg7pwZ1BpKajiRA470tCJatoFFJRQIWikooGLRSUUAFLUbyCPGe9PoAKKKKBFJ828ocfdbrWipDDjoaryoJEKmoLSUqfKftQC7Mx7uI2d1uHCOd1dPBcefbB+/eql/bC5gK9xyPwrM0u5Kv5L9+tS11NqbvFw7G7SUp4OKSmZBRnHNJRQIrD93cFexHFT57Gq9z8rJJ6HmpjhhmmVLox1FNB7GnUCAkAZPQVlO3nybz90dKmu5SSIE6nk1EAAMDoKuKIm7KxHNGJYyh+tS2U32iD7O5/ex8fWkLBRk1npBLPK11bsUI6H1okrjpytoxzefAZQ6Z39x1oim+xWZd/wDWv0HerCzam3y4X3OagghNzdNNId3l9KXkaXS1JdGkJR0cnd1wfetqsOVha3izrwGOGrbyCAw6HmptbQJvmtJdRaKSlpkBSEAjBpaKAMa7tSh3p0rOrqSAwwehrCu4BDJx0NUmKxmTrlMnqDWi91aRwh3fPsKqkBgQfSuZkj2uVPapn5HRSkramxcauxG23GB61jvJLK26Q5NGKXFTY0cmW45Nwwanzis4cHIqXzTjFMSLnmMvQ1Ms56OOKy9zZqVZMcGi49Hozb06QQ3ZQH5WFdLXH2cRmmRoz0PeuwwR1oRnWVmmwrKm41GM+xrVrKuf+P8AiPsaZnEsai37oL6kVsQrsiUegrEuf3txFF61uOdqYqZCp6RIwc80tHQUUxBRRRQAUUUUAFFFFADWO0hqgb5X4qyRkYqq3K4PUUyWasZDIDVR12PUto25MelPnXPNQnZ2NJq8bnJ6vG9rOtzBxn0rd03Uor+LyZvvYwQe9Q6nEJrPd3WsCW2ls2WZOAehFNfys0lJpKaNG/sGs38xOYz+lUiBJHgVuWWqRXafZrzqRjNULyxks2MkXzRH0pp9GElf34F3Rb482svUdK6SvPyxRlmjPIOa7Swu1u4A4+8OtZWs7HTzc8edFp/umkg/1dEn3DSW/wDqhTZC6k9FFFABRRSUAVJL2JJDHgkjrgVH9vX+635VDbH/AE6X6CtTAoegLYo/b0/ut+VOF6h7H8qsPgVWif8AelDQA77ZH6N+VAvIz0B/KppMCNvpWZayFFbPPNAF/wC2J6H8qaLqMEnB/KmGVsZApsEm5iGoAm+2R+h/Kk+2R+h/KpMCkOBQA37ZH6H8qPtkfofypeKTAoEL9ri9D+VH2uL3/KkwPSjA9KAHfa4ff8qPtcPv+VM2r6UbV9KBj/tcPv8AlR9qh96ZsT0o2J6UCJPtUPvR9ph9TUflx+lPEUf92gBftMPqakV1YZWmeVH/AHaeAAMCgYtU9Q/49X+lW6qX3Nq/0oW4MdYuTbLUd5Id0a+ppbD/AI9hSXiDMZ/2qXUfQjtsfamH+zViaSRCAgzmqsPy3rD/AGa0H5WmxFNnnUZIFTKSQCarjfI+H4FOlQouVNAFhnVPvfpSh0YEqaoMkq4k6g0m0sflGDiiwFi2yXduxq1nnFVbPiMqeo61b4oAZN/qmpIeYxSSOCPLXkmhUZFwpoAmqCX76fWnYkpkucpn1oHHc0KKO1FIQUUUUAB6GmQ/dpx6Gmw/dpgS0UUUgCiiigQUUUUxhRRRQAUUUUAFFFFABSUtFABRRRSASilopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFRy/6s/SpKim/1RoQmYJqSH/WiojUkX+sFWyYbmsaKU0lZlhVe6iM9u8S8EirFFMDMSxJUAytkDFIdPcn5ZmrSdljQu3AFVjeWqjlutGoFM6dcdpmpP7Pu+0zVdF9aHnfQb22/vU7sCj9gv88Sn86PsWojpJ+taAvLY8BqT7XBu27qLsRn/ZdTHR/1pfs+qAdQfxrQW5hbowpTcR4yGB/Gi7Cxl+XqwHRT+NMP9qr1jX862BOpAwRz70/ztxwCPzouFjDL6nkExL+dOae/2gGIZrd384yKN5Bxmi47GB9tuwQpip4vrgNgxnH0rcyfWgMfWgDEGoSEEmNgR2xQuoycZRgT7Vt5FHFAjFOpMHCbWx64oOpASbdr4+lbWAaTavcUAZA1RWYx7G5HpWja8W61KyoFJAFMt+IFFIZLRRRSAKKKKAILqJZoGRumK4vGxsKeFNdxL/qm+lcO5+d8epq0RI7K3OYEP+yKS5mMKbgM80lpzbR/7oqxwetSWZv21s42nFKt4x5wcVobVPUUnlx/3aYjP+3OE3Facb4rj5etXfKj/uik8mL+6KQFP7cAQpXrS/b0546Vb8mHrtFJ5EH9wUwKh1CMYyOtL9vixmrP2eA9UFJ9ng/uCgCH7bEeKZ9vi2knsas/Z4P7gpPs9v8A3BQBXN9H8vH3jV2ohBCDkIKloAKhnR5IikZwT3qaikMxRZ34GPNP5077Jf8A/PU/nWucDqabuX+8PzpiMr7He/8APU/nSfY7z/nq1ahdP7w/Ojen94fnQBlfYbv/AJ6mkFhdHOZTWt5if3h+dIJE/vD86AMaLT7twSZD1rSs43ijKOcnNWlePGNw/Oo4iDu2880AS0UUUDEPStCP/Vis89K0I/8AVikw6D6KKKQgooooGFFFFMAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApKWkoAKKKKQgooooGFFFFMAooopCCqN8PkFXqqXozFmmianwnnmooxujgVAls7fe4FbVzxKarV0qJxOs9kf/X6ulpKWgYtFFFAwqKXqv1qWopPvoPegC50XNcdctunc+5rsG4jP0ri5TmVvqa1pHFinsMooorc4gooooAKKKKACtvTLzafJkPB6ViUqkqdw7VE43RrSqcjud3RWdp92J49rfeFaNcrVj1E7q6CiiikAUUUUAFFFFABRRRQAUUUUwCiiigAooopAFFFFAC0UUUwCiiikAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lLQAUUlFAxaKSigBaKSigBaKKKACiiigQUUUUAFFFFABRRRQMKKKKBBRRRQAUUUUxhRRRQAUUUUgCiiigQUUUUwCiiikAUUUUwCiiigApaSigBaKSigBaKSloGFFFFABRRRSEFFFFMYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFGaACikyO5pC6jqRQIdRUfnRdNwpGniX7zUDJaKqte2yDcW4pv2+2I3BqBF2ioopUmXdGcipaBhRRRQAUUUUgCiiigQVFMcRmpKr3RxHTQMfB/qhU1RxDEa/SpKQ2FJRRQIKKKKYwooqjdajb2kgjlPJosIvUVzQ1qSeZkgX5U5J9qzE1S7a43NJmMN+lOwHcfjRXHG7uXD3TybVztUVElxexxh2m4duT7UWGdtRxXH3F/cA+UJdke04bHWtTRTL9lMsz7s85pWA3KK4+4165lvDbWY+6cZ9a6W0edod1yMNQOxbopivu6CjLZPHHagQ+iufuZ9WjZpETCL2rUtLhpYQ842se1FgLlFUH1GGOXymHHrUN5c3CITb4ORxzRYDVo6cmuOt73Ut24vvAPIrUt9XS63wuNpGR60WA5XXLkz3rKeicCsarN6MXUnOeaq1ZA9HaNw6HBFdNZ6/OrBZjuX3rlqKGrlxlY9Xgu4LhA8bDmp96dNw/OvKIrieE7o2INP+23W7fvOanlY7xPVd6YzuHFME0TKWVhge9eXfb7vBXzDg1EtzOilVc4NPlJuepG7tgQC45qGbUrK3O1359q8uLuerGkJLckk0cornoDeIbTynYcMpwB61ly+KHZNqxjJGM1yVFVyoVyea5muD+9bNQ0UUxC1taJZm4uQzD5V61jopdgo5r0PR7UW1sD3PWs5vob01ZOZrgYAHpRRRUkBRRSUxMzrk/PVNjVm5Pz4qoTxXm4n+IzqpfAivPKIYmc+lc5Hlt0rdX5q7qU291t1+pqqcAYFd+Ep8seZ9ThxE7ysRscDNMUcZ9aG5OKfXYc4lFFFABSMoZSp6GlooAxHQwyFD07UVp3MAmTj7w6VlAkHa3UVm1Y3jK4+ikpaCgqSKWSFtyHFR0UWuNNrVG7Dewzr5cw5qObTVb57c/hWNVqG7nh+6cj0rJ02tYnSq6krVEQyRSRNtkGDUdbyX1vOuycYqKTT4ZBut2x7U1Ut8RMqCesGY9IasSWlxFyy8etVc+tapp7GEotbofaP5dwB610Brmidrq47GujRtyK3qKI7mdXZMXoM1AWwC3c9KfK2FxTLdPNfzD91eBVMyXctQRlEy33j1qajNFAmLSE4BNLTJDhKGCWpp2C7bcH1qvqcmy3Kjq1X4V2QhaxNSbfcJEOx5qYdxz1kQRrtQCnnpS0hqgN22IMAJ7VBBme6Mh6LwKijk22eB1JwKu2sflRj1PJqH2GtLsv0UlJQSLWPqR82eK3Hfn8q16x4/32q57JkUPYcVqbYGAB6Co4PndpT07USttQkVJEmyMLUlLuS0tJRSGLUUh4xUlVydz00JlhRhRTqSikMXpzXLp8+sH8a6Vzha5q051Vz6Zoew4fEdOKWmjpTqBBUc0azRlG71JRQJq5xdxA0TlGqXT7s20uxz8prd1C185PMUciuWkQgn1FatcyM6U+SXK9jt1YMAy9DS1zul3+D9nmP0NdFWSZtOFhkkayIUboayIHazm+zS/dP3TW1VW7tluYiP4hyDVGUXyvyJwaDyCKy7K5bJtp+HXj61p0FSViG2ON8Z/hPFWKps3lXKt2brVw0kOXRhRRSUyRaKSloAguE8yEr61y8BxvQ9jXXHmuQk/c3zRnvzVLYmOkmblhN/yyY+4rTrmUcqwYdq6GKQTRhx+NRazsbS95cyJKKSiqMhaSiikAUZA5NFVLlmkdbWPq3X2oKSuQs5mkV+xbA/CtL2qkqq11sX7sYH51coKn2FpKKKDMWqFwvlyCRe/Wr1Q3ABiJPbmgTLEbh0Brnr6M2t2JV4V+TVqyv4HPlhufQ1cv7cXNuQOo5FHkVzcslInilE0QkFSViaVcH/UP1PStqki6sbPQWikopmZBcrugf1A4ogbfEp9OKmYbgV9ap2R/dtGeoY0FbxLVRyyCJC5/D61JWZI/2mfb/An8xTSIXcainmRurc0+mtmI/Nyp70ySVVHHOelaIzabdxkmZW8tenepWYRp5UfSo/8AVJz95qTaVGW6mkU9ERySeTbs+eegq5ZQ/Z7TLcHqfxqgY/tN0kH8K/erSvpNkAiXq/H5UupVuWKRRYCa3cyd6uabP50G09VOPwrOuW2Wpx1Iq8tv9hEM46S8N7VM3qXRjeDNOig+o6GigkKKKQkAZNACMwRSxrDuZDK241oPunbA6VUuohHwO9UhX1KHTmsK8Xbct71udqx78YkVvWiWxrT3KdFPA3DFMIIODUGoUUUoBJwKAAVaji7tSxxbeT1qemFxoVkO6M4IrXtNXIIiufzrKyFGTWfI+58ikyk+jPQldZBuQ5FZtyP9MjPsa56zv5rVhg5UnkVuyTCeZJFH8JoTuZThy6ouWw82/wA/3K1nO6Tb2Ws/TF2rJcN36VdjzjeeppPVkbIfRRRTJCiiigAooooAKKKKAFqtONjB+x61YodPMjK0BYjtG2yFfWtJhuGKwYZCrjPUda31ORmoluaU9Y2KDpvieI9xVfSxHd2jW043YJq/INr59ay9MPk3skR705dy6GqlTZSvdHmt8tCN69vaq9rqstrmC5BaM8EHtXc+xqpPp9pcj96gJp3T3FGnyv3TjZ/JyZrU5Q9vSnWV4bSUSqfkPUVqT+G4/vWz7T6Vjz6XfW2Wddy9zSeqLhLkd+h3HmpPD5sZyCKfB/qhXEadqb2reTJ/q29e1dvAUaJShyMVBs42V1syWiiimQFFFFAGRac3kp9hWtWHG8sUs7xDJxVF7u8YeZvxz0oa1EnodM/IqkBi4B9qtI2+NSe4FV5OJVNJDZak+430rOtoxJE/rurSYZQ/SqFh91x/tUAOim2N5UopOFuOO9PuosjeByKqQy75AzcYpgadB5FJkHpRQBGvp6U6m/xmnUAFFFFMQUUUUAFFFFADNxp6E7qbg1IgwaQyWiiikAVXu13WzgelWKTrwaaAyLa8jigCEHdnpirEiSzhGXoDmrXkQhtwXmpKAMtzJFdByvBGK085GfWlpKLgRSR7x8vBqLypWPznirVFABxjFGBRRSAj8pc7l4JpfLB+8c0+igBFVV+6KWiimAVBN1T61PUM38P1pDjui8OgpaQfdFLQJhRRRQAh6Gmxfdpx6GmxfdoAlooopgFFFFIQUUUUxhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABUFx/qjU1QXP+pNC3E9jDp8f3xTKcn3xWjJhubJpKU0lZFhRRRQA1kWRSjDINYn2aD7eYW+7jIFa1xcLbR+Ywz7VlT30JnSZvl6ZHtVIC4+l2jcYxTBptrjaR0pja1ZbsBv0p39rWf979KNQ0Hf2Zag5xTTpdoe1KNUszxupTqVoP4qNQ0GDSrVeBSf2Xb7dualGo2hAJbrTv7QtDzvpagVjpcWQQxGKP7LHVZCDVn+0LTpup3260zjfRqIojTJlOVmOaQafegEeccmr4v7TON9L9utf79GozN+xaj/AM9T+lO+yah2kNaH221/v08Xdt/fFPURlC11Mcb6TyNUX+PNa32q3P8AGKd9pgP8QoAxvK1UfxUbdU6bq2vPh/vCnCaI/wAQ/Oi4GA6aoBlj8uea3oRiFaSZ0MJwR1FPThAKQx1FFFIAooopgIQGBU9DWLPo0TktE20nmtukoTCxXtomihWNuSBimXF1HbY355q1TWRX+8M0AUBqUB7H8qeNQhPTpVnyYsfd6037NBjG3imBCL6BjgE5+lAvrc55PHtU32eHOdtN+ywZzt60CGi8tz0NL9stycbqPssAOQtJ9lg67aAF+1wH+KgXUB/ipotbcdF60n2S3/u0AP8AtMB6NUqsrjKnIqt9jtv7n61NHHHENsYwKAJKSlpKQBS1E80UZw7YNRi6gPRqBjpYfN6nFVTp8Z6sasi6t26P0pDc2+cb6YiqdNhP8RpP7Mh/vGrYubcnhxS+fD/eFGoykdMh/vGmHSouzmtESRt91gaPNi/vCjUDO/spB0kNWrO3a2jZGOcnIqfzY/7wqXIIyOaBBRRRQMQ9Kvxf6sVRNXov9WKTH0JKKKKRIUUUUxhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUlLSUCCiiigAooopDCiiigAooooEFVrsZhNWainGYjTQpbHFXY/eVVq7ejElU6647HmM/9Dq6WiigYtFFFAwqNuZF+tSVGf9ctAFp/8AVt9K4uX/AFrfU12xGVIri5xiZx7mtaRxYroRUUlLW5xCUtJS0hhRRRQAUUUUAT287QSB1rsIZVmjDrXEVp6feGB9j/dNZVIX1R1Yerb3WdTRSAgjI6GlrnO4KKKKACiiimMKKKKQgooopgFFFFAwooooEFFFFIApaSimAtFFFIYUUUUCCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigYUUUtACUtJRQAUtFJQAtFFFABRRRQIKKKKACiiigAooopjCiiikAUUUUwCiiikIKKKKACiiigAooooAKKWkoAKKKKYBRRRQAUUUUAFFFFABS0lH1oAWiq/2mDdtDDIp6TxSZ2MDigCWimNIi9TVaK7WVyijp3oAuUVRmvVThBuPcVG162QqjBIzmiwXNKiqts0rAmQ554qnc3jCQxIcYGc0Bc1qKy4WuGhEpbOeah3ykmVHzg4xQFzZyO9G4Hoaw7+52FY1P1p+mjIZ85BosFzZyM4zUTzxR/fbFY/mlJpJOvpVW5JYKT1zzRYVzdN7bAZLVY3rs39utc3bpHLKinjnmtW+lEcPlKOX+UUWGmPGowFto+maG1CIKW9DisZZGAMTLyBkfWlZSqIvqQTTsK5rQ6gJX27cA96juNQaOXy413Y61HHH8+6Q5wMgCqSqZpXkPY0DNOS9ZI1kC/eFNhu5nkAb7pqhcEny1XjIpLWE/al5ycEmgReuL3Nx5CttA6moGvJ9pCnBHemM0D3TmbjHSoD91yOlADvtU7H5n5rRtG2wtLu3VjIqt96r4DxWewDJYmgCEytIx3nPNLhlRkb61EsUjDhDU4huCD8h6UwIEK4IIOcdc1NISQOf4aRbW5wR5ZqT7JckYKHpikBVPKAH0qaFttsxxU32GfYF20q2NysZjHQ0XCxf04YtwfWr1V7WJoYQjdRVipRctxaKKKYgooooAKKKKQhKp3Z4Aq7WfdHMiimHVF1OEFPpq/dFOpDYUlFFAgooopjKt7dLaW7TN2HArz68u2uSZX6nt7V2Guo5tgy9B1FclK9qjpLsLbsDaDVIRoxRiz0h5CMSSggfjUcNvEtkGZfMkPvjBrR1oB9Ph8sYz2/CsSOBoIBIxPzHb16UhoveWkdnmc4bdkL61HEkXktdyAqyngetS21wyOqCPdH0JNLc3cgkZVXdHnHSgY2QRX0AuJh2wF+taivPYaXkpsAXAFVbaMSf6URsEfAB71avbxLrTXwNoU4piOS092luA0ZxIc4NdxAwiX/SG3OB9K43S5lhlDqucZ49a1Lu4N4u4gxyL0FSbpX0N+z1NZxhhjmtfIIyK5DTBcvH8rDB9q6ZNlvCquaDKoknZE7YZSp71h3y/Z084gu/8IHFbKyxyfcOacQD1ANBJgTSyRWAnmTf329xUEdpJcQ+dFkZGQK6UohQ7sY96RQEXgcUXA563hklgk8xdkiAjHr71SsIbuz3yzDajAnJrrDMigjjpXJapqvmRm1QYGetMOhy87mSZ3Jzk9airTsLVLq6WB+lN1SCG2u2igGAKq5NupnUUUUxBRRRQAUUUUxCUUUUALRRRQAUUlLQBZtJo4Jg8i7hXdaVqC3YKKMAdK88rX0m5mjnWJCFU9TWc49TenK65Wei0U1WDKCDnilqSWLSUUhNUiJGRcNmU1UkcIhc9hmnzNmZqydUn2QiMdWOK4Jw5qtjdS5adzLRjLI8x7nj6U5jQi7EC+lNbnivWSsrHmt3dxqjv606iimAlFFFABRRRQIKpXVvv/eJ1FXqjkfYB6nihjTd9DFVs8HrT6tXFsT+8j69xVJWzweDWbVjdO4+ilooGOSN5DtQZNDI6HDDFXrWTyIi6j5j3qGWVpWG7rRruOy2KtOSSSM5Q4q3Jbqqj1NV3gdOWpaMbvEtxapMnEg3CrgudOuP9coBrDK1GVqXTRrHESW+ppanb2aRB7U9TyKnsn3W+T24rCYHHWr1qZHj+zx9zkn2qoqxlUkpal3m5l2J90dTWkAsa7V6CmRRLCmxfzpR87ewrQ5m+xIvTJp1FFBIUxhuZV9afQg3XCClLYuG5s9FH0rm2bzbp5OwrfnfZEW9BXPW4+Qse5NC2J3bZNRS0h4GaYy1bAyOE7LzWznBqjYx7Yy56mrlQOXYsZ4opo6CloJBjhSfQVmaYu6Sac9zxVy6fZAze1RaYm20UnvzSZUdmy2/zyqnYdas5qvDyWlPfpSSSZ4FKw7khlGakDZGapA1aT7tNoSY52wKiTrQ5ycUgPNAi1RSUtIoimPAFYFjzqcvsTW25y1YWmnOpT/7xolsVS3bOkBp461Fmng9KBIfRSUtIArA1GywfNjHFb9NZQwKt0NVF2ZE43RwTqQcjgiug03UhIPInOGHQ1Bf2PltuXoaxGDI2ehHQ1Uo31Q6VS3uTO/pKwtN1MPiGc4PY1vVmmVOnYzb2080edFxIvNJZXYnHlvxIvBFaVZF9Zvu+1W3Dr1HrVEQl9llm7QtGSOoqeCQSxK4qlZ3qXalG4ccEU60Jile3bp/DSfctLRxZfooopkBRRRQAVyOtgw3STD8a66ud16Lein3xQgXxIqxuHUOO9aNnP5Mmxvut/OuetpDE3lP07VrLhhj8qb1Rcfdk4s6SiqdnP5qeW33l4q5SJkrOwUtJR9aCSOaVYYzI3aoYVNrbPez/wCsfp9O1MhQ6hdA/wDLKP8AWnXj/artbdPuR9aVzoiuVajrVCkW5vvMc/gas0dOB2pKaMZO7FoopKCRaQjcpX1paKAOYl06GWRkB2ODwaSO71DTjsnBkj6Zq5q6SR7bmHqtLa3iXcWGA3DqKtK5PM0tdUZ7XEJm+0W7de3pXUwyrPEJV71z1xpcE2Wi+RqdYzT6cPJuBujPRvSplFrU2jOM48q3OioqOOWKZd0TZFPpGbVtxazbf5L2SPsVzWjWedqamC3AKigqHVEt7J5MexfvPwPxqskflqB37/Wl/wCPq9aY/cj+UfUU52G41cTObS0Gu6qh39KzBE8Z+04ynYVZ2m5f/YFXI8SSYP8Aq0GDQ2OKtqynGN585jkdqWRwoZ27VHcsLd/NiGYmPIqCQfapkt4zkHkn6UXshxhzNdjR0uErGbh/vPUd04kuSB0StGRlt4C3QKMCsSPOzzG6mlEVR82qGyjzpooB0J5rrLm3FxZGAdduBXN6enm3hc9E5FddGcjNZyep1JKNoowbKUyQ7G+8h2/lVuqcyfY9SDdElwPxrR2DNNMyqRs9CLIAyaquWlOBwoq40IaoGUqcVSZkxoAHAqne/czVwkDk1l3Um4GqSEih61maiPlU1pDpVG/GUAolsaw+IzR0qcBZF561WXpUisVORUmw/wAg5qwiBelKp3DNOoAKazhRk0ySULwOTVRiWOTRcLDnkLn2qOlqWGFp2x/COpqRklrCZH3n7orbDbcN6VEiqihV6CrMMLSAsfuitErIwk7s1rOQTRiJBhR96tOqGnIFg3DvV6s0E97BRRRTJCiiigAooooAKKKKAFp8f3sUynJ1oY0Zt9GYZw46NWtaSeZEDTbuAXFuVHUdKoaZN8xjapeqLj7rNWccA1hynydQRx/Ea6CUZjIrntSG1o5PQ1O8TSHu1o+Z1PUA0VHC26JT7CpKDVrUWggEYPNJS0Ctc4zWrBfOBiGCaq2moXemSeTKCUrpdWT5BJ6EU8Wdvf2gEo5x1qpWe5FPmhFuOvkWLW/t7tA0bDPpVyuIuNDvLRt9oxYDsKItdvbNvLuoyQKnlaLU4S8mdvS1hW/iCxm++2w+9aK6hZN92UUrjaK9l/x8yg9xU0ljbSNuIqjDe2cdw7GQDNXBqFmekgpvclbFoAKAo6CoJ/lw3vQLy1PSQUjzW8i7d4oSGR3F40aFYxklc1kxahJaIWkQnec1qRiJJNxYEYxUxEDDBwaAC2uBdQ+ZjGaja1QnI4NTAxqMLgCl3L6igCOOMx980/c3YU7I9aKAGKD1anUUUwCijFFAgoo49aMj1oAKKTK+oo3J6igB1OXrURliHVhTftVuOrikBaoqob+0HWQVEdUsR/y0FFh3NCisttYsB/GKhbXbIdDmnYVzZornm8Q24+6mfxqBvEi/wxmjlYXR1FJXIt4jmP3UxVdtfvT90gfhRysOZHbc0Vwbazft/GPyqu2oXj9Xo5WLmR6GSo6kVG08K/ecCvOWuJ26uajLuerH86fIHMehtf2afekFQNq9gv8AHmuByfU0U+QXMds2vWa/d5qs3iKIfdjz+NclRRyoLnSP4ik/hjxTYNZurqdY24XNc7VywOLhfrSktC6Ws1c9MT7i/SnUyP8A1a/Sn1AMKKKKAEPQ02L7tOPQ02L7tAEtFFFMAooopAFFFFMAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKQgooopjEqvdn9yasVVvD+5/GmtyXsY1Kv3hSUo+8Kt7ChubNFFFZFiUUUtAFG/z5SkdjTmhtrjZvX5gopbwyiH9yu5vSshWvRdpK0ZC8A1SA1Tp9n0KUf2fZ/3KhkvZ9xCQEj1zTfttz/zwb86NQLH9n2mc7KPsFp/cqD7Zc5x5DfnS/a7n/ngaQE32C06bKP7PtP7lQfa7r/ngfzpPtl1/zwNAFj+z7M87P1pP7OtP7n61B9tu/wDngaPtl3/zwNMCb+zLPH3P1pP7Ms8Y2/rUH2y9/wCeBo+13uM+SaBEx0yzP8J/OmnS7Mn7p/OmfarzvCaU3V2P+WRpAL/ZVmP4T+dJ/ZVpjGD+dJ9ruv8AniaT7ZcjH7k0DFOkWp6Z/OmnRrc9CR+NL9tuQeYTSi+uf+eBoAYdJSJfMDHgitdPuisZtRmkUxtEVGRzWwn3BQA6iiigAooooAzL7UUsZArrkGpra9guxmI5PcVh+IP9av4VFoXFww/2aq2grnTyyNGu5V3e1U/tzjOYiMVoUYHoKkZnDUUwdykULqUROCMVobFPYflSGND/AAj8qBFL+0bb1pf7Qtj/ABVb8qP+6Kb5MX90UwK32+2Jxuo+32396rHkxf3RSeRD02igCD7dbZ+9Si9tzkhulS/Z4f7tKIIhn5etAEQvLdv4qWK4jmYqhzipPJiHQUqoifdGKAHUUUZpDMW8jR7sBhk4NUFhIkOR8o963JLPfP54bBwR+dV/7OIJKyYJ60xGRHEfIMnepJbREtxJ1Le9ai6aqLtZ8rTzZxyp5bHKjoKdxWMw6ejjfHnIGetMWCKUfMMEcda2YLaO3JAyc1XfTYJGJ5Gfei4WKenW4SdyRnj1qjJGfOdRnr61vW1lFZklSSTTX023kYyE8mi47GP9nVbZnbOe3NdBY/8AHqn0FVG0yNwVDYGMVoQRCCIRA5xSbBImooooGFXof9WKo1dh/wBWKTDoS0UUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUlLSUCCiiigAooopAFFFFMYUUUUhBTJBlCKfSNyppgzjL8YeqFaeojD1mV1R2PMluf/0erpaSloGLRRRQMKYP8AXin01P8AXn6UCLdchfJsuWHrzXX1zmrx7Zg47irpPU5sSvduZFFJS10nnhRRRQMKKKKQBRRRQAUUUtMDastS8tfLm6eta63ts38YrjqUVk6aZ0xxEkrHaC4hbowqYEHkVxAZh0Jro9MmLxFWOcVnOFtTopVuZ2ZqUUUVmdAUUUUhBRRRTAKKKKBhRRRQAUUUUCCiiikAUtJS0wCiiikAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAwopaSgApaSloAKKKKACiiigAooooAKKKKBBRRRQAUUUUAFFFFMAooooAKKKKQwooooAKKKKBBRRRQAUUUUwCiiigAooooAKKKKAFqlqDslsxXrV2oLiEXERjPGaAMnT0iJLYIcetUVaTzWjTPzntW1bWLQBssCWptrpot5fNZtxB4p3FYkmxDbbepxxVe1Gy3aVutXbm1W4ABJGKcluiJ5fakM5xiclx3NaEdqRavNMeSOKuHTbYnJz19aulFMfln7uMU7isYFvPN5HlwnJ61EqLcMI+rk8/SugjghiGEFOWONW3KoB9aVwsULv/Rbfy06Zx+FUkiEm2OHOB3rfIVhhgD9aAqr90AfSi47HOPaPJKylTgd6t2kUkSyIFIH8NbPHpRRcLGIba4C+UF696fcWMjkNGcYFbFFFwsYtvYTLMJZCMDtVu6tGnkV1ONpzV+ii4GTJp8rKAGGc5J9qtNYxvGqt1FXKKLgUYLJYSTncTSHTbfcW5596v0UgKrWVu4AYHjpTorWGFtyA596sUUDIvIhJ3FRmnCKMfwin0UANEcY6KPyp2F9BRRQAYHoKKKKAFopKWgQUUUUDCiiimAUUUUAFFFFABRRRSEFZcxzcAVp1lZ3XH40+gL4kao6UUUUhhRRRTAKKKKAMbXIGmsyVz8vPFcVOVt5wbf5gFBYnkV6Y6K6lG6HrXLavojy5ltOBjlfWmmKxZv9tzpIm6BFz+lYVrbmRCVycpnk1a06ZpLdtKnVlY5HNU4bO4lkaCNioRsE+1MB1nA3nbZWzt5wKsB2maSS4wkSNhR6ntUenLGl3KFJZkBHJp8aIzPc3Lbgh4UetIoluZpLeFZxxGy52+9WhKLjRfOZcFhmsa8+0ZW4mI2yfdX09q6RIN+jiLoduaBHJafJHFcI7fdGc1dvpRLcK0Qyp6Y71UsZ4LXrjLAg7ua6DRLIOftD8gfd/Gl1NdtSrBNNY24jX7xq7Bc3DSo04yPSuglt4pE2lRn6ViQ2M0DyXEp3t/AopWB1E1qNsrhIpJTL8m7oDV+G8MsZlKEKO9ZNxZtahbu4O7Jycdqrt9oZ1k3bYD2pkux0Z8i9tyFbj2NZd9cvY2flwtuzx9KggfNw1vaH5SuSayrxJZZcSHbEOMnuadibEljFNeTBVJIxyc0zXQkUiW6Dp3q/4bUI8oByMnBqnrkJa/TsuMkmnfUTF0S0Zpmu26LxWNqUm++lY+tdDo0peaWMfdX/AArDvzHLdSnpzxR1BrQzM0tdnYaVbnT90gyWGc1yLRneVX1wKadyWrEVBVl+8MV2Ok6OYB51zhi3QelVtSvYJHa0RAAP4sClcqMLnLovmOEHetptBuli80MG74FZfk/NhTz7V2miLdrATdfd7ZptlTppI4RhsYo3UUld1f6dZyxmZV3OegX1rD/4R++KeZ+OMc07mVjBpyK8jbIxuPpWguk37MVEZGK7DSrIRWw82PbJ6kUm7FKPc4KWCaA4mUqT0zUddzqOhy38wlVwoHY1lTeGrtGAjYMKFITic5SjOeDir9zpd5bMFKE56EVCLK7JwImp6WBXvod3o8qSWihTnFatYeh2kltbZlBBPY1t1kjSe4ZqF5B0FLI2FNUN3NaRRjNmc5zK31rn7p/tF5jsg/WtaeXyxI57ZrDtwSGlPVjmsaMLzcgqy91IlJplONJXYcwlFFFABSUtJQAUUUooELVNjvuMdgM1c6c1Rh5Zn98UFLYmkk2MAe9RXFosw8yLg0k331Y9M1OVeJ96cqeaXqWl2MjLIdsgwafWq3kXA2t1qlJYyx8xHIqbFKXcbCFbIdttSjyIzuJ3n2qoVlHDIaAkp6IaXLcrm7Fo3GW3d6hebdyTTltnPL1ZSJU+5GSfeqWmxLfcpqkspxGv41YNqkKF53GfSrXl3MnAIQfSpI7FAd0hLGgnm7mRFbSXD/KML6mt6GFIE2rUoAUYAxUU0ywrk9T0FOxLk5aBI5zsXqanRdqhar28bY82T7zVaoJfYKKKKACnW/NyPam1JajNwfalIqHUk1N9tsR61nou1AKs6m25o4/UmoKfkRHa4UqrvcJ6mkq9ZR5YyH8KTLXc0VAVQo7CiiipJJl6U6o06U+gDO1N8QBR3Iq4g8q2VB2rNvjvnji/GtKQ8hR2oe9il8I5m2qI17VHSUtMkWrKnC1VqZjhcetJghAcnNOpopaBlkHignAzUanilc8YpDIieGPtXPaQ27UJj6k1vSttgdv9k1zOgNuunPrmlLZF0up11OBplANMlPUsGikByKWpAKKKKAGuiuu1uhrnL6x2E46GulprKHG1qqMrEShc8/dWibB/OtvT9WKYin5XsasXun8EqMiucmgkhO5eRVSjfVFUqtvdmd+rLIu5DkGlxXEWWpS2zfKcjuDXW2t9Bdr8hw3pWadty50uqM6/09t32q1O2RecetQJei4UOflmj6r610ZWsbUNNE/72H5ZB6d6q1yITtpI043WWMSL3p1c3pt81vJ9kuxtPYmuk9xSRUo21QUUUUyArH1tc2oPoa2Kz9UTfZt7c0At0Ycln59ok8X3lFV7Wfd8rcMOtbuknNqAaoarpxib7Xb/AIgVnGVmd1amparckV2iYTR9R1HtW9G6yxiROhrlrS4Dgfka0Laf7JNtb/Vv09q0lZao5d1Z7m5VC4d5pBZwcs33j6U+7uTGFig+aR+gFaNlaJp0BlmOZGGWJqWyoQt7zIp3i0uz8qP7x6e5qnaQmKPe/wB9+TUCs2o3ZuG/1aH5fetKmhTkJRRRTMQooopgFLSUtICOWNZYzG3euNmhltJiY+Cp/Ou2rOv7UTJvUfMKaZLdtUUbS9S5XB4YdRV4+hGa5aWJo28yPgjrWrZ6gsg8ubg9jWlyJU01zQLZt8N5kLbW/T8qnjvnjOy6GP8Aa7UpHcUoYdHAI96TimKNVrSRfR0kG5DkVjasGMyJH95iBT/IKN5tq+1vQ8j8qqyXBa5V7sbSOjdqho6Kcot3TNVEFvAI+/U1UIad/LT8T7UyZ3mdY423Anlh6Vo+V9mjDfgB3Jpt2Qo03e7GSKIlW3hGSe9MuiqILZDwPvH6VIz+QNx5kI6elZ3zzOUTkn7xpxXUmcr6Ib5qeW7SfcHAqLR2CSs0w2tJypPtSTQ/aJVtI+g61t3lmr2gEfytGPlNS3d3NdIxt3M/VJt5S2Xv1qpKwUBew61VgmaeVpZPvDipnQyFYx1c1WyuEIe8k+hr6ZF5dvvPVq3LZ8jaaoqoRAo7CpYm2uKysHN71xurQGW1LL96P5hUVlMJ7dW7jg/WtggNwehrmLbNnfyWjfdb5h+NJGsldGzUcoBGakrOvLnb+7Tr3q4o55PQp3E2SUWqMp+Q0+oZz8mK1JSI6qXXLIPXNW6pzHdOo9M0mXHcyRwSvpTqWYbZT70lZo38yxC3OKJZP4VqAcUUxCGm0rURxtO+1enekV5j4omnbA6dzW1HGsa7VpIoljUKtWY4zI20VaVjGUrj4ITK3sK05dscG1elORBGu1ahuT8oHqRSZMd0jStV226Cp6RBtQLS1CCW7CiiimIKKKKACiiigAopaKADrwKtogC89abGmOTU1Q2axiMX+6awrlDZ3nmD7r1utwwNVNQg+0W5x94dKaE9i4jb0yO4rD1EboGPdat6ZP5kO09VqrcfM0kZ7ilFboqTs4yNewffbKfartYujyk2rAclSaryapdhyNuMH0pJaHRP4mdFS1zB1a57D9KcNUuT6flTsSa+pJutTTNKbNsB6VQfUpZIjGy54q3pBzERQ+gQXxGrUMttBMMSID+FT0lK4mr7nPz+HbGUkoCpPvWFfaBPZp5kTlh7V3tc3qupeTceQ4JUU02LlSOKJOcE80uW9TW+smnNC25cOelVDBa+XnPzHtV3FYy9zf3j+dODuOjH86110d2GScZHGaY2lSLKIgwLEZouFjN82X+8fzpRNL/fP51fl0q4iyc5x1xUC2Ny43KpxRdCsyDz5v75/Ol8+Yfxn86layuV42GmG0uQM7DRdBqJ9quB0c/nS/bLkfxmozBMBkocUwxyDkqaALP266/vml+33f8AfqoVYdRRQBb+33f9+k+3XX981Vop2Asm9uf75pv2u4P8Z/Oq9FAE5uZz/GfzppnmP8Z/Oos0maQEhlkP8R/Oml3P8R/Om5FGRTAXLeppKTIpaACilAb0pwRz0U0XAZRUnkzf3DUwsbthkRtzRcLFWir66ZfN/wAszUo0e97rii6Cxl0VtroV2wySBUq+H5f4pFFLmQWOforpBoUKn95KKcdL0uPl5f1o5gsczmjIrqPs+ixc78/jTvtOkRD5V3UXHY5cBj0FSrb3D/dQmui/tewThYf5Uw69GP8AVxY/AUrhZGOunXrdIzV220y9ikErqQBUra/OfuKBUX9tXcrBDjBpSvYum0pI7uH/AFS/SpagtTut0J9KnqBy3CiiimIQ9DTYvu089DTIvu0AS0UlLQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUhBRRRQAVSvT+6/GrtUb7/AFYqluKWxk0DqKKO4q3sKG5tdhSUD7oorMsKWkopABIUFjwBVQX1k3WQelWyARtPSqZsrbdkLTQCi8sx0kFL9stf+egpfscH9yl+xw/3aYDftlqf+Wgo+22o/jFP+yQ/3RR9mi/uD8qQiP7daf3xSfb7T++Km+zxf3B+VH2eL+4PypgQfb7X++KPt9r/AHxU/wBni/uD8qPs8X9wflQBX/tC1zjcKQ6ja9N1WfIj67B+VH2eL+4PyoAqvqNsvQ5oOoW4OOvGat/Z4sY2D8qBBHnOwflQBn/2pb+WXC9OMUv9qQbVO3rV8QxcjYMfSk8qH+4PyoAoNqsCvsK0v9qW6uFx1q8YoepQflS+TBjlB+VAGZLf200bIg5yK1k+4KheGBYyQoH4VMn3BSGOooopAFFFFAHLeIP9av4VFoX/AB8t/u1N4g++v4VBof8Ax8t/u1fQnqdZVN5LsP8AKvFXPes83k+4r5LkA4zUlDxNdd0NL9ouf+eZqP7XL3hej7W//PJqYh32mfvEaX7TJ/zyNJ9qY/8ALJqT7Uf+eTUAP+0yf88jR9pbvGab9pb/AJ5tThOx6xtSAPtP+waX7SP7ppDK2f8AVmkErZ/1RpgP+0L6U9JBIMgYqMSnvEalQ5H3dtADqimkEMZkboKlqvdRtNAY060hlVNRSRA+wgGrKzpIhZO1U41vY4liCr8oxyKmginQOZQCW6YGKYjPGoXJk7Be2RUv9ouYyVZSR7VGtvdxt90EH2p/2a7b+FR+FAD/AO0ZBMIiRg9eKdNeTqA0bqSfaoDbXYkR2UHHoKaYLsSho04HPIoEWLO7muXZZsHaOwpHv3WQxCMnHeksbe5imcyDCtRNaXhuC8RAGMcijQeop1IR8FCeM1owSieMSDvWS9pelSDtORjpWjZxSQQ7JOtAK5booooGFXYP9WKpVcg/1dJj6E1FFFAgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACkpaSgQUUUUAFFFFIAooopjCiiikAUdqKKBHK6muDWNXQaqvWufrqhsebU+I//0urpaKKBi0UUUDCmxczN9KfTYf8AWMfakIs1nanD5lvuHVea0aRlDqUPQ04uzJnHmi0cNS1Yu4DBMVPTtVautO+p5LVnZi0UUUxBRRRSGFFFFABS0lLQAUtJVi3haeQIKG7Dim3ZEI9q3dJDAkEVoxWcESgbQT71ZVFX7oArnlO+h30qPK7sdRRRWZ0hRRRQIKKKQkKCx7UDFoqOKUTLuUY5xzUlABRRRQIKKKKBhRRRQAUtJRQAtFFFIQUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lFABS0UUAFFFJQMWikpaBBRRRQMKKKKBBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAwooopgFFFFIAooooAKKKKACiiimAtJRRSAWikooAWikooAWiiigAooooEFFFFABRRRQAUUUUAFFFFABRRRQAUUUUDCiiigAooooAKKKKACilooEJS0UUAFFFFABRRRQMKKKKYBRRRQAUUUUAFFFJSARjhSayYvmuK05TiM/Ssu15mpvYUfiNiilpKQBRRRTGFFFFABRRSUgI/Ji3b9o3euOaBDEoO1QCetSUUwMpNJt4pHkjyC+c/jSQaPBBFJHndvO7n1rWooAwE0VGdXumLbegFacvlqpQ/KpGM1bPQ4rmrpNXmZlwuzPy0IGPstEtFLNJ8/PFbTeVZoFUfL2xVXSorqG32XWM9sUlzaXU029WAX0p9Qdy+rlxkU4gg5OKrQRXEUIjJUkd6iEV+P4lNIRdkjSZCjjINM8iHYE2jA7VFIt5tAQrmmAX+OSlAySO1hiYugwTwaWa1t54vKkXjrxUR+39tlH+n/wCxQAttZW1mMQg8+tE9nBdjM36U3N/jomacTfA8BKAIbawtrPd5Ofn65qBtCsZGLtnPWrjG9xwEzSh7zGCFzQFyVYkSD7OnTGKyrfRLS3l87lj71d33g6qKTzL0/wAIoAt4FYr+H7SSQyFmyfer4N6ey0uL0/3aAuVrfRbS3kEgySPWtYqhXZ0FUfLuz3FJ5FyerUAWkijj+7UvmL3NURazd2p32V+7UAWvMjHORTWmT1qD7Ie5pfsi9zRoMk+0IKT7SopBbJ6mnfZ46NBAJY5Dzg49afheoAqJo0jYbakoGhc0hNJSGgCtcN2qmTgE1JK2WqtI2ImPtWqMWc1qEvyFB/E2KYBsQL6CoJT5t0F7Dmp260U1ZGVR6jaKKK0IEooooAKKKKAENLSd6dQIZKcRsfaqsA/d59TU9wcQt9Kih4jFHUvoPZQw2movLlUYVuPepqKLAm0U5YnCbifyqaBbry/MhO8elT4BGDVRnazlDW7ZHcVL0KTuWReFeJkxUy3Vu3cCnrqdrOoWePB9aY9pYT8o4WhMGl1ROGjPTBqQe1ZT6eU5jl/WqbvNCcb8/jRzMXInszouaazonLMBXNm6uCPvVCzux+ZiaXMNUzauNSSMYi5PrUVij3Mhnm6DpWVGhlkEajr1rq4oxFGEHahajlaKsiWikoqjEKWiimAVLaf696iFS2f+uepZS2ZWvDvvAP7tNprnddSN9KdTEtg5PA71tRII4wo+tZlsm+Ue3Na9S9xvsLRRRQIclSVEvWpCeKBPYyW/eaiB/dBrQPJzWdbfNdyP6GtChbsuWyQUtFFMgUVIeWx6VGOtOXnmpYx9FJRQBKhpGOTTQcUvU0AUtTk8qyY+vFc/4fOLk/jV/XpflWAfWs7RTtvdvsamfQ2o7M7KikpKZkWEOVp1RRnqKkpFC0UlFAC0UUUAHXg1mXVgsgLJ19K06KadiZRT3OFubEhjj5WqisksD91I7ivQJreOYfMOawrrTiOgyKvSW5MZyh6C2Wu4AjuRkeoro45Ip13RMCPavPpbV4j8oyPSkgu57ZsxsR7Gs3FxN04T2O2vNPhu1+YYbsRWVHPd6Y3k3Kl4+zDt9ada68jYS5GD6ittZLe5TCkOD260XTJtKGnQbFLHMm+Jgw9qfWZJprwv5tk20/3T0pyXzIdl2hU+o6UC0exo1Xu13W0g/wBk1KkkcgzGwb6U50LRsuOooJMXRz+4I9DW0VDDDDINY+jKcSr/AHXrVnuba3GZXGfQHmoZ6V9mcvqVg9nJ9ptxlCeRSRTm5jEMS73PYdq2vMu9Vzb28eyI9WcVr6fptrpkZ2cv3Y0K9rGMlFy5kR6XpYsl864O+Q889qzdSvHvrgWVv0B+Y1JqGpSTP9ks+WPDN6U60tktkx1Y9TVJGVSZNFGsMYjToKkooqjASilpKACikDKTgGloAKKKKBBRRRQBk3tpnMsY+ornJ4Sp3pXc+xrEvrUIfMXoapMjWLujLs9RaLEc3K+tbilJV3xnIrmpEVZMHo1PU3Ni4K9D27Gq2LlCM9UdFnFIypKNrjOar293Hcjjh/SrIJQbz26VWjORxcXYzVtJ45ibM5K87TWzDqkUimO5XZMBgBumfWp7TZaxmaTr94n29K514/7TupLlvlHIT+lY7vQ9Dm5YrnZZnaTG1eWY8mpZZVsLUIv+seqVvPNYv5V6u5OzCrFvEdU1DzCf3cdOUugqdNfFLY0dNtvIh86T771Hqd20cQgj+89XrmZY8seAvSsazja7nN5L0H3RRaxHNztyZXm057SFJ4fmPVhUlgftF1vxgJW8OmDTVREzsUDPpS8i1PQdRS0lIg0IW3JWNrMJTy71OqNz9BWjbttbHrVm4hE8Dwn+IYqHudEHdGPNeoIFdDkuM/nWMWLHJ6mqcW5Jnt5P4DgfSrVbxWhyyWotVpjkgVOaqucyj2qhodVAHdM7Vcc4UmqUP3dx70FLYr3KZYH1qsvoetXrgZTPpVVl3AMvWs2tTVPQbRSbh0PBoZgBnNK5VhuC7hF71twQrEgA696pWMP/AC1bvWqBk4qorqZzl0HIpY4FasUQiXHeo4IRGNx6mrFBmLVeX5pUSrFQRjfeKPQGk9iqfxG0aSlpKRIUUUUAFFFFABTgMmm1Kg4JoY0iM9amiTPJqHvVxBhRUsqCH0UUVJoIRkYoU5HNLTB8r/WgXUxCPsN7/sPRdfLcBuzVe1KDz7clfvL0rJeQy26yd161ce5EtrE2jOVmki/H9a6AxxtyVFc3YEJfn/aUV0jAlTt61KOqfRkYjt89FzTTFbluiisSaOeJ8/McmnknC7g2RTsTc2ZIoxG2xR0qppJ+8PeqbSkE8tyKn0g/vGFS0VF3ubtFFFAgrB1qyjeE3BIBFb1QXESTRlHGQaExWPMuPypwJFda+kWTMSdwqnLoaKhdXx6ZrTmRPKYP2iYHIc/nThdXCtvDfN61rPoFwgBV1yfWq76LeqcYB+lF0FmVPt90cgnr1qVNTuo1CLjApjadegkbDx7VCbS7Gcxtx7UaBqWhq1yCTgHNPGrzhPLwKzTDOBzG35UwpIOqkUWQXZpDU38gwsoOe+KX+0yYfKZBkdDissg96bkU7Bdmv/amVKtGv5VmEgnNMyPWjK+tFhXHDGRnpWsh0rywGDbvrWPlfWm5WiwXNlTpbZBDD8afGNJH3t351h5WkytKwXOizo47Nz71GG0kPyGx9awcrRlaLBc6FpdIxwrUoutLVMCMk1zuVoytFh3OiF/poYnyj+lL/aliBgQ/oK53IpeaLILnRf21bjpCPyFMOtrnKxL+QrB2t2BpwSQ9FNFkF2bTa5IRgRr+VNOuXWMKFH4VkiGc9I2/Kni1uj0jb8qLILsvtrN63cCoX1K8cYL1GtheN0jb8qmXSr1v4CPwo0DUgN9dEYLn86iNxOesjfnWmuh3regqdfDt0fvMtF0GpgmSU9Xb86aST1JrqF8NP/G4qwvhqEfec0cyCzON4o+Wu7Tw/ZL1LGrSaRYp/Dn60uYOU88AJ6CpVgnb7qE16OllaJ0jX8qnEUS/dQClzD5TzhNPvH6Rn8qvQaHeswYjFd6MDoKUE0nIpKzuRwRmKFYz1AqakpakG76hRRRTAD0NNi+7SnpSR/dpAPpaKKYBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRSEFFFFACVQvz8oFX6zr89BVR3FLYzaO4ooq3sTDdGwn3BTqZH/q1p9ZmjEoopaAIpUMkTIpwSOtZS6bdr0m/U1o3F1DbAGXvVX+1bP1poCL7De9pRSfYr/tKP1qX+1rP1pf7Ws/WjURD9j1D/AJ6j9aPsd/8A89RU39rWfrR/a9n60ARfY77/AJ6ik+x33/PUVL/a1n60f2tZ+9ADPsl7/wA9BS/ZLz/noKP7Ysvej+2bL3oAPsl7/wA9BSfZLz/noKX+2bP3pP7Ys/egA+x3feSk+wXJ6y/rS/2xZepo/tiy9TQAn9nzY/1p/Oj+z5cf60/nR/bFl6mk/tey9aAF/s6T/nqfzpP7Nk/56n86X+1bP1pf7Us/71ADH02Qr/rT19a1oxtQKecVlNqlqQFQ5JNa4GBSGLRRRQAUUUUAcz4g+8tV9D/4+W/3a372wjvceYSMelRWmmR2chkRicjHNO+gramjURlQHG4VN2xWS1hOGcowIY55pDNHfGf4hQDHn7wrK+x3SptBGcg1Ibe484PxtxTEaJaMdSKUNGR8rA1mPa3O1hkHPSnQ208bBjjHegC4JovMKFhn0p7SRgcsKzmtJ/OaRcc077PcHGdtAGgZEyPmo3r/AHhVAW1x3K8U5YLkE5K0AXQ4PelzmqK290P4lqRIZxJvdhjHSkBZqOWQwxmQDOKlpCARg8igDOi1S3kQM7BSexqcX9qf+Wgpxs7U9UH5UCythzsH5UwLAbeMg8VWuJ2hxgFs+lWQABgdKMDrQBUhuJJTgqy/WnyyrBgytgHpVrFY+olzIgUBuehoAs/ao5PusKm+0wgbWkUGsVGlWZQYwAT6Uh81naQIpAPpQBuJPFIcI4J9qlrDtAftBkkXbgdBWk95CpAznNAy1RSAggEdDS0CCrkH3Kp1cg+5SY+hNRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABSUtJQAUUUUCCiiikAUUUUxhRRRSEFFFFAzC1VflNczXW6muUrkz1NdNPY8+sveP/9PrKKKKChaKKKAFpsH3mNOptv8AxGkIs0tJS0DM+/tBcx5H3hXLMrI21uCK7ms6809Lkbl4atITtozlrUebVHK0tTzWs0Bw6/iKgroTTOFxa3CiiigQUUUUALRQAT0GavW9hPMc4wPek5JFRg3sVI0aRtqjJNdTZWgt0y33jTrayithwMt61drnnO530qPLq9woooqDoCiiigQUUUUAFBwRg0UUAAAUYUYooooAKKKKACiiigYUUUUAFFFFAgooopALRSUtMAooopAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFLRSUALRSUtABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQMKKKKYBRRRSEFFFFMYUUUUgCiiimAtFFFABRRRSEFFFFABRRRQAUUUUxhRRRQAUUUUgIpy4jJj61VtWuGY+b0q/RSsWp6WCiiimQFFFFMAooopALRRRQIKKKKACiiigAooooAKKKKACiiigAoopKACiiigZXuTiI1QsuZfwq1etiMD3qvYD94TTewQ3Zq0UUUgCiiimAUUc0YNABSUuDRg0AJRRRSAKSlpKAClyaSigAyaKKKACiiigAooooAKKKKACiiigAozRSUAHNLk0lFAgooooGJS0lFABRRRQAUUUc0AJRRSZHqKAIpAS60tOZl7kVCZYx1IphdElQyttUmmm5hHeqU9zG/CmqSFKSsRk5NVrptsDH2qUMD0IrP1WTZan3q3sZGBbfMzyn1xVg1HbrthHvzUlXHYxe4lFFFMQlFFFABRRQaAAUtFL3oEVrviE02P8A1a/SnXn+q/GkT7i/Sl1L6DqQkKMmkdwi7mrPd3mOTwvpQ2OMbkslwznbHwPWogMe5oAx0oqTS1gIB6imbAOnFPppYDrQMb+87MaYVPUml3M33RSFGPU0gIyaQmnbSKkhiM0qxj8aBmrpdvtUzuOTwK16aqhFCDsMU6rSOeTu7hRRRTELRRRQIKlteJn+lR05TsmY+oqWNFGPlmb1NS1HF938TUlMZo2S4Uv61dqOJdkYFSVIMKWkooEKOtOkOEJ9qZTbhsRMfahClsUbAZ8x/U1fqnYDFvn1q5Qi57i0UUUyAJwKkHAqE/eUetTUhi0UlLQAopy9cntTKhvJfItWfuRikD2OZvWa6vmC87cimaafL1EA+9WtOiLEzN1aqY/dan/wKs5Svc7oUuWy7nbGkpM0VZxMVDhqsmqdWlOVzQwTELYNOBBqM00HFA7k9FRBzS76VguSUVFvpNxp2C5NScHg1DuNAOKLBcgns45BleDWBdacwydvIrqs5prAEc1SlYhw6o4X7OTwvX0NAN1anKFl+nSuku7BZP3kXDCqdvON32e8XPYE0NJ6ouFSRHba9LHhbgBh6jk1tpqGn3i7WI+jVRm0a2mG6I7TWJc6PdQfMmCPapsWnCXkdUdNgf5oJCnsp4o+zXkfCSbvqa4qG5u4W2CRgfQ1uR3t+qgyDcvqOaWg3GS1Wpfg0e63MTNsDnJ2mtSDRLKNt8xMrf7fNYa3ksnCsVPvxVoJfuOJh+DUuXzF7bo0dBNPBax4yEUdhXNXF9dag32ezUqndjxQ1ncMcy/vPY81ZQ3US7Y4gB7U1C2pMqsnpFEQsGtow0B+cdT61aiukkADjaw61GZb/wD55iq7LeyHPlJn1qiEn9o1Bz0penXissW+pHqQo9jSPCIxuurg/QGkVyl97iFOrbj6L1qhPfSN+7gX5jwM9RVdCJ22WMf1dhg/hWpBbJD8zHc56setIrlS1YWsJhj+c5Y8k1YoopkthRRRQIKKSigBaY6CRCjdDTqKBHI3kJUMD2q1Zhbu3MEv3l6GreoRDefR6x7NzDchT3NaEx2aGXFrNbSZXIYdDWzp08l6Asq/c7+tXbyPz4gg6noalKRabZlx1x+ZqWaJ3V3uZ2q3GcWkXU8n6UsKhYlVeMCqNqjOz3M3Jfp9KtO+yPA6ngVa91XMal5yUETQwtf3iwvzGo5/CtK40mW1Y3GnHGeSvarWnW32eAO33n5NWL++WytjKTz0Arnu5O522UFyo5Wa6kvStoV2yfxfhWyiLEojToKyNJZZpJLic4lY8A1s4weau5lNJaIWikpaCAooqKWVYV3N+AoAmB2kGtWNtyhqxlLNHvYY74q9ayZGKllwdnY5jXYfs16lyo+VsA/U1BnPI710utWv2myb1T5vyrkLWTfFg9V4NaQYVVrcsE1VU5djU7nCk1Xj+7n1rQzWxDdNiPA70ijaoFMnO6VUqSkinorEcgypFU06VeaqK9SKh7lx+EGVW60wxIBUtNbpQyk9TXhAEYA9K0raL+M1nw/cH0rZhGEFBmS0UUUEhTbMbrlm9KUnAJp+mjId/elIqG7Zo0UtFIkSiiigAoopaAAVZxtSoYxk1Yf7tSy0tCsvLCrtVIhlqt0mVDYKKKKRQVHJ03DtUlGMjFAmJwy81zcsZglkgPRhxXQxHgqe1Z+qQloxOvVOTVx3Intcw4JCt3G3q2K7NTXC7sTROOm7Ndgxl2K0YzxU9TpWsIsmkZCwBqTZGecCseSUtwRtNadu+6MZ7UmgQskMW0naM4rL0zi5ce9bL/cP0rG03/j6f60PYcd36G9SUtJQIKzNVkuI7YtAM+uOtadIRkYNCA4zT9R8uRhdklT0BrYuLqznhOHwF5q9Lp9nKdzxrn6VWfR7Jl2hdo9hTuIlE0LLGzMOAMetK01w771wEH51ntosby71lYADAAqNtMuy+EnbaPegDYjn3IXIYDOKd5ibMkcVkyWuqfKsbjavvSN/aakAohUdeetKwzZCQuoO0fjTGt7duDGp/Css3V8h3PEMdgtTHVAkY3xvuPXAoAttY2bcGJfyqI6XYn/lmv5UJqVs2BhgfcVIt9bOdoYZo1Ah/siwP8A/Kk/saw/uD8qu/aIP76/nTxJGejj86Lsehnf2LYf3f0pP7FsP7v6VqBlPQincUrsLIyf7EsP7v6Uv9i2H9z9K1aWi7Cxl/wBjWH9wflS/2PYf3B+VadFFxWM3+yLD/nmPypRpVgP+WS/lWjiii4FAabYj/lkv5U8WFmP+WS/lVzBoxRcCsLS1HSJfypwt7cdI1/KpsUUAMEUQ6IKXag6AUuR60hZR1IFADvpRk0zfH/eH50nnQ9N6/nQBJk0VA1zbocM4/OkN1bqMmRfzpgWKKqfb7T++Krtq1mhxkn6UWC5p0tY7a1bjhUc/hUZ1d2/1ULfiKLCujcowawvt2pyf6uJR9c0Y1qXsi/Q0WHc3sGmGSNfvMBWILDUZP9ZOy/Q08aODzLM7fWiwrs1DeWoODIufrU0ciyDKdKzY9Ls4zu2hj7itKNVUYUYFJjJKKKKAEPQ0kf3aU9KSP7lAElFFFMAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooopCCiikoAKzL8/Mo9q1Kyb/AO+B7VUdxS2KNJS0lWyY7mxF/qlp9Rxf6pakrM1e4UUUUCI3iil/1qBsetRfY7Q/8sl/KrFFAFb7FZ/88l/Kk+xWf/PJfyq1RQBW+xWf/PJfyo+xWf8AzyX8qs0UAVvsVn/zyX8qPsVp/wA8l/KrNFAFX7FZ/wDPJfyo+w2f/PJfyq1RQBU+w2f/ADyX8qT7BZ/88l/KrlJQBU/s+y/55L+VJ/Z1l/zzX8qu0UAUf7Nsf+eY/Kk/syx/55j8qvUUAUP7Lsf7g/Kj+y7L+4Pyq/RQBSXTrNGDKgyPar1JRQAtFJS0AFFFJQAtJS1A4n3fJjFAEtFV9t17fnSbbr2/OgCzRVXbde350bbr2/OgC3SGqm269R+dG269vzpiLdJVTbd+350my89vzoAt80c1T2XnqPzo8u89R+dIC5zRzVLyrz+9+tHk3Z/j/WgC5g0VS+zXJ6yH86T7HOeszfnTAu8d6aXjHVgKp/YGP3pnpf7Pj/idm+tAE7XNuvVx+dQG/i6IrMfYU8WFqOqA/Wp0jjj+4oH0oAr273LkvKAF7ClntlnIJJUj0q1RQBnfYACD5jEj1qP+znGdsh5961KKAM6OykRy7NuBGOagbTpArKmDk557VsUlAEcKNHEqN1UYqSiigAq3b/cqpVu3+4aTGieiiigAooooAKKKKACio3fbwKRZCetILEtFFFMAooooAKKKKACiiigAooooAKKKKACiiigAooooAKSlpKACiiikIKKKKACiiimMKKKKQgooooGUb9N0WfSuU8kbjmtzUpyz+SvQdayql1GtEdNPCQfvTR//1OspaSloGFFFFAxabbfdJ96U9KLX/V59zSEWKWkpaBhRRRQAjKrjDDIqlJp1tJ0G36VeoouyXFPcxW0ZD91jUf8AYx/vVvUtVzsz9jDsYQ0Yd2qwmkwL945rVoo52NUYLoV47S3j+6gqx04FFFTc0SS2ClpKKAFopKWgYUUUUCCiiigAooooAKKKKBhRRRSEFFFFABRRRTGFFFFABRRRQIKKKKAFooopAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUtABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUxi0UlFAC0UUUhBRRRQAUUUUAFFFFMYUUUUAFFFFABRRRQAUUUUAFFFFIAooooAWiiigQUUUUAFFFFABRRRQAUUUlAC0UlFABRRRQAUUUUAZt8egpNPHJNR3pzJU2njgmqfQKezNGiiikMKOxopKAOflGrPK3lNgDpzTfL1sfxfrXQdWFOz2ouBze7Wx6H8acLjWRyUB/GuizRmgDnxe6sOsS/nTv7Q1IdYR+tb2aM0AYI1S8xzD/OlXVrg/eh/Q1uZooEYn9ryDrCfyNL/bGOsTfka2qMD0oGYw1lO8bfkacNYi7o/wD3ya1to9KNq+lAGV/a8H9x/wDvk0v9r2/91/8AvmtTavoKNiegoAzP7Wt/7r/980v9q2391/yrR2J/dFHlx/3RQBn/ANq23o/5Uf2pbej/AJVoeXH/AHRSeVF/dFAFH+1Lb0b8qP7TtvRvyq75MX90Unkw/wB0UAU/7Tt/Rvyo/tO29G/Krnkxf3RR5MP90UaAUv7TtvRvyo/tO39G/KrnkQ/3BR5EP9wUaAU/7Tg9D+VN/tOLsD+VXfs8H9wUeRD/AHRQIoHU17Ifypp1NuyVpeTD/dFHlRf3RQBlHU5eyU06jOeiGtjy4x0FG1fSi4WMb7bcnsaabm7bpurcxRTuFjBMl2eufxpuLx+lW76Z9wRDirSD90CaYrXMo2123Uj86abGY9WrXwG+71qpLOI+o5oCyKBsNoyzmqjQIDgMTVl5mfr0qJR3q0SyPyAOjEVm6sxEaQ5zk1s1z+oHfeonYYNNkMcBhVX0FJTm602rMRKKKKACkpaKAEpyjJpKljXjNADD1pO9OYYNIOtAFW9/1Q+tNyFjDHsKdefcX61SmfftjHQCpbLSukROzTNuP3R0FLRSEgdaRqLTSwHWozITwlATu3NIA3M/3eBShAOTzT6KAClpKKYEbHFa+mQbVMzdTxWSiGaVYh3NdSiCNAg7ChETdlYdSE4pagVxJKVHQVRkkT0UUtMQUUUUAFE52sG9aKS5GYwaQ0QIMLUiDdIo9aYOlT24zMKTHE1x0FLRRSEFFFFABVe8bFuasVSvziED1IoFa7SJbQbbZB7VYqOIbYlHoKfQthy3FooopiGDmXHpU9V4zmV6nqSmLRSUUxDhyaxtXkMjJar3IJrZUgfMegrCT/SLx5j0UkCok7K5tQhzzSLtvEEUKOwrA1AeXfK/qc11sUf7stXNaunzLJ6VlHY7pP30zp423Rq3qKdVWxffaRn2qzWy2POmrSaENTRHtUJpUOGpkInb1plSHkVFSGLRRRTAKKKKBBRRRQA4GnZqOnA0hgwxyKpXFnFcrn7rdjV+o2XacimmJrqZUE8ts/2e66dmrVyQMg5FMZI5l2SjIqJVktflY7oz0PpQNq5TvtNiugWT5XrLtbqWyk+zXQ47GuoxkZFVrqzhu02yDnsaTSe5VOo4EJigmGcA1Vls/KQyRSFcelUD9s0t9so3RdjV+SdLi0LRHOayd0zvU4Ti2ZsF5qrk/Z2LBfWpF1nVN/lHg+5rT0d40gKAcjg1Zmsbe6BWQYbsa1UW9TBxaipWKAvdYb+FD+NNabWj2UfjVaeO609ts2Wj7MKtwXYYAOc+9SuzFZNXRnXJ1hUMkkhAHXBq9ptraXSebK5lbuGrQ+SQYPINYlxay2Ev2q0+73FDRS5WuXqdSqoi7IxtHoKWqdlex3kQZeG7irlUnc5WmnZhRRRQIKKKKAEpaKKAEopoYNnb2paAKOoLui3j+GudugY5xKO4FdVMu+Fl9RXNXI32u7upNWtiVpI6G0AmCSdgKzL24N5dCFfuIc1BBetHY+Wn3m4p1tD5a7j948mhLqJ+4ixwOB0FWrC1+1XHmP8AcT+YqtsaQiJOrcV1Ftbi3hWJfx+tTUfQ1w8bLnZMzKFLNwqiuNuZ21O7wP8AVIePeruuX5I+w2x+bqxFcxY3htZSj/dzRBWKm3ZtG1cWu9hJF8rr0Iqa1v2LeRc8N6+tSB1cblOQarz26TDn73Y1co9jmhVtpI2qKwLe9ltH8m65Ts1bqMsg3Kcis/U2a6oSSRYkLv0FV4Ymlbz5v+Aj096YP9Ln/wCmafrWh9KCthfrTIW8uXZ+NPqKUYxIOooIemptYEiFT0YYrzqWM2eoyQngMSRXf20m5P1rlvE1vtkjvE7YBqYOzN56oy7g7UqNeIx9KbO+8JjvzTJW2x1uYpbIgT5pGep6iiXagqWhbCk9SNqoj/WMKuHrVLOJTUS3NYLRokprdKvQ2jP88vA9KlvI0SHCDFIasnYltzlB9K24vuCsK2P7sVuRfcFMhklFFFBBHKcRk1b05dtsD61QuTiPHqa1rZdtug9qmW6Kj8LZPRRRQSJRS0UAFFFOUZNIaRPGuBSy/dp44qOb7tSjR7DYBzmrFQQdKnoY47BRRRSGFFFFAETfLID/AHqc4DqVboaimOVyOoqRTuUGn1JWqOKukMEhiP8ACdwrtLF98CH/AGRXO65AQROvfg1p6PNvsx7cUSWppRd6bRcvUAwcUWp+Uiku33ACqyOV6U7aDTsaEzhUP0rL0n5ppG96W4mIiZie1P0Rf3PmHvUy0siqevNI26KWkpAFFLSUAIay/LvPNyzfL6ZrVqEpLnI5FMDLvmv0INmgIxzk1QN7q0ZAaBTn3NdJlh1FHmGi4WOc/tHUVlCtAApGeM1ahv5pS5MWNvTg81s780mQaAOfTWbhmZWgIx7GpP7WbYrGE5PbBrdyKMj0oAwbq9jjKZg3F/Y1Hm2WfC2+Qe+K6Dg9RS5oA52SOFH3tB8vsKgni8+dFtQyDvxiuq3UZouKxzq6XfqzbJmAI45po0vVl6XB/OukzSZp3CxlPZ3ph2JKd2Ouazhpes97g/nXTZo3UgsY1tZ6jFu86UtxxzVZ7PW92Ul49M10OaN1AWOe+y64P+Wv61Zgg1ZQRM+T9a2M0ZoCxz/2XW1kyJcr9asNBqpYYfA+tbGaM0BYwjZ6vv3CY49M0j2OquuPOI/Gt6ii4WOfXS9R6NcN+dWJdMnljCNM2RWxRQFjAGiyd52qwNGi4JkJIrXozRcLGWdItmOWOaf/AGVZ4wVBrQzRmi4WKa6bZL/yzFSLZWa8iJasZooHYRYoV+6gFSDjoKZmn0ALk0UUUAFFFJQAtPXpUdTDpSAKKKKYCHpRH90UHpQn3RQA+iiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiikIKKSigAooooAWse+P7wVr1jXh/e1USZFWkNFFaMUdzWh/1S1LUMH+pWpayNXuFFFFAgooooAKKKKACiikoAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBKKKKACiikoAWkoooAKKSigBaSiigApKKKACikooAKKKKAEooooEFFFJTGFFFFABRRRQIKtW/3TVWrVv8AdNJjRYooooAKKKKACiiigCv1bmnsABSOhzuWkCu3WkMlX7op1A4GKKYgooooAKKKKACiiigAooooAKKKKACiiigAooooAKSlpKBBRRRSAKKKKACiiigAooooAKKKKBmTdae80pkQ9aWHTEXmU59q1aKXKjT20rWuf//V6yiiigYtFFFAxG4UmqkV/BGNknHNWpP9WxrmJz/OmloQ3ZnWpNFIMowNS1wwZl5U4q3HqF1F0bI9KOUfMddRWDFrQ6Sr+NaMV/ay/dbmpsO5dopAQeQQaWgYUUUUALRSUtAgooooAKKKKACiiigBaKSloGFFFFABRRRQIKKKKBhRRRQAUUUUCCiiigAooooAKKKKQBRRRQAtFJS0AFFFFABRRRQAUUUUAFQzsVTIqaq9z9ys6rtFlw3KfmyetL5snrUdFeZzvudlkSebJ60ebJ61HRRzy7hyol86T1o86T1qKijnl3Fyol8+T1pfPl9ahop+0l3DlRN58vrS/aJPWoKKPaS7hyon+0SetH2iSoKKPay7hyLsT/aJKX7TJVein7WXcXIuxY+0vS/aXqtRR7WfcOSPYs/aX9KX7U3pVWij20+4ckexa+1N6Uv2pvSqlFP20+4ezj2Lf2o+lH2o+lVKKPbz7i9nHsXPtXtR9q9qp0U/bz7h7OJd+1j0o+1D0qlRR9Yn3D2cS79qX0pftS+lUaKf1iYeyiXvtSelL9qT0qhRR9YmL2US/wDao6X7THWfRT+szD2UTR+0x0faYvWs6ij6zMPZRNH7RF60v2iL1rNop/WZC9ijS8+L1pfPi9azKKPrUg9ijT86L1pfOi9ay6Kf1qQexRqebH60vmx+tZVFH1qXYPYo1fMj9aXzE9ayaWn9afYXsUau9PWl3L61k0c0fWn2D2Pma25fWlyPWsjmjJp/Wn2D2Pma+R60cVk5PrRlvWn9a8hex8zWpayNzetLvf1o+tLsHsfM1qKyvMf1o82T1p/Wl2F7FmrRWX5snrS+dL60/rUewexZp0Vm+fL60faJfWj61EXsWaVFZ32iX1pftMlP6zEPYs0KKofaXpftT+lP6zAPZSL1FUvtTelH2o+lP6xAXspF2iqf2r2pftQ9Kft4dxezkW6Kq/al9KX7UnpT9tDuHs5Fmiq/2mOl+0x0/aw7i5JdieioftEfrS+fH60/aR7hyPsS0VH50frS+ZH60+ePcXKx9FN3p60bl9afMu4WY6ikyPWlyKLoVmLRSUU7gLRSUUALRSUUALRSUUCFpKKKBhRRRQAUUUUAFFFIeATQIxLo5kNXdPH7sn3rOnOXP1rTsRiI/WqkFP4WXaKKSkMKZLny229ccU+mS/6s0COdjOroQxG8c8UiXGrRgs0Wc+9dBGD5YBp9BVzBkvdR8rPk8n3pqalqHAMH61v96UkD0oFc55tVvRJxBx35qc6rcBd3k1s8HsKOPQUBcxRrEpHMP86auttnDwkfnW5x6D8qXC+g/KgLmI2uBSB5R/I1J/bMeCSh49jWvhD1A/KjCH+EflQBiprsTMFKEZ9jUp1m2GeDx7GtXZH/AHR+VIY4/wC6KAMr+27XGcN+Ro/tu19D+RrV8uL+6KTyov7ooEZf9t2vofyNL/bVp7/ka0/Ki/uik8mH+6KNBmd/bVn3z+Rpw1iyPc/kaveRB/cFH2eD+4KNBFIatZH+I/kaUarZf3j+Rq39mt/7gpPstuf4BRoBW/tSy/vH8jR/aln/AHj+Rqx9ktv7lH2O1/uUaBqV/wC07P8AvH8jSf2pZ/3j+VWPsVr/AHKT7Fa/3KNAIP7UtPU/lSf2pbdj+lT/AGG1/uU37Da/3KegEP8AakHak/tOPstTfYbX+5R9itv7lGgalc6oo/hoXUw7BQnJqx9jtv7tAtLdTuC80aAIblx/DTTeYB3LirBjj7isq7lUny06CmiWVpJDI5c1eS5iKBW4IrNpKqwky+10g5j61Rd2c5am0UwuIaUdKQ0tNAxRXNufMvmb0FdETgE+lc3B80kj+5o6mb2LBpKWkqzISiiigAooooAOvFWgMDFQRjLZ9KsUARSDvUY6ippB8tQjqKAK17/q1+tZh+Q5PQ1qX3+qH1qoMFRn0qWjSLsikZR0Xmm7Wbl/yqy9uPvJwahJZThxipsWmmKAB0ooopjClpKKAFprHAp1MKmRxEvU0AaWlwcmdvwrZpkUYijCL2p9NIwk7sguJRDEXP4VX08ExGRurHNZ2o3HnSeSnQVs2ybIVHtR1KatEnpaSlqjMKKKKACnyDdCaZUyjKEUmNFMdKs2v+tqqKtWn+tpMaNWikopCFopKWgArOvzwi+pFaFZ15zNGtHQa+JGiBgYpaD1ooQmFFFFMRFByzGrFVbb7pNWalFPcWikopiILyTyrdsdW4FVLOMrGoPU8mkvG864WAdF+Y1pW0eWz2FYVXqonfhY8sHN9S6q4j2+1c3qse6JvY109Y19HuDrTiEnqmQaO+612+latYGiMRvjPrW9Vw2OfEK1RgabSmkqznLKHctRtw2abE2CVqRxkVJQnWlqJWxUlMBaKKSgQtFFFABRRRQA4GndeDUdKDSGRsMGlDkDB5FSkbhUBGKYiRcKPl6UvBqKlzQBIypIuyQZBrDuNHKsZbJtp7rWzmlBosNO2qOYtZJ9PmY3KHDHqOa6CG+tLkYRsH34qySGGGAIqhLp1pKc7cH1FS0+50wxLWkjTKeYhjkw6mufudHngJmsuV6lasCyuYf+PeYgemKsJcapCfmHmj8BUtMr2kHsYsN1tba2UbuprUSUOuDyDReSxXQzPbYb1B/wrFbfEcxEr7YoQnGL2ZPPbyWkv2uz6dWWtuzvI7yPev3h1FY6agoX96Dn1xVCS6ihnFxaEqe4weaPQJQurNnZUVnxanbPGGckN3GDT/7Qtz90k/gau5zcrLtGKpfbHP8Aq48/jiqM+oTZ2IfmP8I/xoBK+xryyxwrukIAqikst6cp8sXr61Wt9Pklbzr059FrXAAGAMAUDdo7bgAFG1eAKWikpkBWA8fMtv7Z/Ot+si8Hl3iydn4px3JkZFl94xt/Ca1icDNZQHk3xX+9W1bRG5mEY6DrVXshuHNJGlplsRm4fv0qzqV6tlblv424UfWrcjR2sO5uFQfyribm4a7ma6l+6OEHtWKV3dnTN2XKisPMBJPMsp5/Gr1zopW3EkX+sA+YetWtLtySbqUdfuj2rZzTvqZzdtEcNa3T2z7H+73BroEkSVd6HIp2oaWlz+9h+V/51zaSz2cpUjBHUVpFmEoKWqOgljSVdjjNZjSXOnqyKSY2/Sr8FzHcLleD3FSsqsMMMiqauRCbg9S7YvC9uvknPrVuuWaGW0fzrQ/Va17PUY7n5G+V+4rJqx1K0leJp0hGQR60tFBLV0Fo5VjGe38ql1S3F1YuvcDcPwqm58uZZB34rZXDrjswqHozSGsLHlsZJYKeq8U+c7mCCrF9AbXUZFPAJJFVVO+UvW176EruTChulKKYTk1ZCGmkskV7klucUp6UWH+vas5dDen1Nc1VuxmE1bNV7kZhNMzbtqV7U/uxW9D/AKsVz9ofkregP7sUkOZPRSUUGZUuDmRE9SK6BRtUD0rnh896q+ldGal7l7QQlFFFBAUUUUAFWIl4zUAGTirgGBipZcV1FqGb7tTVBN0pIqWw+H7tS1HF9ypKQ1sFFFFAwpjtilY4FVWbJppESY7OaID1Q9qizSq22QH1psUH0C+hE9sy9wOKxNDl2+ZbnqM10xwRiuUK/YtT9A/9aJbXLpO0nHubLE55pKVuuap3V0lrGWPXsKoryRW1CfJW3Tq1dPp8XlWqr7Vx1jG9xOJ5OrGu8RdqhR2rJu7N7WgkOooooICiiigAooooAKTAPWlooAYUU9qb5foalooAh2Gm4IqxSUAQUVMVBppSmBFRmnFTTcUAGaTNJSUwFzSZopKAFzSZpKSgQZNGTSUUAO3GnhgaipRQBLuFG6milpDDJo5oooAKWiigAooooAKKKKAFpwptOFADqKSigAopKWkAqjmpaao706gAoopaYCHpQn3RQ3ShfuikA6iiimAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRSEJRRRQMKKKKYBWJdn98a26wrk/vmqokSIKDRSGrEtzWt/9StS1Dbf6kVNWRq9wooooEV5LqKOTynODTmuIlTzCeKztUACq2OT3rJ3hovLDcf1p2JudOk8TjcrdaXzos7d3NcvHIEPrgU4Mm4N3znNFgudVTS6g4JpIzmNTnPFY1wB5zbjg5pWGbmQec0ZFc75kiqVzwTxVmFpQCo707AbXFJWKruqjaanj8x5DJuxjFKwzTooByM0UAFFFFABQSB1OKKydXZhANvqKYGpvT1H50b09RXNQWs0q7x0+tK1rcsyqgOO5zRYR0m5T3FLkeorm57W73KI847nNTSw3MWwRseeposM36KwLp7iHYiMRnqasWf2hyxZ8oOnFKwGvRWWjznejtyOlUYbm4xlnzyRQB0NLWHby3Ek+fMynpikvLmfzcRttCjNOwG5Rmuea8uCgcvtJ4FX7CeSWJjOclTjNFgNKkzWG91czzskB2qpxmliuJzdCF2yO9FhG1mlrGluLiS6NvAcBeppi3dxHOIWbd60WA26M1h3N3OGzC2AelSxzXCBWlbdmiwGvRmublurlHcBsAc1Obm48lWDcmiwG5mioNzCEMTk4rEjv7jeQzcbsUWA6KisQ3U73IjVuMVb/ANIKnDc5osMv5FFYt0Z4sMr9qoJc3Tx72k79MUWEdTRWDe3FwiKyPjPWpxLOYD82Wx1osBrHijj1Fcxby3MkjoX6Vnm5uVJUOetOwHbcUVyNxd3HyMr0+3urp5kzJwTgiiwXOrpMigVmamX8tNjYywBpAaW5c4yKdXPiNRdRqpOcZrfoAWrNt0NVatW3Q0mNFmiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAEooooEFFFBIAyaTYwoqE3CA4p6yI/Q1CqReiY3FofRRRVkhRRRQAUUUUAFFFFAH/9brKWkooGLRRS0DIpuIWrmJutdLcnEDfSuZm61UdjOW5DSGlpppiCkoooAmjuJ4v9W2K0YtZnTiQbqyKKLDudTDq9tJw/yGtFJopBlGBrhKcruhyrEUuUfMd7RXJQ6rdRcMdwrfsr9LsEAYYdqlopO5fopKWkAUUUUAFFFFABRRRQAtFJS0DCiiigAooooEFFFFABRRRQAUUUUAFFFFIAooooAKKKKACloqrdTmBfl6mgqMW3ZFqisQXs/qKnTUD/GKXMjV0JI1KKgjuIpfunn0qemYtNbhRRRQIKrXP3RVmqt190VlW+BmlP4ilRRRXlnYFLSUtABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRSUUALRSUtABRSUUALRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAlLRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFLSUUALRRRQAUUUUAFFFFABSUtFABRRRQAUUUUAFFFFABRzRRRcAyfWlyfWkop3YrC7m9aXe/rTaKOZhZD/ADZPWl86T1qOinzy7i5US+fL60v2iT1qGiq9pLuHKuxP9pkp32l/Sq1FHtZ9xci7Fr7U3pS/aj6VUoqvbz7i9nHsXPtQ9KX7UvpVKin9Yn3D2cS99pSnfaI6z6Kf1mYvZRNHz4/Wh5Y9hwazahkNbUa7lKzMqlNKN0RSnJJrYsh+4rDc8VvWgxAK7ZbmUPgLNJRRSAKhuCRC2OtTVXujtgZj7UAZscuoBQNvFSTzXiBNiEt3qRb+IcNxipFvbdjjcM0rmln2IHurlYQ3lneaT7aw2hozz1q79phJwCKPMgPAINArd0Vlv4ycbSKT+0bYEqxwauAw+1HlQt82AfwoDTsVBqFqRnfThf2h6OKc9tCz7VXjHNN+wW46LTBqIv261/56CnC8tv74pn9n25/hppsbYdj+dAcqJ/tdt/fFO+1W398VX/s22PJB/Oj+zbX0P50EFj7Vbf3xS/abf++Krf2daeh/Oj+zbX0P50AWvtEH98UefD/eFVP7NtPRvzo/sy19G/OgC55sR/iFL5sX94VS/sy1/wBr86P7Mtv9r86BF7zI/wC8KN6eoqh/Zlr/ALX50f2ZberfmaANDcvqKNy+orP/ALMg7FvzNJ/ZkXZm/M0AaOR6ijI9azv7NTs5/M0n9nekh/M0AaWR60nFZ39nP2kpp0+XtLQBpUlZv2GftLR9iue0ooA0qSs37Jdj/lqKT7PejpIPypiuXJ32Rk1gkljk96mmNwrbJGzUFUkSFJS0lMBKKWkpgNPWnUn8VLTEyG4bbA7egrBtR+7J9Sa2L9ttq/0rJgGIR701uZy2JKKKKozEopaSgAooooAniGFzUlIBgYpaAEbkVXH3qs1WPDUCIb0fuapL90fStC7GYG+lZ6fdH0pdS1sPpCqsMGlopiKrwFeY/wAqh3Y4bg1oU1kVxhhUuJan3KdFSNAy8xn8KhJZfvjFItNDqu6ZDvkM7dB0rOY7sKvU10tvEIYVQULcUnZFiqt5OIIS3c9Ks1zd/P8AaJ9g+6tNmcFdleFS8qk9WausUYUD04rnbNN1x/ujNdEOlJblVBaWkpasyCiiigAqePpUFSxnmkxlZxhyKmteJRTZxhs+tJAcSrSY0bFRxvvBPvilnbZGSOp6UkK7Ux+NIS2JKKKKACs245vEHtWlWZJzqCj2NJ7FR+I1D1ooopkBSHpS0h6GgCG1/wBX+JqzVSzOYvxNW6SKluFIzBAXPQUtUb+TZBtHV+KYJX0K1oDLI8x9SB9K6KBNqVjWpt7eJfMYDjpViTWYEGI1LVzqEm7noTqQilG5sAVm3afMfcVmtrsn8KYqB9WuJesZNbRptHPKrF6FewPlagyeua6SuOW4YXqyMpXNdhnIzQlZtCrPmtIKi3/vClSVSnbZdRH+8ao50i3nawb86tdfxqqRuBHrToH3pz1HFJggPBxSq2KWQd6joGWM5oqEMRUoINAhaKKKACiiigApaSigBwOKGG4ZFNpynFAEVFSOvcVFTEQy3UMDBZWxmpVdH5Qg1i6kA1wmfSlEK9VJB+tZSqWdjthhOempxZu8jrSgisZWu0+7IMfSn/a7leq7vpTVRMxlhai6GvSVlf2k4+9C350v9qxd0IquZGbpT7GqGx2p+4EcqPyrH/tWLsppp1YD7sZNHMg9lPsaxSNuqj8qb5MX9wflWM2rXB4jgaojdanLwPkHuKXOi1hqj6G6UhQbmAFZ8+qWsR2QgSN6AVlmzmlO64kLfTIqBgdOuFmjXKdDnmlz9jRYWyvJmqI9Rv8A/W/uY/TFaNvaW9qMRjnuTzUyTCeNZEOQRTqq3cxc9LLQKKKKZAU1mCjJp1VXbzJxEOi9aBpXLAPHNUNSQtB5g6pzV+mOodSh6GkJnNXgx5VyPbNb2iSK0pJ9KxSAVeylOCMlfxqtE88GUTjtmrkrjg7bm3q96bqb7LGfkTljWfbQG8nCjiNP6VVwSRFHyznk/WuptYBbQiMde/1qH2Rrey5mTqAoCr0FOopKDEWqF7YRXi88MOhq9RQI4OaC4sZsNkeh9a0rbUFkwk3B9a6SeCK4QpKMiuZu9HmhO6D5l9KpS7jtz6M0uoyORVKe1D/OnysOhqhb3ckB2P09DWwkiSLuQ5q7pmLjKDG2mpvEwgu/oGrfBDAMvINc9NBHOu1vzo066kt5vscxyp+6azlGx0xmp+puzDKH25q5Yy+ZCPUcVWb09aqaZMUuJID/AHiRUTWly6O7RneKIdrJcqO2D+Nc/APkz613mtWwubBh3Xn8q4W25jFXT1IlorEh4FR05zzimVoKIj9MetPsxicimfeb6VJa/wDHwaiXQ2p9TUqKYZiYe1TVHJypHtTRnLYzrU/KR71v2xzHXP23DMK3bU/u6RUy1QTxRUcjbUJ9qaMZbEVj896T6ZroKwtIG52krdrPqzWelkFFFFMzCiilFAEsS85qxTEGFp9QbIKgm7VPVeb7woRM9iZPuin01fuilpFhRSZFMdsCgTZHI3aoKUnNNrQxYtIelFFAJlxDuUGsLW4jtW4XqpzWxAeq0l1CJoHjPcUo6qxc9GpIyftcaWqzMecdPesILJe3HmP07D2qBRI0ht252tgCuhggESe5pb7nVp0LdhCPNAHaujNZmnx4y5rT4qXuOXYKKOKMj1pEhRSbl9aNy+tAC0U3enrR5ietADqKZ5kfrR5kf96gB9FR+bF/eFHnRf3hTAkoqPzov7wpPOi/vCgCSiovPi/vCk+0Rf3hRYRLTSKj+0RetJ9oi9aLABFNpDPF60zzo/WmA+kpnmx+tJ5qetAD6KZ5qetJ5qetAD6KZ5qetHmJ60APopnmp60eanrQBJTxUHmp60vnR+tFgJqKi8+Ojz46LDuTUVD9ojpPtCUWC5PRUH2hPSj7QnpRYLk9FV/tK+lH2kelFguWaBVX7T7UfaT6UrBcu0lU/tLelH2l/SiwrlynAZqh9oel+0S0WHc0xRWX58p70CSQ9TRYDUoyKoKWNSqCam5SiWTyOKcOBTVXaKfTJCiiimAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFIAooooEJRRRTGFFFFABWBOcyt9a3z0Nc9LzI31qomchlIaWkqwW5qW3+pFT1Ba/6kVYrI1e4lFFFAijqCkwFgcYrETcUO1Pxrevv+PVvpXPQs4JUHqKpbEvcWNHPzJ1FMLlX+cd+lTKhMRKnGDR5TEjJycZz70COggdXiUr6VTuokBL4yTTrBiUKkYI61Wug3n7s8jtSKIiAI8MMelXIIZduc8VTjLzZV+MVctS/mbSeB2oAaYyoxsx70kapySakcMPnLZFWfLjBHvQBOhygNOpBgcCikMWikooAWsnV8eQP94Vq1l6sM2wP+0KYmQWRcjYR8tWpnMTBIxVOw8wkAHC45q++7zkVemOaAIori4ZijRkAfxVJdziFk2jdmnqZEaTf90niqsyyb45FXPtQBaMsRIVx83YUsNzHIWReNvWk8o+cZDycflVS1QJcSKfagCy00e1kj5PrWQsXlxbJ/l+YmteTzY3ZosbQORiopjvjjbA3FuaQylYDZM0hOExTJoppGZo1JBPWti3KSITgDBxSmSTJWPAwM9KYjJvIyLVFQZZQM1Y09AloxPVuatpIXiJkAyPanJgx7umRmi4zLsz5RdpBwTmpbdd1y8x7nirluTKrCQDg8UKp3n07UCM6Fit5OQM8/wBKSGOQzGWUcVpiILlk6063LyLukxRcDJvU2wAx84q1CBJGg71YuIxuCrwTUqRBBz1oAw75NpfA7Uu7dCntWvJDv3v7VRktmVRsPWgDUUAxDPpXOSKu5vL65rpFH7sA+lZnk5uduRjGcUAV7KNjcAycHbW9tAqskQSTcOuKsE0AZ1/sCfhWLCB5P4iuhuiqRFyM1jAjyi4QigCTUR+5TFXYVxbj1xU9uFmgUuM1OFCjigDBsh/pEmayJlPnnHY1srn7c4XpQsQ3MWTk0wMmQttCP+FPs8/aEz61JexyPtCIeDTbVXFzGHUjmgR19Zuo5MaAHGGBrRqtdvJHEGiXcc9KQzOtsyXoYHICkVt1jaeZJZ2kkXbjIrZoAKs23eq1WbbvSY0WqSlpKBC0UlLQMKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKQCUtFFAgqlO+TtHSrhOBmsxjljXNiJWVjakuolKCQcikorjOgvwy7xg9alrNRirAitEHIyK76M+ZanLUjZi0UUVsQFFFFAgooooA//X6yiiigYtLSUtAFe6OIGrmZvvV0l5/qDXNzfeqlsRLchpKWm1QhTyeKaQQcGnc54prHJoABS0lLQAUUUUAJW1ov8ArmrFrc0UfvGNTLYuG50lFFFQMKKKKACiiigAooooAKKKKAFooooGFFFFABRRRQIKKKKBhRRRQIKKKKQBRRRQAUUUUAFZeo/eFalZeo/eFKWxtQ+IzqKSiszvFBI5BxWjbXhyEl/Os6kpp2InBSVmdPRVSzkLxAHqKt1oefJWdgqpddBVuqd11FY4j4GVS+IqUUUV5h2BRRRQIKKKKBi0UlFABS0lFAC0UlLQAUUlFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAC0UlFAC0UlFAC0lFFABRRRQAUtJRQAtJRRQAUtJRQAtFJS0AFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAC0UlFAC0UlFAC0UlFAC0UlFAC0UlFAC0UUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAgooooAKKKKACq8tT1XlrfD/GZV/hK7V0NsMQr9K55uoro4eIV+lem9znXwIlpKKKBBVa7H7g/hVmq11/qSPpQIwbwbiBH1qUwuqsccsBn2q6bOQfMCKk8ic53Ec0jfnRWCBLbdUUcchZHQZ55NXvIkxsyMGhYbhMBWAANKwc+hn3reXJjOSewrSso5Ehy56880klvJKwY7ePanotyilSVNCQpTukiiGnF0VVqlE1x9oEeQRiphBcBt4K8+1N+zXAkEgYcDFMfMmTm6ZQcr0qobuO4K7vlq15EpyGI5qD7C4j2kjIoEnGxbN1bqMbxSfarc/xioBYwMMsDml+wW+O9BkT/aID/GKXz4f74qr/AGdbn+9+dH9mwerfnQBb82L+8KXzYv7wqmdNgPdvzpv9mQ/3m/OgC9vj/vCl3L61Q/suL++350n9mJj77fnTEaGQe9GRWf8A2aO0jfnSf2aQciRvzoA0qSs3+zpc5Eh/OkNld/wyCgDTpM1m/ZtQX7si0mzU17qfwosBpbqTdWb5moKeVB+gpDd3Q+9CT9BTsBpbjTd5rN+3OPvQvR/aCfxRsKLAaJc1G8hVS1U/7QgPYioZ7yN02p3ppCbKzsXYse9Mpu72o3H0qiR1JTdx9KTLUwH0Uz5qNretADM4bNSVEE3Hk1L0piZnaocWp96oxjES/SrWrn9wo9TVdeEUe1NbmcwoooqjMKSlpKACnoMtTKmiHBNAEtFLRQISq78PVioZPvZoAZcDMDfSstPuiteQZiYe1Y0fQ/Wky47EtLSClpgFFFFAgowD1FLUcrbEJoYJBaRCW6LgfKlblUrCLy4Mnq3WrtShzZTvp/IhOOp6VzsQyS571YvpzPNgdB0pmNkZpX6miVlYvacMsz/hW4OlZViu2H681qjpTiRPcWiiiqMxaKKKACnpwaZSg80APnGVBquhwwNW5OYzVOl0KRpu3mlcdF5pLdmd2/uio5P3EDP7UsTbYPl6vUD6F36UUzIRQCecVG8r5CxjJ9aZNieszrqQ+hq2kcwbczA81SU51Q/Q0mVDdmtRSUUyBaQ9D9KQsFHNKDkZoAq2J/dke5q5VGxP3x6Gr1JFy3CsTUd9xcLBH25rbzjk9qx7IebPJOfUgUpS5Vc0oU+eVhq6e/8AG351MtlCvXk1e5JwO9altaqg3uMk1h7SUup6HsadNbGOlmx+4lSNZSqMkAfhXRAAdBikkXehBot3J9p2Rweo2zJH5wPKkVuWsnm2yP6ioL6PdC6VX0aXfa+WeqcVtE5q2qua1Z2ofKiy/wBytGql8m+2cVbOaO5MjZAPqKareVP/ALL8D61UsZfNtwfQ4/KrUqlo8jqvIpCtZ2LrcjFV6dBKJYwe44NDjBoAbSgkdKSimBMrg9afVanByKQieimhgadQAUUlFAC0UlLQA9Tng1E67TTqeRvWhA9TCvMfao89OK6BtPidQyHBIrAvhiRG9CK6qFt0KH2rKfxHoUn+5i0Zbaa4+6aiNhP2rdoqOVFqrIwhp056kU4aY56lfyrboosg9pIxxpQ7lfyp66VEOpFatFOyF7SXcorp1utTC0tx2qxRRZEuT7lCawRhmLg1iXFsSCjjrXV1BNbrMPQ0DUjibS5fTp/s83+rbofSumBBGRyDWZfWO4GOQc9jVGxvHtZPsd30/hatIyOatSs+aJ0VFFFWc1xksgijLt2qpZgmMzN1eoL5zK62qdW61oKAqhR2pdTRK0L9x1FJRTIMfVLQSqJl4I64rECOOc5Fdg6h1KnvXLzJ5cjJVxsTdrQtaaga7B9BmupPWuY0o/6V/wABrpj1rJdTprfZ9AooopmAUUUUAFNkUvEyDqadSik1ccZOL5kc5PbRzDDDBHpWWI57V8pytdLcx+XJns1VXiDVkpOLPZlSp14KRVjm3jNMuhgpKOqtQ8JhO9OR3FMmcSw8V0KakjyJ4eVKpZ7HSo29Ef1GayDIbfU1bs39a07f/j3jz/drH1YbJUkHbFLdNBTly1EzsZAJIivZlNecMn2e5lgPY8V6BYy+daI/tXG65F5OoCQdHBNTTZdSNpWM08nNITgZpab95selbEoegwvNLaf8fBpx6U2z/wBeamXQul9o1KQjPFOpKEQ9jJh4lcVuWh+XFYa8XDCtm0POKT3KlsXqrXbbYjVis6+bgLTRi1eyNPR1xCze9a9Z2mLttc+taNZI1q/EwoooqjMKkjGWqOrMa4XNJlRRJRRRUmgVXk++KsVXfmQU0TIsDpUTP2pXbAquTQkKTHbqazZpuaSqJCiiigQUUlLQIcjbXB9auHpVA9KuxtuUGlszVaxOXvYha6isuPlfj8TWvtHUU3V7fzbfeOqciobKXzrZW7gAGjqXB3j6FoFh0OKMt6miigoMt6n86Mt6miigAyfU0c+ppaKAE59TRz60tFAB+NFLRQAlFLRQAmKMU6igBMUmKdRQA3FFOooAbRS0UwEpKdRQA2jFLRQAmKMUtFACYoxS0YpAJijFLRTATFGKdSUAJilxS0UAJijFLS0AJijFLiikAlFLR1oAKKWigAopaUCkAlSqKRVqwiZqWy0gRatom3k0IgWpKlIpsKKKKZAUUUUxhRRRQAUUUwsBxQA6lqPLml3HvSEPopKWgAooopjCiiigAooooAKKKKACiiigAooooAKKKKQgooooASiiimMKKKKAEb7p+lc8/Lk10En+rb6Vzp6mqiZy3EoooqxI07T/AFVWaq2n+qq1WRs9xKKKKBDXRZFKN0NVPsFsOQDmrtFAGcdNhKlQSAfem/2ZD0JOB71p0lFxEcUSQqFTtVSa1aSXfuAFX6ikJDgCmMprZbeQ1SrbFHLg8mrRzjiod8mcFaAITasV2q1S+VJuVgRxTjIV/hNAl56GgBUWQSEucg1NUIlB60/evrSAdRTPMT1pd6etADqimhjnTy5OlP3L606mBnf2dCOFJH40f2eo6OfzrQowaBGcbFsYDn8TSrZyr/y0rQwaKBmetrOr7/MGTQLOdHMiuMnrWhRQBSMF0d37xfm46UjW1wduHX5far1FAFFILqPPzLz7UCO8BzuX8qv0UgM4x3YBAxz7Uqrdqu0gHHtWhk0lAFBBdKDx19qjAvV5xn8K08mjJoAzt15z8v6U6NrpRhkq/RzQIy5TdmVWVDgVKZbkAfuzzV/JpM5pgZ/mXA3L5Z5FReZcbMGI8Vqg0GgCrG7vFvdCpHassyzi63GJvSt3NBAznvQBmyzvFKpKHBH60+O6Z/vRkds1eIB6jNIQMYxQBkahLtxGQeRWYj5Uox4FdQyK33gDTTDEeNo59qAIbVkaBShzU5pFjSMbUGBTqAOXvGeK6fYeeKiW/ucbTj8q3pdOgmkMr5yaiOk2/bP50wsYwvLhSW4/KpLa5lmuUD4xn0rU/siDuT+dPi0uCKQSKTlTnrRcVjRPWq93xbsR2FWaZJGJUKN0NIZT06MLBv7tzV+mRRrEgjXoKfQAVYtupqvVi2+8aTGi3SUtJQIKKKKAClpKKAClpKWgYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFIQlFFFACN901mHqa1azJF2uRXLiVszek+g2ikpa5DcKvwHKfSqFXbf7hrow/xGVXYnoooruOcKKKKACiiikI//0OsooooGLS0lLQBTvjiGucm+/XSXg3Kq+tZk+lTA7oyCKpMhrUyaSppIJoz8ymoMimITFGDS0lMAooooAXPGKCc0lLQAlb+iD7xrArpNFH7tjUyNIdTcopKWoAKKKKACiiigAooooAKKKKAClpKKAFooooGFFFFAgooooAKKKKBhRRRSEFFFFABRRRQAVm6iOAa0qo6guYgfek9jWi/eRj0UUVmegFFFWLeAzPjsKCZSSV2adkhWEE96uUgAUBR2pa1R50nd3CqV194Vdqjc/frDE/AXS+IrUUUV5p1hRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUgCis7UL42SB1GSTzWLday08OyIFTnrXRTw8pamUqqidZikribfU7qE8tuHvW/b6xbzsIzlW96c8NOOoo1os16KKK5zYKKKKAClpKKAFopKKAFopjyJEu6QgD3rKl1q0jbAy30q405S2REppbmxRWdbapbXJ2g7T71o0pQcXZjUk9goooqSgoopKAFooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApaSigBaKKKACiiigAooooAKKKKACjr0oq1bIDljWlOHM7EylZXK5RwMkU2tVsEYNZbYycVdakobEwnzCUUUVgWFFFFACGq8tWKrTV0Yf4zGv8ACVz94V00f+rX6VzI++v1rp0+4B7V6T3MV8CFooooJCq10MwkD1FWarXgLQMq8GhCJAMKAaXBrB/s+8xkTHnpzSix1AHAl/WlYo3MGjBrDFtqakAOPxNBi1dScMpp2EbmDS4rCI1cY+7Td+s5xhaLAdBRXP8Am6yB91f1pwm1fOCq0WEb9LWCJ9Wz9xaT7Rq39xaLAb9FYP2jVv7i0vn6t/cWiwG7RWF5+r/3Fo8/V/7i0WA3aKwvtGr/ANxaPtGr/wBxaLAbtFYf2jVu6LR9p1X/AJ5r+VAG5RWH9r1Mf8sx+VH2/UB1i/SiwG5RWJ/aV2OsJ/Kj+1ZR1hf8qLBc26M1jf2vjrC/5U4axF3icfhRYLmvk02swatbnqrD61INTtD1OKLAXiAeoppSM9VFVRqFof4x+dPF7an/AJaL+dAEvkQn+BfyrHvRGJNqKBj0rV+1QEZEin8awZpQ0hYnvVRQpDabSF1pNwqyR1JTd/saTf7UAPopm4+lJvPpQAL1NOpiZ5zT6YmY+rn5EHvUY+6v0p+r9I/96mdh9KaMp9BKKWkqiAooooAQ1aQYUVWAyQKt0AFFFFAgqGXsamqKT7tAB1Qj2rETgsPettOVrFIxK496TLjsyQUtNFOpgwooooELUDKZplhH41MTgZp+npvdpj+FJ9io9zUwAABVO+n8mHA6t0q7x1Nc1dzG4mJHQdKUgirsrxLltxqaTkbfWnKu0YoA3zIvvR0NN3c2YhtiUe1XV+6KqjgYq0v3RTRjIdRRRTJClpKWgAooooAnHKVXhTfMB2HWpkOFOantYtqlz1NSx7K5DqbbYQg/i4psckcexB97A61FqLbp4oR61eeCNwMjkd6RS0SuRuyIQAdzMefan/aBGpGw8GnrGi8gc+tPwKBXQJLvwcEZrNj/AOQm341pishD/wATM/jQyodTbprMFFKzbRVcnJyaZmgJJOTUsZ4xUNOU4oGQ2XE0q+laNZlscXkq+wrSqUVLcrXknl2znuRxUFmnlwD/AGufzpmpNny4h/E1W0XhUH0rGs9EjvwUdHJl6zh3vvPQVrVFDGI4wKlqYqyKqS5mLRRRTMzHvosMfRhXNaW3k30tue54rtLuPfFnuK4m4/0bUY5uzda0RM9UdJTXG5CvqKdnIzQK0OM53S5Ak0lsfXj863VP6VyUm+C888dAea6mJxIokXoalF1FrcYh+z3GP4X6fWr7DIqpNH5keB1HI+tSWs3mx4P3l4NMl9xaKc4xzTKZItFFJQAU4ORTaKAJRJ604MDUFFAFmiq4JFODmkBNSq201GHFB5pMZT1KP5Aw9a2rJt1qh9qzZx5sDL3AqzpL7rbZ3Ws57o68M70nHszTpaSlqSgooooGJRS0UwEoooPAy3A96AFparNdWydZFz6ZqA6jAOis30oEXpIknXa/XtXLahYZ/dyDp91q2TqYH3Yn/EVFLqDzrtMJ/EUi0n2MKyvnt2+y3f8AwFq22ZVQvngCsS6hklXEicdjjkVlGe42my3HJ4BNaxd0ctShrobGn5nnku26fw1rZrFhkudOjEU6bk9VFaMVzBMMxsPp3oi0yaqaepZopM0VRkLWDqce2UOO4xW7WfqSboQ3oaaJl3KGlf8AH1+FdR3rltKP+l/hXVHrWa6nTW+z6CUUUVRgFFFFABRRRQAyWMSxlO/asoZB2N1FbFQTwiQb0+8KmUbnXhsR7N2lsZ2M9ar/AGQPINvTPNXII2lciTjFXwqp0pQhY0xOJU/diIBtUKOwrI1hcwq3uBWxWdqa7rXPoa2juedLuWdBm3wNEeoPFVvEkO6BZx1XAqpos3lXKg9GFdFqkImtXT8fyrGOjsdVd6KZwBYbc+tPjXAye9V4Bv69quHgVujOXYjc9qdZf61qhY5NT2P+sapl0NIbM06O9FFMgyX4ujWran56y7gYuh9K0bc/PSY+hpZrHum3S4rVY4BNYpO+XPqafQzhrNI6yxXbap9KtVFbjECD2qWs1sOb95hS0lLTJFUZOKt1BCM/NU9QzSKsLRSUUFC1WP36sVVJ+bNNESFc5NR0E80lUQFFFFABSUUUCCiiigYVNbtglT9ahoDbWDClIqD1L8iB0KnvXM2uba8e2boxJFdQhDDPrWDq8BjdLpOqkZ+lLdGifLPXZl3GOKSljcTRLKvcUuKad9TRqzsJijFLiigQlFLRQAlFLilxQAlFLijFACUU6igBKKWjFACUUtFACUUtFADaKWigBKKWigBKKWigBKSnUUAJSU6igBtLS0UAJRS0UAJRTsUUAJRS4ooASlpaKAEIBGDVZ4nXlDVvFKBQmJxuUFnZThxVpHV+hp7wLIOeDVJ4ZIjnt61WjM3zRLwWpVSq1vOCdsn51rpGMZrOV0bQaauQpHVpVC0uMdKWosXcKKSlqhBRRRQAUUUUAFFFFADWOBSKuOTSHlsVJSAKQgHrS0UwI14O01JUbdQakpCCiiimMKKKKACiiigAooprdKADcKdUfy4py9KAHUUUUAFFFFIQUUUUAJRRRTGFFFFAEc3ETfSuerfuDiI1gVcdjOW4UUUlUJGnZ/6s1aqpZ/6s1brI2e4lFFFAgooooAKSlpKACkIB5NLS0AMLDtUBmcHG2knleI/KuRVf7XJ/dFOwi15p7rQZQOq1UN247Cj7a3cCnYCz5yddhpfMQ/wmq320Y+7Tvtqf3aVgJ90WcbTQGiPGDUIu485KmgXUf900WAlLRjGAc5qcVU+0w/3TS/ao/Q0ASyRbznJH0qE2z/wufzpwuovenfaovejUCBra5zlXpT9qiTe5GBU/2qKkleKZNhbGaAKouZiC3GewrRj3FAz8GsInY52jLL69K14MvCGYnJoYFikIzSbcd6Me9IYuKMUmKWgAxRRijpQAmfajcfSijmgBNx9KNx9KX5qPmoEJuPpSbj6U75qOaAG7j6Ubval5o5oAaW9qN1Lk+lGT6UwE3CjIo59KT8KAFyKTij8KT8KAF4pcU38KKAFHIopilsdKXcfSgYtFN3e1G72oEOopufajPtQA6ikBzS0AFT2/36gqa3+/SY0XaSlpKBBRRRQAUUUUAFFFFAC0UUUDCiiigAooooAKKKKACiiigAooooAKKSikIKKKKYBRRRSAWoJot4yOtTUtTKKkrMpOzujKIKnBpK02RW6iovs6VyvDvobKqupSALHArRjXYoFCxqnSpK2pUuQznO4lFFFbkBRRRQAUUUUgP//R6ylpKWgYUtJS0AVbjl4x71dHSqc3M0Y96uUMEDKrjDDNU5dPtpf4dv0q7RSAwZdFbrC2frWdLYXUX3lz9K7ClzVcwrHBlWX7wI+tNyK7l4IJPvoDVGTSbWT7vy/SnzC5TlaK25NEkH+qbP1qjJp93F95c/SncVmUq6nSBiAmuYMcgOCp/Kut0xdtqM1Mi4bM0KKKKkBaKSloAKKKKACiiigAooooAKKKKAFopKWgYUUUUAFFFFAgooooGFFFFIQUUUUAFFFFABTJEEiFDT6KBp21OekgkibBGaYFY9Aa6QgHrzSBEHQCp5TpWIfYxobOSQ5bgVrxxrEu1akoppWMZ1HLcKKKKozCqFz/AKyr9Z9x/rDXNivhNqO5BRRRXnHUFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUlAC0UUUAFFFFABRSUUALRSUUALRRRQAUUUUAFFFJSAKZJIsSF36Cn1WvGKWzsFDYHQ1UVd2E3ZXOX1WeOWUNC5IxyKyqVsFicYz2pK9uEeVWR50nd3CgEg5HWiirJNe01aaD5X+fJ711ccqyxiUcA+tee8jkVZN5clFjDkBelclXDKTujeFZrRnfUDnpXMQa4Uh2zDLDpVOfU7lpi8LlVPauZYSTZs68bHUreW7SNFuwV65qZZY3+4wP0rz9nZnMhPLdTUsFxLA4ZGIA61rLBq2jM1iO531U5r+2gYpIeRWPJrgMGIh+86Vz8jtK/mScsamlhHvMqdf+Ut3l5LdSHJ+TPAqlgDpRS13xikrI5W76sVSVIZeCK7TTLr7TbjJ+ZeDXFVtaNNsn8r+9zWGJheFzWjK0rHW0lLSV5J3C0UAZOK0DEmzpWkKfNqRKdjPooPWisywooopAFFFFMAooopAFFFFABRRRQAUUUUwCiiigAooooAKKKKAClqaGIucnpVswxkYxXRDDuSuZSqpOxnUVJJGYzjtUVYSi07M0TvqLRRRSGFFFFABTkdkORTaKabWqE1cmad2GKhoopyk5bgklsFFFFSMKSlpKACq01WarzVvQ+Mwr/CQL/rV+tdMPuiuaj5mX610vavSe5h9iIUUUUEhUM/+qOKmqObIiJHamgGoMIo9Keelc42o3XmEbQFzxV0Xsn2gxEDGBiiwzVorITUyylyoxnA9eKkk1FY+MZbGcUWA1KMms8ahGWEePnIzQuoROCVBwpwTRYDQ5o5qguoQMC4ztHelOoQjk5AIyKLDL3NLz61Q/tCDAznLdBij+0YeRg5HBGKQi/k+tGT61QOoQ5wASR7Uf2hDuKgEkdeKYF/J9aXJ9azv7RhxuAOB3xStqMKJvYHB6cUWA0Mn1oyazjqUKAMwPzdOKlS+hk6ZyKLAXcmjJ9ay/7WgLEAHA6mh9VtlXdyce1FguamT60ZNZ8epQSJvAIHXpUQ1i0JP3uPaizC5q5NJWd/atnjOT+VSf2ha8ZbrRZhcuYX0ppjjPVRVcX1qeN1OF3bHo4/OizEPNvAeqCmGztD1iX8qeLiA9HX86eJIz0dfzo1AqnT7I/8sl/KmHTLM/wAVe3KehFLkHvQBkXFhaQxlhwe1ZexR2zWvqLYAWsqrRI3C+lFLTSfmxVCCikLCm7hQA+im7xTfMWgCOeRkHy0QMXTLVFcMGxinWp+UiqEZ2r9I/8Aepv8I+lO1f7kZ/2qaPuj6U1uZTCkpaSmQFFFFAD4hls+lWKS2XKMaWkhtBRRRTJCmSfdp9Nf7poAZF0rHnG25PuK14qy70YuAfUUmXEYKdTO9PpgLRRRQIimbCYHU1q2sflQqtZSr51yqdh1rd4A+lJFS0VihqE/lRbF6tWHEv8AEaluZTc3BI6CnAYGKnd3L2VhaktF3TlvQVEeBVuwX5C/rTYuhoVaT7tVKtJ92mZsfRRRTJCloooAKWkoJxQBIi72Cj8a0xgD6VVto9q7j1NNvbgQQE9z0qBvXQyfM8/UQ3YHFbtcxbEi4RvU109HUufQKKKKZADrWODt1In61sDrWFKdt/8AjSZcOptOdzGm0UVRAUvekpRQBSifGpMvqBWzXLRzZ1gHsa6v3rM0mjFmPm34XsoBrbtI98mfSsK2/eXUkv4flXV2keyPJ6msJ6zPRp+5RRbooopmIUUUUABGQQe9cXrtuVQsOqEYrtaytUtxLET7c1SEzPtJRNbJJ6irI61iaPIQr2zdUNbdapnJNWZgz2/mLM39wZpulz7SbZz7itWJQ1xPEejKK5+aJ7d/NXqhz+FZp62OmcLwTOnBqo5NvMJh91uD/jT7edZ4xIv41O6LIhU9DWpy7FjhhkdDVc/KcGoLSUoxtpOo+79KuSLuFITGUlNVv4T1FOpgFFFFAgopaKAEpaSloAKM0UUAOB/Ximaa/lXLxHvyKWqspMUyTjtwazmtDow0rT5X1OoopqMHQOO4p1Zm7CiikZlRSznAFAC1Vnu7e3Hztk+i9apPcXF4Slr8id2PBpY7O3g+d/nf+83WhtIqMWxv228uOLWMAer8GmfY55PmuZmHsDxVppzjjgep4rIutWtrfhnLN6Dmkm3sacqjuaK2lqv8AY+pqT93H0wtcbPr8zcQqAPWst768nPzO1Wqb6k+2S2R30l9BH96QfnWfLr1rHwCTXILDK/zN+tJtRTgDJ9qfs0Q60uiOgl1/eMIufrWLLcGaYSYwRUsdrcSjKqFFTLpzD5pGA/GrVlsYvme5ah1iUL5dwuRVgCzujugPlP7cVz87COTYG3CnIyjleDU+zXQ0Va2k1dHSLcXVqdtwu9f7y81fiuIphmM/hXPQX1zEMN86ehqeNraV90LeU/oOBQm1oyfYwnrTZ0OfWoLld8DD2qkl3LEdtyuR/eXmrpdJE+Q5BrSLT2OOtCUVaSMTTDi9x7V1p61yengjUWFdZULdm1TaPoJRRRTMQooopgFFFFIAppO1SfSnVHKcRMfamBBGC6eaOoNT5yM1Ha/6j8akPynPY0kXPcKp3wzbMPartVroZt3/wB01S3M2czbOUMbjsRXeuRNb7v7wrz+IZjHtXbabJ5tkvsKiekjpa5qLXY4Jk8i6ki9DSO2TVvV4zFqJb+/zVI1qjJapMbVqw+8xqqauWA4Y1L3RrHZmhRRSUyTNvOLhT9KtwHDCqt8PnVqniPQ0pBE0Lhtsf1rLiGZV+tW7l8gD2qtbjM6j3FEvhJor3zskGEAp1IOBilqUS9wprnAp1RA+ZOFHRetALcvoNqgU+m0hYCoNLjqQnFRl6jLGnYlyJGeoaKQ9KZLYlFFFMQUUUUAJRRRQIKKKKYBSHkYpaKQE9s+07DU1zEJ4Sh7iqXTkVoRPvX3qNmb/GjD0p9kj2UnY8VqPEVNZOoIbW6W7T1wa6NGSeJZB3FLZmyfPFSM0ijFXJIe4qsVIqrkjKKWimISilpaAEopaKAEopaKAExRS0UAJRS0UANopaSgAoopKBC0UUUDCiiloASilooASilooASiloxQAlFOxRigBKKdijFIYlGKdinYouFhmKXFSBTTxGanmKsRBacFqwsVSiMVPMOxXVKl8oEYYZFThQKdQBkzWOPmi/Ki2uWiPlTfrWtUUkEUo+Yc+tac3RmXJZ3iSDBGRS1HGnlrtzmpKk0CiiigAooooAKKKSgBaKKKAIz9+pKY4704HIpALRRRTAjftT6YeWxUlIQUUUUxhRRRQAUUUUAFJS0UAN2rS0tFABRRRSEFFFFABSUtJQAUUUUxhRRRQBWuziE1h1tXp/dVi1cdjN7hSGlpKoSNKz/1Zq3VOz+4at1k9zZhRRSEgDJ6UCFoqobyLYXHQUiXsTjPI+tAFykqst3E7+WM5qzQAUUUUAGAetN8tD/CKdRQBGYYj/CKabeE9qmooArm2ipv2SL1NWqSi4FX7JH6mk+yL2Jq3RRcCn9j96abM+tXqTNO4ij9jb1pPsb+1X80uaLhYz/sb+1H2NxzxWhmkzRcLFFrMt1OPpVxFCKEHanUUhhRRRQAUUlLQAUcd6KKAE4peKTiigQtJkGjikwB0oAWiikoAWikooAWkoooAKKKSmAUUUUAFFJRQAUUUUAFFFJQAUUUUAJRQCD0ooAKmg/1lQ1NB/rKTGi7SUtJQIKKKKACiiigAooooAWikooAWikpaACiikoAWikooAKKKKACiiigAooooAKKKKACiiigAooopAFFFFABRRRQAUUUUDCiiimAUUUUgP/S6ylpKWgYUtJS0AVpObhPrVyqjc3K1boYIWiiikMWiiigBaKKKBC0ZpKWgY0ojfeUGnKqqMKMCiigQtFFFABS0lFAC0UlLQAUUUUAFFFFABRRRQAUtJRQAtFFFAwooooAKKKKACiiigAooopCCiiigAooopgLRRRQAUUUUgCiiigArNn/ANYa0qzJf9Ya5cV8KN6O5HRRRXAdIUUUUAFFFFACUtTRw7+TwKc8G0ZBzWvsZW5iOdXsV6KKSsixaKKKACiiigAooooAKUAk4FJVi3XLZParpx5pJEydlcmS3UD5uac0EZHHFTUV6apxtaxycz3Mx0KNg0yrV0OQaqV5lSPLJo64O6uLRSUVBQUUUUAFFFFABS0lFAC0lFFIAooooAKrXhk+yv5YycVZqhqNwLe3JzyeBWlJNyViZ7M4k5yc9aSlJLEse9JXuHmhRRRQAUUUUAFFFFABRRRQAUUUUAFLSUUALWnpUgS5Ax1rMrU0ogT/AHc+9ZVvgZdP4kdlRQetFeKeiFSebJjbmo6Kak1sDSFopKKQC0UlLSAKKKKACiiimAUUUUgCiiigAooooAUAnpSdKtwyxquD1qvKwdsitHGKV0yE23YZRRRUFhSgZOKSpYBmQVdNXkkKTsrmgi7VAp1FFescIyRA64rLPBwa16jaGNjkiuetR59UawqW3MyitD7NHSfZkrn+rSNPaoo0VbNr6GozbyDpzUOhNdClUiyvS0pVl6im1k01uXcWiiikMKKKSgBaSiigAqvNViq83St6Hxowr/AyKAZnX610tc5ajNwtdGetemzB/DESiiikSFRzf6s1JTJRmMimgZVWCCVAzIDT/s0O/wAzaN1EGFhHNT9aRRUFlbg5Cj1oaygeXzsfNjFWqSi4FU2UJk83oxGKFsolRo14VutW+aKLgUhYQiHyAflzmiTT4pAoJ+70q7zRzRcLFQ2UTOshPKjAoWzjV3kB5frVql5ouBTSyjTOG604WUSltp+91q1zS80CKhso9hjBwDQ1lE42k8DoKt80c0AVWsomCg9V6GlSzhQlh1PWrPNHNAFX7DajI2DmnfY7XG0oMVYo5p3AhW1gUbQox6U37FaYx5YqzzRSAqmxtCMGMU06faHB2DirlFO4FA6ZZk52CojpFp2GK1KKLsVjIOjW/ZiKadHX+GVhWzSUXYWMb+ypR9ydhSDTr0H5Z2rapCcDNO7CxzE8N0H2PJuIqDypv71XpDukY+9RmtCCt5UndjSeU+c7jVrtTKAIPJbPLGk8k/3jVik4oAr+Sf7xpfIz/EamyKTcPWgCpPEqKCDTLY/Mwqa5IKDFVbc4k+tUT1K2r/6pT71Gv3F+lTasM2+fQ1BGcxL9Ka3ImOoopKZkFB6UUjf1oGjRthiEe9MYYOKnjG1AKjkHINRE0miKinYowasyEprdDTqD0oArR/eqjqIwytV1eHqDUVzEG9DQ9io7lDNSVCDkA1KOlCKYtBOBmio5Wwn14oYluWdOTcWmPc8VNqE/kw4HVqmtI/Kt1B9OaxbuT7RcYH3VqehS1dyGJMDJ6mpaKKYxkhwprUtl2QgfjWW4yQvqa2lGFA9qXUJbDqsx/dqsOtWY+lMzZJRRRTJFpgb5trfhT6ayhhg0hoWpIk8x8dhVUmSP73I9auwSoibunrQHoXWZY1y3AFc9Kz6hchV+6KknnlvX8qH7vc1eijS0hIHXuaRSVjIcCOcAfw10inKqfauZl5dj610Fu++FT6cUhy2J6KKKZADrWBdcXv41visC8/4/fwJpFQ3NhTlQadUMB3QofapaZLFqOWQRRs57Cn1l6hIXZbZOrdaG7IqEeaSRSSIqguz1BrrQ4MAf2rNa3DWhiA7UltOW09s9VyPyrKLudWKhZqwmlLv3e7n+ddgo2qBXM6JHnntnNdSayW7Z01dLREooopmIUUUUALTJEEkZU0+igDhWU2eqhjwshrfql4gtjs89Oq8ipbWUT26yD6Vqmc9VdSNDt1A/7QApl/CN5IHB4NPk+S9hf+8cVoXcXIbtWc97nXQd4JM5WF2s5Qf4G4roEZWAI6Gs6W2DsYT0f7v1qpb3Elo5trjtWkZXOarSszXuYC4EkfDryPf2qa2nE6c8MOCPenRuHUMpzVS4ieJxdQdvvCqMbX0J50I/eJ1FCOHXcKmilSePevfqKz5M20m8fdPUUErsXKKFYMNw6GlpgFFFFAgopucHBp1AwooooAKjkQOhWpKSgPMtaXPvjMDdU4rVrmQ5tblZh908H8a6VSGAYdDWDVnY9C/MlNAzKiF26Cs3Y94fNm4jHRfX61cnVXwH+6OtVnkMnCcKOpqZX2QQS3Yryqg8uMc1i32qQ2n3zvfsvaqOpasI829n17vXLHc7ZPLHua0jT7ilV7Fy71W8ujgsVX+6KoJGznj8zU6QhjgDc1bFvpF1MB5h2L7Vd0jNRlLUylhhTmVsn0HNSGaNfuKBW1cWlpp8OSN7npWA4aRunJ7UXuJuzshxuCx+YnHpU6XkUf3Ix9ajFjORnGKQ28ifeGaLhZksmpXDjC8CqjXEz/eJq0scTDGdpo2NE24gMPSgSsUAuW3Gr6G2bhuDVmNLK4O0jy2pJtLlj+aP5hS5W9UaxqQhpJC/ZnxuhYEfWoWXBw64PrUQLxnDZU1bWdyMONwou1pIcqEJ+9Sdh0V1PCMZ3p/dNTR3SeZvhBU917VHHFbznAbY3pUrWJi+Yy4/Kiy3Rn76XJNFnS2Et88pwoA78VuS3ttF95vy5rl1a3Q7QxkY9sda1rbTvMIlmjCL1ApJkVIbOeiL0F41yf3KfL6nirtIoCgKowBS1RzvyCiiimIKKSikAVXum2wMfarFUdQbEJX1oDqixbDEAqU8imQjEC/Sn0LYqp8Qg9KhuBmF/wDdNS96ZMMxP9DVIzZydvyjD8K6TQpPkeE9jXN2vWRfRq19Kk8u+2nowJqanc66K+KJD4jixJHL9c1h113iCLfaFv7pFcgDkZqoswhtYa3StCxUiHdjrWbIcLW9ZArbKKHubL4WGV75owD0NWaQop6igmxkagv7tT70sR+VTU2oRKIMrxiqsBzGppsSJpW3NT7QZuF+tQnrVqxGbhaU9h0viudYaSg9aKkyEZgqljUdiNwaU/xVBeybUCDq1XYV8uJVoY49WTlqjJpM0lIGwooopiCmtTqYfvYoAWiiigQUUUUAJRS0lMAooopDK6ybZjGe44qzWVfsY3WUdjWjFIJYw49KWzHvG4+no5jbcKbRTaBOzuie8iF1bHFVtFn+Q2z9U4qeGTYdp6GsuYGx1ATL91v61DWh0UmuZro/zOpxUTxBqGniChy3B9KhF0HOIlLfXipLsMeFhURXFWttw/3vl+lHkf3jmncXKVMUYq35Qppip8wWK2KMVMYzTShouKxHRTsUYpgMop1JQAlJS0hpiEpKDTSaBXFpKTNJmmTcdmnVHmnA0hpj6WgUuKChKKdilxSGNxRTsUu2i4WGYpcU/aaeENLmHYhxTsVOI6kEVTzD5SqFp4Q1aEYqQIBSux2RUERqQRVZwKWiwXIhGKeFAp1FFhXExS0UUwCiiigQUUUUAFFFFAwooooAKKKKACiiigAooopgJTMFTkVJRSAj3+oo3E9BT6KAEUYp1FFMAooooAKKKKACiiigAooooAKKKKACiiigAooopCCkpaSgAooopjCiiigCjfn92BWRWpqB4ArLq1sZPcSiiimBo2f3DVuqln9w1brNmzCkIDDB70tFAjPltIwjRo3XtRJa7tmP4cZqQwyqzMjZz2oxOig4ywNAFTcrXKptwVrTkdYwWbpVbdlwxTB7mpZYxcIOehzQAw3cYQSHoeKk85M4PUjNVpLUsoVT0OcVI8BeRZB0AxigRKtxE4yp609ZFboearJa+XMHzkAYp0cHlys/96gCwWUdTSBlPeoJbfewcc0NCCAFHSgCxuX1FG5fUVVMILAqPrSeSrnHSgC51qo92quUxnFWlG0Yqi1s5dj2NMBRfRZxg/lS/bI/fFMNo4GFoa3f5do6cGgBft0J9fyqRrhExuzz0qA28meAOuafKhZl+lAD/PQ9CcmnCXJwCc1AI/3iVJGm24cnpQBJ5nO0Hmn7nUc1AibZdvpU8q5xSATzGxnAo8wjjFMCN0AzQy5YA8UAPEjE8ijzCTgCiQYGcVEuD060wJ95zjFMM3tSnKY3854qJlIfI5FAEvmHqRSeb7UpGRTAOx7UASh884pDIveoySOaQgDp0NAEpb5cioVdj3p33flPQ0gXYevNADizbcikDOehoLZHzigIByKAFyx60wOWfbmnk7hwarcrMPpQBYyfWkz70FT1pq89RQA7PvSfjSdD0oxmgBfxo/GkxilKjHSgA/GjqetN4xiheOMUxC55xmjOO9KWHcUzgkelIBXJzgU3celK3BozmmAIdpNBdjTRnNFAAd3Y1btCd/NVKtWv+sFDHE06SlpKkAooopAFFFFABRRRTAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiikAUUUUDCiiigAooooEFFFFAH/0+spaSloGFLRRQBXHNz9Kt1Rk4kJFAlcd65XiUnZo2VF2ui/RVUXB7ipRMh61oq0X1JdOSJqKaHU9DTq0TRFmLRRRTAKWkpaACiiigQtFFFABRRRQAUUUUALRSUUALRRRQAUUUUAFFFFAC0UlLQMKKKKACiiigAooooAKKKKBBRRRSAKKKKYBS0lFAC0UUUAFFFFABWXJ981qVkv941x4rZHRR6iUUlLXCdAUUUUAFFFFAF2FgUx6VKxAHNZwYqcilaR24JrqjiLRsYOlrcaevFJRRXKbhRRRQAUUUUALRRRQAVdthhSao1owDEYrpwy94xq7E1FFFegcxUuugqnVy67VTrzK/xs66fwhRRRWJoFFFFABRSgFjgUrKy9RT5Xa4rjaKKKkYUUUUAFFFFABWXq8PnWvAyV5rUqOZikTMBkgVpTdpJoiaumjz3kcGinytvlZieSajr3EecxaKSloAKKKKACiiigAooooAKKKKACiiigBa2dGLiYgfd71i1v6LAd/nbuPSsMQ7QZrSXvHUUUUV4x3hRRRQAUUUUwCiiikAtFJRQAtFFFABRRRQAUUUUAFFFFACUtFFMAoop8aFzgU4pt2QN23GVbt42B3tUyRInbmpa76VDl1ZzTqX0QtFFFdJiFFFFABRRRQAUtJRQAEA8EVXe2U8rxViiplBS3GpNbGW8bIfmFNrVZQwwaoTQmPkdK4qtBx1idEKl9GQUUUVymwUUtJQIKrzdKsVBN0rWj8aM63wMbZ83AroTXP2PNx+FdAa9VnM9kJRRRSJCkblSPalo7UwZyz22oFyYXIXPAqRI9WjZmJzxx0rYhIBZB2qz1p3KS0OdQawpGeefapDJq4kJC8Y9q36KVxWMBZ9WCnKc/hQlzq2Duj/Wt+ii4WOfFzqwH+rz+NKbnVcj91+tb1FFwsYTXOqEjEX604XOqk/6r9a3KKLhYw1uNWx/qv1FKJtX358vj6itulouFjF8zVSPuc/UUobVh1X9RWzRRcLGL/wATYk8fyp4/tXHI/lWvS0XCxkBNU9f5UeXqn97+Va9FFxWMjy9U/vfyo2ap/e/lWvRRcLGPs1X+9/Kjbqvr/KtilouFjGxqo/yKN2qj+HP5VsUUXCxjebqo/wCWefxFJ9p1MdYc/jW1RTuFjE+3Xy/eg/WmtqU+07ocVu1TvSBAcAc0JiZzXnscnHWmNK5FXcDaKQgVpcmxRMkmBzTSz5q/gYpOKLhYz8uaPmNX+KOKLhYz8PRter/FGRTuFjOdWCZNRRnEimr9xzGcdqzc45pkvcfqYzatVKA5hX6Vo3g32jf7tZdqcwj2oW5E9iekpaSqMgpDywHvS0i8zKPahlR3NQN2okGVqKpFbI2mpaKT6DPenU1eOPSn0yUhMCmlRTqKAKB4f8aS8XdbmlfhjUkg3Qke1NiW5gIflFTr0qunGR6Gp06UkaMfURHmTpGPrUlPsk3ztJ/d4oYl3L13KIYCR16VgxjAyepq5qEnmSiIdB1qvS6lLRBRRRTEEQ3TqPStisyzG6Zm9BWnSQ5CjrViPriq69anQ/Pj2pmZNRRRTJFopKWgBjqzD5Tg1WaKaTiQ8VcooHcq2zeRKY+zdKnmcniqs4I5HUVZCGdQV70il3KciHYH7ZrR05sxlPQ5p88AW1ZR2FU7B9suPUYpMfRmzRRRQQLWFfcXi+6mtysPUeLqM+ooKhuXrQ5hA9Ks1SszgunvV2hCktRGYIpY9BWZZxmaVrp+/Spbxy2LdOrVpWlvkCNRwKym+h2YaFlzssQrmsaRDbSzW54BGR+NdhFbiMVg67bldt2v8PWpiVWfMh2k3FvbwZlbHNaR1ayH8RP4GuY0mXT/AC2e5PzZJrUOr6bF9wj8qVraGrXN7xof2tB/CpP4GlGrQd1I/A1lnxBaDoAfwpy6/aH7ygfhQL2Zrrqdk3G4j8DVpJ4ZPuMDWINT02cYYj8qljs9On+aE4Prk0CdNo3OKKyBa3lvzBLuH93FWEvShCXaeWfXrQRYlvIVngZG7iuV0tzFJJZSdVPH412fyum5TkGuS1WE2l7Hep0J+aqRE1dFm7G0JL/cOa3igmhHuoNYt1iS0Yr3ANa2nyeZaq3px+VEgoy90yJ4iDjuOlOmsYtRtt44lXj61p3kG4eYvaqFvKYJM/wnrWadmdElzK5zcNzPYS+TcD5c10McqSLuU5BqxqWnR3cfmxjPFcvELiyJaL5kB5X0rZSOOdPqjXZGtZPPh5Q/eFWJQk0XmJyDUVtdw3C/IfqKXY0LFovunqKoxaKEcptX2tzGentWmCCMjpWZc7QT79qjguTbkI5zGeh9KY7XRsUUgIYZXkUtBAx13rgde1Mhl3gqeGFTVTuUZG+0R9R1HqKQ12LlFRxSLKgdfxqSmAUlFFADJEEiFTVzS7kspt5D8ycVWqq5a2lW5T6Gomup0UJ2fK9mdPIgcc9K5PWdS2ZtLY4/vEVtX18qWPmRnlhxXDSjnA5ZqmK6s2le/KikxFXbTTprnDN8iVo2WmgESTDLeldFFCAOlNy7GipqOsina2cNsv7tefWrm4Rgu3apitZOrzeTblO7cURjdk1Ktkc3e3LXE5brzgVPbrFGuWPzGqdvBLId+OKuG1B6rWjV9jmjJR3Lu4N0NMYcc1Q8uSE7lPHpVx5DKqxxj5n4pbGsXzOyKjtHu24z9KfGkUn3Dg+ldtpujQWsW6RdznqTWJr1lb27LPB8rmo5u5Uop6ROdliIOHGD61qaffbGEEx69DUMLecNkoyPX0qveWjwASp0FNrqiY2ejOiurOKZOR1rnbiwntDvTlfWul0yb7XZgnqKsMnG0jIoUr7kSpOLvE4sTKw/fD/gVO8uJiBuJBrSvrAR5mjHHcVivlAQppOC3RcMRLZnT2Dadbj5eXHetI3EzESA/ITiuYgRC8Zx83Ga6Fj+6H+9TW1zOqve1NKkoHKg+1FM52FFFFMQlFFFABWTqLZYJWtWFctvuUHq1LoOKvNG2gxEo9qWlxhQKShBPdiGmv8A6tvoacaRvuN9KaIlscdbHFxKvuauRP5d1HJ+FZ8R237r65q7IMAH0IpyWh1U3aSZ1moR+dbOvqM159H93Br0VD5tsG9RXATJ5Vw8foamnsRJWqNFaXoB610kS7YlX2rnlXfOiV0o6AVXU0+ygpaSloEVL0Zt2rNt/wDVita5GYG+lY9v/qzTRn1ZLV/TRm4H0qhWlpYzP+FKexdPqdHRSVHM/lxlqRg9igx8+8CdhWwayNOUtI0hrWpPcq1lYKKKKBBRRRQAVGOWJp54GaYnTPrQA6iiigQUUUUwEopaSgAooopDM7UF3R1V0y5wfKb6VfvBmOuZEnkXOPXp9aclpcdJ+84vqdnRUNvKJogw6jg1NUp3G007MSobtPPtyv8AEvSp6KYrtaobpKQTR/vBl1rdCheAK5SFzZ327+Fq6wEMAw6GsrW0O5vmSmuoUYpaKCBuKTFPpKAGFRTCgqaigCsY6YY6t4pMUAUilMKmr5UUwpTuKxQINMNXmjqB46pMhlMmm5pkuUNQeZWiiYOpYs5ozVXzKPMp8ovaItZpQaqeZS+ZS5Q9ojQQ5qULVCKYBhmtmNAyhhWU7o6abUlcgCGnCOrYQU8KKjU00Koip4iqxilosFyERinhBT6KLBcbtFOxRRTsK4UUUUAFFFFAgooooAKKKKACiiigAooooAKKKKACiiigAooooGFFFFABRRRTAKKKKACiiigAooooAKKKKACiiigAooooAKKKKQBRRRQIKKKKACiiigApKWkoAKKKKACiiigZl35+YD2rNq7fNmUCqqoTVSnGK1ZCi5PQZRVgRgdadsX0rmeLjfRGyoPqWLP7hq1VW0+6atVve+pLCiiimIKaeKdTSDQAlJg0Y9qNp9KYBg0nzUuD6UYPpQITc1G44zijDUYY0AG4UuRTdjetIUagCTcKNy1Hsb1pNj0ASbhRkVHtemkuvBNAE2aTNQ7n9aN7etAE2aM1AWb1pd70AS5oyuc4qLe1J5jelAE+1c7sc01mBOwiovMbPSkMhDDigCQBlXBGfSlZd6j1FRtP2xSGbb2oAkTP3XpAPLyMZ9Kj85W7UpnX0oAlI3phutIilQdxzmoxOB1FDTrt3YoESBcNnNDLnleDUIuUPanGXaM460DHgEd+aAgC461CLgHtTvOyOBQA8A4+Y0mwt940zzT2FHnnOMUCJBgnaTxTSxU4BppmIXdigSZ5JoGO2/xA0x8FgU5anF8jhqaJFBG45oEPLMPvik2uo3ngGmynd8vWlDqY9hFAB85PApctSBgoxikdgy4oABvYkYx70uWHynmkVgqbeppOQd1MB20jtSZPUCje3pSZb0oAXLE9KTmj5qX56AEKnORzRhumKMmkoANppdrUlFAg2tVi1yJRmq9WLf8A1ooZUdzUpKWkqACiiigAooooAKKKKACiiimAUUUUgCiiimAUUUUAFFFFABRRRSAKKKKYBRRRQAUUUUhhRRRQAUUUUAFFFFABRRRQIKKKKACiiigD/9TrKWkpaBi0UUUAU5PvmmU5/vGm15E/iZ3x2CiiipGLzTxI69DUdFNSa2E0iwLhh1qUXCnqMVTorRV5rqS6cWaIljPengg9Ky6UMw6GtViX1Rm6K6GpS1nieQd6kFyf4hWyxEWQ6TLlFQCdD14qUOh6GtVUi9mZuLQ6iiiqJCiiimAUtJS0AFFFFABRRRQAUUUUAFLSUtAwooooAKKKKACiiigAooooEFFFFIAooopgFLSUUALRRRQAUUUUAIelZR6mtRvumso9a4cX0Oij1EooorjOgWiiigAooooAKtrbKV561DENzgVo12YemmrswqSadkZzwOnTkVDWvULwI/PQ0Tw3WIo1e5nUVI8TIeelR1ySi1ozdNPYKKKKkYUtFFMBK0of9WKzqu27grtrpwztKxlVWhZooorvOUpXR+YCqtTTtuc1DXlVXeTZ2wVkFFFFQUFFFFAEsThDzTpZQ4wKgoq/aPl5SeVXuFFFFZlBRRRQAUUUUAFNZlVSX6d6dWDql9JHGYthAbjNaUqbnKyInLlVzL1UWfmbrU5J61lUY70te1GPKrHnt3dxKKKKoQUUUUAFLSUtABRRRQAUUUUAFFFLQAlbukyzmRUU/IOtYVa2mEeaC7hAO3rWNde4zSk/eOxPWikDq33TmlrxrHoBRRRQAUUUUAFFFFABRRRQAUtJRQAtFJRSAWikooAWiiigAoopKAFq/AuEz61n1pRf6sV14VatmNZ6EtFFFd5zi0x5ET7xpk0nlLmstmLnJrGpV5dEa06fNqzR+1xU8XEJ43VkUViq0jV0Ym6MHkUVFACIxmpa609DlaswooopiCiiigAoIBGDRRQBmyxmNsdqirQuFymfSqFeZWhyysjrpyugp4jcjIFPhUM2T0FWWmROlXClHl5psUpO9kZ5BU4PFQT/AHatSSGRsmqs/wB2pppc6sKr8DuLp3M5+lbprD03/Xn6VuV6RhLoFFFFBAUUUUAc7fXdxZ3TCBC+/sPamRapdyRySGEjaOlaV5+7mjl4wOualUqCcAYPWqYR2MePV7potxhOc1KuqXBl2GI4xWwFUDAAxSceg/KkVYxl1iYqx8o/KTilXWJsAtCcmtkIo/hH5UbF/uj8qLgYn9tTKSDAetOOrzqQDAeRW1sT+6PypcL/AHR+VFxGMdWn3BfIPIpf7UuCDiA8e9bG1fQUDA7CgDI/tS5wD9nbn3pRqV3/AM+7fmK1/l9BTsewoAxv7Rvj0t2/MUov789Lc/mK2cUtFwMX7dqP/PufzFH2zUv+eB/Stqii4GN9r1Q/8sT+lKLnVP8Anif0rZooEZH2jU/+eR/Sj7RqX/PI/pWtmlzQBk/aNS/55H9KX7TqI/5ZH9K1M0UXAy/td/3gP5ij7beDrAfzFadFFwsZn2+4HW3P50f2i4+9CR+NafFJgegouBnjU4v4lIqtd38EkYVTzmtfYh/hH5VjaiieYqgAVUbCkUPPXFNM49KcYUphhSrJE88DtSef7UvkpR5KUwIzM2eKPOY1J5SUeUlAEJlYd6b5jGrPlp6UyUpFEZPSgRHuBG096osOoq9DKJlzjB9arzptfPY0REx7fPaN9KxrM/u2HoxrYhOY2jrGteHkX3NPqRLZlukpaSqMgpIebj6UtJacu7e9Jlx2bL9FKAScCpJIZIxlhSbWwKLauR55zT6iqQHIoAWiiigClMMNTk5TFLOO9MiPaqIMJxtndfenpTrpdt19RTF60kasexwpNXbIeXbGQ9+azpTwF9TV+6bybQRjrRcLaWMvd5kjSHvTqRRtXFOpIbEo6UtNc4UmmCLdivyFvWr1V7ZdsIqekiZbj1608HEuPampSMcSimSXKKKKZIUtJRQMWiiigRFIueabZvscxN+FTEZGKpvlGEg6rSKibZG5SvrWBF+7lHs1bkTh1DCsi4XZcMvqM0MpG4DkA+tLVe3ffCD6cVPSICsbVB+9jatmsnVh+7V/QigcdxbZttyV/vc1fdgilz0FZUZxOj+1W79txW2U8tyfwqW7GijzSSGWaGaQ3DfxdK6y1hEMe9uKybYRwRebJhUX1rD1PW5rsmGD5Yx39azt1Z2Slb3ImxqWvRwEx253N7dq5K4urq7OZnz7VCqFjxz71bSIL7mi9hKHczGiaP5ucVchijlHBxVtlQD5qpvGh5jODTjK24pU77MtfYe4NRNauO9JFcTRDDcipftkbDnrWt4s5mqiZTZJU6ihJpEOVYg/WnG4dj8ooCzt/DWbS6Gsaklua9pr13bkBzvX0rqrTVrK/XY2Ax7GvPjHIvVTTQwU5U4PtUOJupqW56elsYTugb5T261DfQC7tniPXFc3pevPERBdHK9Aa68MkqiSM5BpXCULHM6bKZYWt5PvJkfhWpozERvCeqsT+tZl9EbG+S6ThJDhqu2ziG/4+7KABVS1VzGmrScToDgjBrIuINjcdDWtTXVXXDVm0axlYz7S4MZ8p+h6VHfWBYm4tuHHUetJNAUNWra4yPLfqOlCfQqUeqOYFoLpi0R8q4XqPWnpezWzeTqCFf8AarcvrATfvoflkXnjvVOGdbgG2vFAccc1opGLpqQyWCK7i+Qg+hrnXL2zmG5HB71vS6VcQEzae/HXaeaqTTR3SfZ79PLcdCatWZg1KDuV4ZpLfG05Q1rRTJMPl6+lc+jPZP5M/wA0R71ovazxqLi1+dOvHaldp2Zfs1NXialHXrVGC+SX5JPlaruapM53Frcy5C1hL5g5ibr7e9aaOsih0OQaSSNZkMb9DXNLJcaZOY+qdce1J6GkVzadTqKKrW93DcrmM89xVimZtBSMAw2noaWkpgYl1I8bCAfMB0FWLS0APmOMsasXFsJBuH3hVvTHSU+XJw61lI9GjUTj5l2G3AG5ulWD6AVa2DpRsFIhyuVduTXG65L5l0Ih0ruZF2oW9K86uG86/wA+hrWG1znm7yS7GlCgSIAelONJnAqhcXoX5Y+T61d7GaTkXGMSAtIcCjSbiAXnmSjAHTNc+7tIdznNSQ+Y3GcCs5e9sddHlh8R6Te63ZwRZVwT6VwF5fXOpT71BwvQVGEBlCE7iaupIsPycA04w6szqVlH3YIzS97CdxHFa1neR3UZt5+9J5iPxWZcxG2lEifdNOUexnTq66nSaEDa3bWr9G6V1T2ynkVxljPvu4Zh1zg133YVgd09bMwry3xA4I7VwMoy+weuK9Qv9os5GPYV5zbxGa+UAZG7Jqk/dOblvULKLsuI1/2RW5IcRqP9qslgDqJUdFrVmPyoPVxVLYmbvUNRfuAe1LR0FFM5wpKWkpiCiiikAyVtqE1hJ897GPQ5rVu3wmKzLIb74n0ApvYql8VzePWkpe9JQSxDSN90/SnGmnoaES9jhZG8vUCffFasgyDWNecXTH0atlDvjDeop9WjeL91M6TSpPMslHcVy+rxeXfM3Zq29EfAkiPYjFVdfjwY5fTOazp6F117yZg2S77rd/drfrI0tfvyeta9WhvsFFFFMRHMMxMPasO3+6w963n5RvpWDB1ce5poze5NWrpQ/ek1lVsaUOSaUyobM3Kzb+TgRjvWhnAzWHM3mzfjQu5lu7GrYJsgz/eq7TI12RhfSnVI3uFFFFAgooooAa5wv1oXhRTZDyo96fQAUUUUxBRRSUAFFFFABSUtFAFW7H7uuQ1BSCHFdjcj90a5i8TdGatbCj8Zb0q84Ge/BrpeOorz62doXz2712djcCWMKTyOn0rHZnTJcyv1RepaKKoxKV5Fuj3jqK2NMuPOtwD1FUmG4YPeqthIba7MR6Gomup0Yd3Tpv1R1FFJRUli0UUUAJRS0lABRRRQAlFFJkUCAio2XIqTrSEUwZk3MeRmsg9a6KdMg1gSrtat6bOCtGzuRUUUVoYBmlzSUlADgSK3tPm3rsPasCrdnJsmFZ1I3Rvh52lY6iikByM0tYHoBRRRSEFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFMYUUUUAFFFFIAooooEFFFFAwooooAKKKKYBRRRQAUUUUhBRSUtMYUUUUAFFFFABRRRQAUUUUAFFFFIQUUUUAFFFFABSUUUAFFFFABVWa4CfKnJptxPj5E696o1zVa1tEbwp31YhG5tzcmlooricm9WdCSWwUUUUgJ7X7pq1VW1+6atV60dkcUtxKKRt38NHOM1QhaKYrbu2KXeu7bnmgB1FFFACUUUcUAJRS0UCEooooAKKKKBhSFQetFBx3oEG1aTYtJgUcUABjWjYtJRkUwDy1pPLWlyKMigBNi0eWmc0ZWjK0AJ5adcUnlRntTsrRlfWgBnkx+lJ5EXpUmV9aTK+tADPJj9KTyIsYxUm5fWkyPWgCL7ND6U8RqOO1OyPWjI9aAI/IjBJx1oEMY6CpMikyKAGCGMcYpPJjznFSZFGRQA0IqjA6UhRfSnZFGRQAm1RSFVPbpTuKQ9OKBBSU3NG6gB1FM3CjcKYD6Q8fjTd4pcqRzSAKKTNJmmA8AZ5pcAHmo80ZoAD1pKKKYgooooAKnt/wDWioKmg/1gpMqO5rUlLSVABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAwooooAKKKKACiiigAooooEFFFFABRRRQM//V6ylpKWgYtFFFJjKTfeNNpT1NJXkS3O5bBRRRSGFLSUtABRRRQAUUUUAFFFFABS80lFADw7joalFw461BRVxnJdSXFM00bcu6nUyP7g+lPr1FscT3ClpKWmIKKKKACiiigAooooAKKKKAFooooGFFFFABRRRQAUUUUAFFFFAgooooAKKKKAFopKWgAooooAZJ9w1l1py/6s1l1wYrdHTR2CiiiuQ3CiiigBaKKKAHxttcGtMEEZFZNTRTFDg9K6aFXl0ZlUhfVGhRTVkR+hp1dyd9jmtbcCAetVZLcHlKtUUpQUlZjUmtjJKlTgikrUeMOORVCSJkPtXBVoOOqOmFRPcjopKWuc0ClDFTkU2imnbURbW6/vChrnIwoqpRWrrSta5Ps4inmkoorIsKKKKACiiigAooopAFFFNdggJPamlfYTdh1FYMutopKopyKqtrkx+6o/KuiOFqPoZOtFHUU1pFTlzgVyDaveMewqlLczynLsa1jg31ZDxC6G/qGptEcWzqawp7ya6GJcfhVbvk0ldsKUY7HPKbe4UUtJWpAUlLRQAlFFGRQAUtJRQAtFFFABRRRQAUtJSgMxwoyaACpYkkaUKqnNa+m2Dk+fwSDypro/s0HmCfbhgOgrjq4lRfKbwpN6mVZ3Udvv8AtDgMxGFPWttWDDIqr9itnlMjrkk55qKRbz7Qwgwqe9ccuWTujpV0aFFNVsqPWnVgaBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFIBaUAnoM0lTRNtOPWtaUFKVmTN2VxqxndhqXySWOOlWCc0oNdscPFbnO6rKrQuBkc1dhz5YzxQGXO3PNSYx1q4Uoxd0KU21qLRRRWpBUvM7RVAKx6CtogNwaaNgHGKxlSTdzWNSysYxBHBqzDAXOT0q/5aHkingAcClGik7jlWbQoGBgUUUVuYBRRRQAUUUtACUUU13WMZahuw0rkc5ATHrVMQyHtVmMGZ97dB0q3XPKmpvmZop8uiK+0QxZ71nE5OatXMm47R2qrXLWld2RrTWl2FQT/dFT1DP92lS+JBW+BjtN/wBcfpW3WNpn+sb6Vs16Zzz6BRRRQQFFFFAGfqUXm2rjvXCpO7797sCOBzXo8ihkZT3FeVXSmG4eP0NaLVEx0lY6wRbAArtjAPX1q0LK5Do6yfKetclHqt1s8ogemcV39hL59mrke1Tsi2QpDcdNw6/pSxG6LMHxgdKwNc1OaK5EMJ24AJpLbV5prdgxGQOopPRAtdDpWeZOcZp9vLJLkSDBFcdpM15d3Z3klF9K6bUb9bG2Mv8AEeAKYWNLIHBoNeZx3l5cXakueWHAPvXpKdFB9KQ7aXHhSaje4t4f9ZIFx61zHiDVLi3YW0Pyg9/8K5yOGeZTLO5x7mnYk7xNaspLkW0bbie4NaprgPD9tHNemX+50rvec0MaHCnYNZep3DW9sdhCs3AJrmLW4uYbpCJxIGPIzmpKjG53VFVTdRKuScn0qL7XI33VIHvTE4tF+iqnmSMOoFNQyq3zH5aBF2kqvvz0o3GgCxRVfe1HmGiwE9FRCT1p9ADqw785nx6Vt1hXnNyfpVRJkV6aadTT0qyRtFRhmpdwoAfRTN4oLigB1RzRrLGUboaPMo3Z4oAFVUAVegpkqB09xUlFMRnQtiXB78VmRfLdSL9a2Zo8OJFrHb5b0+4qvMzfVFmiiimZjWOFJ9qdZD92zepqKU4Q1PaDEA96XUpfCy2rFTkdqnlunlXaarUUOKvcSk0rBTlODim0UxJktFIDkUtIoimGVqrGcNV1xlTVEcNTRDKGoriVWqsvWtDUVzEG9DWap70upr0Q9BvuEX8am1Bt0yxjoBRZDMzSH+HNV5G3zO/vSGJRRRTEFRydMetSU3G6RVpPYpbmsgwgHtTqO1FMzZKnSopjh1NTL0qC4/hPvQxLcvKcrTqiiPGKkpiFopKWgBaKKKACoJF5qemsMigQlnJtYxN+FN1BdsqP6nFQNlSHXqtW7sia1WQdV5pGm4tk33k/GtCsa2fbIreorZpEsKzdVXNrn0IrRqrervtWH40MI7mUD+6R6uWiiWd7uY4Re5rPiO62U+gqCS5aSIQjhB1x3qJHTSdr2JL6/kvX2J8sY6Cq6Q55anxx4+YipiwUZbpUN3OhRsOCqgz0qu9xj5Yx+NRO7P8AMxwtXLTTp7z5sbE9T3pDvYziWZu7H0FXYdPvp/4fLHqRXUW9jbWowi5Pqeat8mqsZttnLT6RHawGW4kyewBrDjjaRsKP/wBVauq3LXFx5YPyrxj3FS20YjjBxya0UTByIIfLj+THze9TNK6844ptyi4Djgg1JbEvlmHy4pPQuLuRi4VqGEMvDDH0p+EAzgVA0kQOMipcjVQuV5bRl+aM5FbWi6s9s4t5z8h9e1UY54wdpqSa08xPOhHIqHqaxbWjO7ubeO9tmj/vDg1zKO8QVJeHgbP4dBU2g6mxP2S4PPatLV7Jnxdwj5l+8PUUk+jMqkeV3RrRSCWNXHcVJXPaNehswOe/FdDSQ5KwjKHGGrOmt2T5lrSox60mgUrFKCfd8knUd6ZeWKXI3D5XHQip5bZW5Xg01JXj+WUcetA9HqjOhuZbd/JuBgjofWr0sVvdptmUc9x1qaWGK5TB59DWfsltTtb5k9adw3Mu80WZFJtzvT0PJrHtr+fTZtjA7e6mu4jlyMocio7iwtL1f3i4PqKtSujJws7optZ2GqwieE7W9jWe9vf2XUeag/u1Yg0m50+bfaPlO6nmuhUll+YY9RSuNpNanMw3UMpwDhv7p60l7a+cglUfOnI9627jTrW4HK7T6rxWY1lfWvMZEiDt1NWpJ7mDpNO8TPTTotQj86zby51+8vv9Ki8zU7Q7LiIuB3Ap73As7gXUYMbdGVu9ddDPFeQiVMMDUbGtlJXOSXU4j/rFKfWphfWrfxiuma2tn+9Gv5VA2mWLdUA+gquYh0kYongb7rg02SMlhNAcOtax0axPTcPoajOi2/8AA7j8aHIFTad0y3ZXq3K7G4cdRV/Fc5/Y08Evn20nzD1PWtm3uSwEc42v796g2eupJdcW7n2rzaLH2l5G6A16Rd/8esn0rzIHlx6k1rDY55L3xbi6e4cqnyoKiWNnO2MZNSQQPO21RgetdDb20cC4Xk+tUo31Yp1FH3YmfDp4ijaWXk46VnxdCfWuofmJh7GuZQYJHoapkU23e5NZrm6Bqe/i2PvA4NMsh/pQ+hrWnjEqFaaWgpO07mBbxTPICFO0Vo3FuZLdgw5FWLZwnyHirrAMppmcpanM6W5F1Gn+1XqIPA+leY6fHjVEQf3q9OHQfSuWW7PUTvCJk63N5Vg4/vDFc9oVvlGunHXgVf11jcTJZJ3PNXjEtpYlF42rmh9ERTWrkczD8167+5Fac3Msae4NZ1mNzl/Vq0x898q+i1o9kcyd3KRqmiikpmIUUUUAFFFRyNtQmgGZt0+5sVHpYzNJJ7VFO3Jq7pSYty/qTRPoXT0TZpUlLSUGYU1vuH6U6mP/AKtvoaBPY4O6+a4f61oWLb4APSs6Y5mc+9WNPbDmP15pvc3ivcsb+mv5d5t/vVoa1D5tm4HXtWKreVMknpxXVzgSRjPTFZrR2NK2tNSOR09dtqtXaiij8rMP92pa0Fe+oUUUUAI33TWBF/rHHua6CsBOLhx9aaM5bktbWlD5WPvWKa3NK/1bfWlIqOzL11J5cR96y7VN8wH41NfSbpAg7VJpyZcv6UPYzh1ZsGkooqSQooooGFFFLQBCfmlx6CpTUMXLM34VLQAUUUUxBRRRQAlFFFABSUUUART8xGuduBla6ObmM1z844IqlsSnaaZD9hM9ibmMfMhwfoKj066MbAGuk0ABreSNuQSc1zup2bafeFgPkbkH61iveVjtqe5Uv0Z1qsHUOOhp1ZGl3IkTymPPataqTuYzjZhVC7Uo6zr2q/UUyeZGVptXViVLlkpGzayiaFWqxWDpExwYmrerFHZNa6BRRRTJCiiigAopKKAEJqM09umaqvIRTRLdiYGpAc1S84jqKsxuGHFNoSlcSQZFYN0uGromGRWNeJ3q6b1MK60MqkpTSV0HEFFFFIQtOQ7WBptFDKi7O51ls++IGp6zdNk3R7a0q5HueqnfUKKKKQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQMKKKKACiiigQUUUUxhRRRQAUUUUgCiiimAUUUlAC0UUUAFFFFABRRRSEFFFFABRRRQAUUUlABRRRTGFFFFIChNbsGLryDVWtkVh3bFZ/lrCeH5tUaKty6MdRVcTHvT/OWuV4ea6GyqxfUloqLzlpjTccURw829gdWKL9r9w1ZqrZ/6vJq3XopaHM9wopKKBBSYGc45paKYCU0KQxJNOooAKj4HBqSkwD1oEM460vU5NOwKQqKAEGM+1HynvQVyMCkwfSmA7gd6TIphHPINOBHpQA7moDI3mbAOMdam3L0qLA8wkelADRMeeOlAm9VNNxwewoKrtGDQIe0oXqDSpIHGQDUDISeTVhAAmBQMYsyMcAdKcXQcGoY0UEnPNPKKW+bvQIfvU9KTzE6ZFBQAHFQLGpXdQBY3IOpApMqeQQahdeV9KkEa7s9KAF3J3IpN8fqKbKgznHSodmRuFMCxuj9RSZHXIIqB8fKT071KUG07OQaAF3pnG4Uu5BxmqywkjqM07y3BzjP0oAsAxn+IUhKeoqv5ZCn3pNjY9jQBZ+U9xR8vrUCq2zYBj60AOGO4ZFAE+1fWkwnrUKh9hyOe1N+c8Y5oAnwnY0uMCq/zgDC81Z7DNADQOaCR3pB96lx2oGJkUZFNkkji++cVCby3AyWHNAFjikqv9rt8E7hx1pPtlts37xigRZpKg+1QBdxcYoF1bsCd44oAnoqITwH/lotS9eRzQAUUUUxBRRRQAVNB/rBUNSw/wCsFJlR3NekpaSoAKKKKACiiigAooooGFFFFMAooopCCiiigYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQIKKKKACiiigYUlFFAj/1uspaKKBi0tJS0hlBgQTkUlaGAetNMaHtXHLDdmbqt3KNFWzAOxqMwMOlYyoTRoqkWQUtOKMO1N5HWsnFrctNBRSUtIYUUUUAFFFFABS0lFIBaB1opV+8KqO6E9jST7op1IOgpa9ZHCwooopiFooooAKKKKACiiigAooooAKWkpaBhRRRQAUUUUAFFFFABRRRQAUUUUCCiiigApaSigBaKKKAIZ/9Waza0bj/V1nV52K+I6qOwtJRRXMbBRRRQAUtJRQAtFJRQAoJHSpVnkX3qGiqjJrYTSe5Z+1P6U03EhqCiqdWT6i5ESGWQ96aXY8E02ipcm+o7IKKKKgYUUUUwCiiigAooooAKKKKACiiigAooqKcSGM+V96hK7sJuxkahc3sDkIvy+tYMl3cSHljVq5uL3JSbNZh55r2KUEkcE5XYEk0lHFHFbGYUlFJmgBaKTIpaACkozSZBpgPCOwyoJqSOCWVxGqnLVe06O6Y7Y4ycnuOK7e0tSqZnRQw6balyKSOctfDkm8G5b5fatX+xLKH5trMBW9VS8juZotluwUnvUXZVjzzUIYYLlo4M7R61SrprjQL133gqSepqodAvtpbjiruiLMw6K6WPw/cfZ2zjf2qRdAnNvg48yjmDlOXororfw/dGUedjaKY2hXfmPwNo6U+ZBY5/IrZ06xMrCYuFA7GrZ0OdV37QeKiexvTtKoRgY4qJ6qyKirPU6bCRIZFxwMnHesU66SSAnA9qtpJLHZ+XIjbtuOBXOCOUdY2/KuOlRWvOjac30NYa04OSo/KmJq1zJI5AXbWWUfup/KnrhexFbeyh2I55F/+15inCgevFa2nNI9qpkOTk1zJCsQB3rroVVIwq+lc2IUUkkjWjdu7JaKKK4zpFopKWgAooooAsfuvL96r0UVUpXJSsFFFMkfy4y/pSSvoNkmKMVz7X05PpSfbbj1rf6vIj2iOhqSP71c/HqEwYBgMGt+I5OfUU6dNxmrilJOJZpQeabSjrXoHKcTqN3LFqDbXIAz0NaFrrU8YAfDr+tY2sDF8341nK7Icqa1cUzNTaPTLbUba5HyttPoave9eXpcc56H1FbFtq9zBgZ3r71Dgy1JM7imGNTWTb63bTfLJ8prVSWKUZRgfpUFkgpaKSgQtFQzu0cZZetZi3su4ZqZTSdjSFJyV0bNFIp3KD60tWZBUE0rxkbanqvc425rOpfl0Lp76kJuZDwBUWSzZc5pVVm6DFWFjjj+Zzk1zrmesjduK2LaABQBUU0gRcDrUD3P8KVUZixyaKlZWtEiNPqwJyeaSiiuM3CoZ/uVNUE/3K0pfEjOr8LJdM++1bNY2ldWrZr1DnnuFFFFBAUUUUAFcxqXh0XcpngbDHrnpXT0U07A0eazaFqMOQBu+ldFoP29A1vcoVQdCRXUZozmncDzXXYrh75n8ttuMZxWfaMyh4+mVr1do43GHUH61X+w2fJ8pefai4LQ5/Q4o0g/dNlieaxNeuxNeCLOQgwfrXdR2NvCSYhtz6VnT+H7KdzIxOTQnrcqTucHZSIt5GWPGRXqWBwR0Nc43hW16o7A1p21jc26eX5m8Dpk0MHsO1CyivI8FcsOhrhtQspLV9gkLE/wg16UVbbjvXJ6tp90qb4FDMep70JknNadNNbyFoTg13OmXl1dRlp1xjpXC29perMB5bDJ54r06GMJCqgYwBTYDLs26WzSXIBCjvXL6fZfaZ/tUYCx54FXdWlFwPszpJtHdR1ot76C2hEUccmB7VJcdNTQDWzMUQjeOxqVZoCdjMA3pWKLi2+0faPLcEjB4qNZYDO0zo/txSLfK1qdC0IPQ1H5ci8Kc/WqsF6gGGDc+tXUmjk+4aZk1YT96B2pN7jqKmopiIPM9VNL5i1NSbQe1AEJlj7sBUqywgcyL+dIYoj1UUw2kDdVFAEvnQ9pF/OsW7aPziwcH8a1BYwelY88EQlIAFVETK5mQfxA0wzp71N5Uf8AdFGxPQVRJW81ewNIZfRTVravpRgUAVPMPZTQGc9Fq3RTEVh53oKVVk3ZbGPap6KAG0UUUhiEZGKwLobL0e4AroOKwtRG25RvUimjOS1H0UUVZkVrk/Lir1uMQr9Kzrg5YCtOIYiUe1LqV9kkopKKZAUtJRQAoODUg5qKnA4pDTH1QcYY1fqrMMNmmgZBcjfbH2FYiH5a3R8yMvqKwuhZfTiky4bF22+WCR/WqSfdye9W2OyyA/vEVWAwMUkULRRRTEFLCN1wPain2gzMx9KTGjSoFFKvWmQTDpVe5+4D71YqpeOqRcnntTYo7lmI8A+tWaoWzboVNXlORQDQtAopgODQIkpaSloAKKKKBETr3qNG2o8J6MOKs4zxVWVSDmkUmQxH5f8AdOK3Im3Rg+1c9CcOyHvzWxaPwUoBlyo5RuiYe1SUnYluBSEcqJhFE0PcGkhjz8zUror3rsn3c1K7hB/IVlJnfSjZXYO4QZNVWy3zycD0qQKSfMk/AVYtLY3twEP3R1qTXct6bp/2k/aJ+EHRa6YAKNqjAHYUoUIoRRgClpoyk7jcUyU7IXf0FS1WvDi0l/3TVLcmWxxCjzLgn1bNbHTism05n/CtY1qYMgmjMoCj1qRyscOxafUcq7lwKTRUWRJG0zrEP4q7+z0Syt413IHPcmvPgJUKMOq118XiER24M6EMBzWNmdVR6LlNi50exuU2FAp9V4rkZraXSLgLJ80THrVk+LmLfJHlap6jr638BhaPB9aHEzjU5WMvrIwMt9a8jrxXX2Nyt3arJ14wa5jRbtZIzZz8g9K6OxtfsgZF+6eRUWN6mqujJv8ATntpPtdn25IrXsbxLuIEcMOCKu+1ZktgUl+0Wh2t3HY0MhO65WadFV4Zw4w42sOuasUENWCkIDcEUtFAEPlbTlDinAkjbIM1JRRYdyjJalTvgOPbtSRTbTtkGKv01kVvvDNKw+bowB7qeKWmqgTgU+qJEpaKSgCCa0t7gYlQH3xzVKGwksn3WjZQ9Vb+latFAXEHTmloopAFFFFMAprIr8sOfWnUUgGvEZYXhB+8MDNcbeeH5bKM3Bbdgkke1dhOzrbyGP72OK446rfXEZgkA25IJ71tBN7GFSSW4tuqbQVGM1bqCMAKNtWK2OJO4e1c667ZnHvXR1TnsVmbeDg1LRpCVnqUtPGZy3pWzioYLdYAQvU1PTRMnd3MmXifitJDxz6VSkXNxVmRxGjP2ApifYq6PF5urM46LzXd5A5PQVzXhyDELXLDlz/I1rajIUtjGn3pOBXK3dnq7RSMqzj+1Xsl6/QfKv4VZ1iTZZN6sCKuW8QggWMfU/WsPXZP3aReppLVik+SBRsF/dr9a0LMb7h5fTK1Utfkt2Y9hV/TlIhZz/Ec1q9zjirU79y/SUUUGYUUUUwCqVy/8NW2baM1kzvnmmhMoTt8pNb9mnl2yr+Nc+VMkqRf3jXUAYUD0FS/iNNoeoUlLSUzMKilOIXPsalqC5OLdz/smmhPY4Nzl2Pqadbt5c6tUWct9TU0yGMqfxpSOmHY3Jh+7yOxFdNbv51qreormYz5sIb1FbOkyZtdp/hqZb3NFrTaK1yu2bd/e/pUVXb5cAN/dql2qzCk9LdgooooNAFYPS6et4dawpOLtqaIluPNbemnbAzVimtO3fZaN7miSDoxkjb3LVs2CbYc+tYijJA9a6OFdkSr6ClLchaRJKWmHrin1JIUUUUDCmucITTqhnPyY9eKBCxDCfXmpKRRhQPaloGwooopiCkpaSgAooooASiiigBkn3DWBOOtdA33TWDcDrVR2IlujV0A4DD61p6rZre2rJj5hyKzNC4LfSukrng7Hp4hXZ5nbSSWs4VuGQ4NdmjiRA69DWLr1gY5PtcQ4PWnaRch08k/hVvR3OePvRs90bVFFFUYsoRsbe846GupU7lBrl7xcbZR/DW7ZyeZCPas5KzOqm+amvIuUUUlIYtFFFABRRRQAmKrSQtnK1aoppiauZ3lP6VZijKDmrFFNu4lFIbVG7iyprQpCARg0k7CnG6scg4wcU2uhuLBJOU4NZEtpLEeRmumM0zgnSlEq0UYxRVGIUtJS0AammPiTbW/XL2bbZhXUdRmuaa1PToyvBBRRRUGgUUUUAFFFFABRRRQAUUUUAFFFFMYUUUUAFFFFIAooooAKKKKYBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFACUtFFABRRRQAUUUUhBRRRQAUUUUAFFFFABRRRQAlFFFAwooopgFFFFIQVz92czGugrnbg5mb61cSJEFFFFUIKKKKBmtZ/6qrVVbT/VVarM1YUhIAyeBS1Bc/wDHu/0oEO86L+8KEljkJCHOK5ksxIx+Va2mAbWOMGqaEmadFBZV61mGaVZOuQTUgadFZ7XrBsAcA4qQ3iKfmBphct0VXS5jkOBwT61IZowcE80gJKM03cvXIpdw9aAFpKMg0UwEwPSjAoooATApNq06ikAwopoUBeB0p9JQAzag5oKrT8CjFMBgCjjNNMaepqTaKQigBhQEc0nlgHOTTttBz2oAbt65PWovKI4B4qb5vSgtQIi8s429qRFdD7VJv9qXzBnGKYERjI+YZ5oCsPWpt4xmmCZTQAwZxtpMMvHWpd4IzTDMoOKAEDN0NLl6TzkBwe9O81KAGeYx7Ubm64p+9DSh0oAj85fQ1IelMCxjnOaf2oAb/FS009RS96ABlRvvgH61E0MPdB+VNuIDcJsDFfpUD2UjDb5hxQBOYIM7do+ak+y2+3YVGKryWk7RhVfkUhtLjyjHv+Y96ALZtoCAjKMdqQ2duBjaMVWS1uR99s+lRx216iMrNkt0oAttaW5PIxn0qbaFG0dBWcYL0R7NwyvOauw+Z5Y8371AiSiiimAUUUUAFSxffFRVJF98UmOO5sUUUlQMWkoooAKKKKACiiigYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUCCiiigAooooAKKKKACiikpjCiiikI//1+tooooGLS0lLQAtFFFIBaWkooGLSFFPUUtLSsFyEwIfamG29DVmlqHSi90UpyRRMDioyjDtWlRWTw0eharMy8HvRWmUQ9RUZgQ+1ZPCvoy1WXUoUtWjbDsaYbdx0rN0JroWqsWQU6PlxTjE47U+FG35IohTlzK6CUlYvUUUV6RxhRRRQAtFFFABRRRQAUUUUAFFFFABRRRQAtFFFAwooooAKKKKACiiikAUUUUCCiiimAUUUtABRRRQBWuT8lUKu3X3RVKvNxPxnXS+EKKKK5zUKKKKACiiigApwBPSm1at2UZBqoRTdmTJ2RXKkdRTa0JWTZzzWfTnFRegRd9woooqCgooooAKKKKACiiigBQpbgU5kdRkinxTCPqKWSfeMCtLR5b31IvK5BRSUVmWLRSUUAFLSUUAFFFFAEckEUv31BrMurKzQqCMZrXqKWGOb74zWkJtPciUbmcNOsQOoqaOwtdoOwGpBZRKd2Salj4QCqnUb2ZMYd0Q/YrX/nmKT7Daf88xVqjrWfNLuXyogGiWtxGTjaT0xVX/AIRaPP8ArDXURjagFPr1oXSscUrNnOx+GrVPvOxrRi0iwi/5ZhvqK0qKq4rDESOMbY1Cj2p9JS0DCiiigAozRSUgFopKKAFyaMmkooAXJpcmm0tAAeetNMcZ6qDTqKAIjBAeqCmm0tT1jWp6KAKv2G06+WtP+zR9uKnopOKe402tit9lXsaabX0NXKKzdGD6Fc8iibZ+1NNvIK0KKl4eHYftZGb5TjtTSjDtWpRUvCxK9szKwfSkrW2qe1N8tD2qHhezH7Yy6qXzbbZq3TDGe1QTWMU6bG4FEcM073B1U0cTmiujfQYj9yQ1WfQpx9xs/WurlMuYxs45rqLVt4B9hWNJpN7GOgNXrFpIRslUg/SolB8yZSkrNGzQOtRCRT3xTwy9jWxmcPra4vSfUVj10PiFNs6ydiK56tVsYvcWnq7LTKKYFtZA3WrMc8sXMbkfSsynq5FAJtbHSQ63dRcNhh71pxeIIW4lUg+1cesgNTA1PIi/aPqd0mp2coxnGfWlBsmOQy/nXC8Uo46VLplKrY9FE0AGA6/nQZ4R/Gv51wUHzNgmrJT3qlRbM3XSdmddJe20Yyzj86q/aBP+8j5HauKuAMEV0umjbaJ9K48XFwS1OnDyUr6Gj5j4xmmZJ60UV57k2dVgoopKQC0lFFABUM/3KmqGf/V1pS+JGdX4WTaV/FWxWPpPRq2K9Q557hRRRQQFFFFAwooooEFFFFABRRRQAUUUUDCkoooAKWkooAMCiiigBMA9aTYh6gU6mscUAHlx/wB0Uhji7qKiLtTCx9aYD3jg/uiogkSn5FAoNJQIWk5oopgGTS5NNpaAF3Gl85R1plIQD1oAmE0Z71hzEGUkVoOq4NZB6n61USWOpKbSHpVCHUnFRUUASZFGRUVGKYEm4U1mzgCm4oUc5pAOwKXAoopiuLWNqwx5bf7VbFZerD9wp9DQiJbogHIBpaYnMan2oc4QmrM+pVcZO7/aArWUYUCs2RdsSDuxBrTpdRvYKKKKZAUUUUAFFFFADge1MlGVzS0vUEUDKiH5vrWLcDZOw9TWyflas2+X98retKRcNxLjhI4/bNQ1JOcyIPRajpIphRRRTEFT2Q+81QGrVkP3ZNLqPoW6evWmVKlMhjmYIpY9qrLam5jeaT0O0U4g3EwhXoOTWwqhVCDoOKT1NIqyuc/Yt8pjPUGtJTg1mSL9mvj6PWjTRM1qT1EeDT1ORTX60zMepyKfUCnBqYUALRRRQIWmOu4U6loAxnzFOD68VowPtkHvVe+iynmL2pkL7kDdxSL8zf68CsG7nufN8iQ7VPQjvWvG+5QaS4gS5jKt17GpaKhJJ3ZhErEuAKaqn779T2oCMrmCbqvQ+tOJIJZutZHenfVDH4GK6bSbcRWwkPVq5wr8oJ7kV2kICxKB6UluVLSI+lxT1Qt0qwtux60zEq7aq6gh+xSn/ZNbi26jrUGoxL/Z8wA/hNCeopbHmll/rv8AgNap45rKtGWOUu/QCnyTyXT+XEMCtmY26k8l0inanzGo8yycs2ypRFHbALjdIaljsvM+ec59qLMzlUSKuxj0kz+NacUq3MZt5hhscH1qrPYRhd0XBFV43YoH/iWnbuLm5loyphoHaNu1Txr5pwBWl5CXUiyf3utXYraOIYUUJEymjFiLW06npg16LaSiaEMPSuHv4sjeOtdBoE++MxnsKxqKzO3Dz56bXY6GiiisxjWRG5Yc+tKBilooAKKKKACiiigAooooAKKKKYBRRRQAUUUUAFFFFABRRRQAUUUlIBwAPynvXDSQNBfPbt3O4fjXcVga7bkBb6Pkr976CtqUrOxz14XjoZsP8SHtVodKpxsHxKvQ9au9q2ZxIKKKKRQUEgAk0tQyfNx2oAqj5mLnvUd0WlC2yfekNTMQBuPAFXdHtTNKb6Qcfw1FSVkb0IXlzM6C0hW3gSJewqs6me73n7qcj61ezUfAHFc9zvsHWuO1eTfe7OwArsC20Fj2rhJWM16W/wBrFXSWtzHEv3bGg3yWwTu5x+dbMKeXCiegrJ2+bdJEOiYato9apGE9EkFFFFUZBSZpruEUsahWQlNx70ANnftWTI2TVyZ+Caz2OBk1SJ3JbFPNvM9k5roKy9Ki2wmU9WrUrNdzapvYDSU1zgClqjIWqeoNttHPtVuszV222Z9zTQjkLdPMkA9Ku3i5G4duKSyj2pvPU1KQbiXyU59TSex07E+nvvgC/wB3itfTDtklj9xWBYnyrl4D3Nbdqdl7j+9U9TWPVGrdLvUj1rHQ5WtyTk1hLwSvoapbHNHSbQ+iiimahWJPxd1t1i3fF2KaIluONWlb91tqsanToKfUUvhLNum+ZV/GukrG09Myl/Titgmoe4paWQwcv9KkqKPnLetS0iQooooEFV5fmlVfQ5qxVZPnnY+goGtyyaSiigQUUUUwEooooAKKKKAEooooAQ9Kwbnqa3j0rCuvvGqRD3Rp6F1b6V0dc7oY+8a6KueOx6tb4iC4hWeJo271wLK+nXuzsDx9K9FrnNdsvOi89B8y1a1Vjkk+WSmW43EiCQd6fWJo1z5kfkt1HStummKpGzIpk3xlafpUvG006qNufIuyvY0prQrDv3nE6eikByM0tQahRRRQAUUUUALRRRQAUUUUAFFFFAC0hUMMMM0tFAFCawik5Xg1kzWMsXbIrpaOvWrU2jGdCLONII4NFdRLZwy9sGsqbTZE5TkVsqiZyToSjsUIjtkBrrIzujBrkirI3zDFdPaNugU+1Z1e5vhno0WaKKKyOkKKKKACiiigAooopjCiiigAooooAKKKKACiiikAUUUUwCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooopCCiiimMKKKKQgooooAKKKKACiiigApKWkoAKKKKYwooopCCiiigBD0rnJjmVvrXRNwprm5Dl2+tXEiW4yiiiqEFFFFA0a9p/qqs1Wtf8AVCrNZmr3EqG5/wCPd/pU1Q3P/Hu/0oRJzfIbA4NbWm8QnPXJrIVTk4P41saehSAgnPJqpCReKq33hmq/2ZN5YdCOlWKWpKZQexJHyHnNJJaOWDJjgYOa0KKLiMtLWYSKzY4Hao2iYSPkdTWxRmi4WMyNTv5BwKiw4jJOQa16Qqp6jNMRnOWBVVOM0u9vMVd3BPNXjHGeqim+VH1A5oAqCd8t6LSm4kGDgc1Y8hOfemm2U4+Y8UAMFxztI7ZpFulILEYwcVJ9mG7dmmG0ypXPU5oAX7Sme+KU3EeR15qJrV+MHoKDbPldvQUAT+agOO9KsiNwDVdrd/M3jpTEhkHGMYp2AublzjNG5QcZFUwj72JH0pm0hTkHNFguaGR60cVQYPhRz70pL7iBnpRYLl3Iox2qjl93GeKbvk2kgmiwi/ijaKoeZKMdeaPMkD454FFgLxHam+Wo4Aqos8m0k/hSLcPtweaLBcubR0oKKe1VROxQ560iyMJQM8UWAsGFD1pDAtQu7GTrgClacgYFAyUQr2oaFTxmo3couVNNLuMOT1oEPMKjvUnbFV+eparGeBQMa3UUp60jdqKYAWCLuPaq326HrhvyqeTOw7Rk+lUhPcBQDCtICwb6IDOG/KnC7hwTzxVQ3Un8UIpv2vsYf0NAF5bqF+RnihbqF84J4qgLtB/yy/Q0n2qEf8sj+RpgaH2qHbuzxnFO3BhuFZv2q3PBiP5GrUE8cvCAjHqMUCLFFFFABRRRQAU+P74plOT7wpMcdzZoo7UVBQUUUUCCiiigAooooGFFFFMAooopAFFFFABRRRQAUUUUAFFFFABRRRQIKKKKACiiigAooooAKKKKACiikoGLSUUUAFFFFAgooooA/9DraKKKBi0tJS0ALRRRSAWiiigYtFFFAC0UUUALRRRQAtFJS0AFFFFAC0UlLQAUUUUCCiiigApaSigBaKKKACiiigAooooAKKKKAClpKWgYUUUUAFFFFABRRRSAKKKKBBRRRQAUtJRTAKWkpaAKd3/DVOrV394VVrzK/wAbOyn8IUUUViaBRRRSAKKKKACiiigAooooAKKKKACiiigAooooAKSiigAooooAKKKKACiiigAooooAKKKKACiiigAPSoV+7Up6VCv3aBD6VeWAptSQ8yqKuCvJClsaw+6KWiivWOIKWiimAUUUUAFFFFABRRRSASis86lbh2Q/w9TTv7SteOTz7U7MVy9RVMX9qejHj2qRrqBQGZsA9KLDuWKKhW4gcZVqXz4T0alYLk1FR+bH/eFKGU9CKAH0U3cp6EUuR6igBaKTj1paAClpKWgAooooAKKKKAClpKKAFooooAKKKKYBQQD1FFFAERghbqoqM2kR+78tWaKBGLe6ML1drSHjpWBN4Zu0/wBS276mu5opqTFyo8zl0q+g++n5c1SaOReGVh+FetZqJ4IJOJEBp84uU8o+tFekSaNp0vWMA1ny+GrZ/wDVvt/Cq5kLlOIp6uy10knhiZf9W+6qMmg6hH/Dn8afMhWZQWUHrUwINRvY3UX30NQjzFOCCPwpkmpa/fNXWxisVJWXkHFS/aHIwTWkWYyjd3GSnLEeprq7QbbZB7VyYG+RV9SK7GMbUC+grzMfLVI9HCL3WSUUlFeedYtFJRQAtFJRQAtQz/6s1LUU3+rNaU/iRnU+Fk2ldGrYrI0roa169Q5p7hRRRQQFFFNLxrwzAfjTAdRTQyt90g/SnUhhRRSUwFopKKQC0UlFABRRRQAUUUUAFFFFABTGp9NbpQBXNNp5ptMBtFOxRimA2jBp1FADMUuDTqKAEC5pdoopaAIZ0URlhxWB61v3BAhb6VgVUSJC0h6UmajZgOpqhDgKMVUe4jTq1VHv4h0NAGrSEj1rCbUCelQm7lPQUCOh3J61IuCOK5f7RP24q5a3zxcSDNAzdoqKKZZlytS0xPQKz9UGbX6VoVS1EZtW+lMiRnRcxL9KRhvdYx3NEH+pU+1T2a7naZui8VRm9LsgusfaY4h2FaB61kK/m35PpWuetShyVkkJRRRVEBRRRQAUUUUAFFFFAFeUYOaz7wZ2N7itSQZXNZ10MxZ9DQ9io7lJzmQn0ptIDkk0tSjRhRRRTEI3Sr1oMQiqD/dNaNsP3ApdR9CanM4jjLUlNK+bKsQ9eabegoxu7F6xh8uPzG+83P4VdpANoCjtxS0kaN6mVqkJaMTL1WmQSCWJW9ua1mUOpQ9DXPQn7PctbHoTxRsyWro0gcGntyM1HTgcjFUYjakU9qjozQBYpaYpyKdmgQtLSUUgEdQ6FT3rGizHI0R7GtsVlXyGOQTL070MqPYv2z87DV5TWNG/RxWmj5IPrQBBqNt5sfmp95axoGMxw55rqB8wwa565t9kzFOCDxWdRdTqw8uhLKPlBHY13FlEk1usme1cLFJ5ilJOGrrNCuP3ZtmPK9Kw2Z2TV4eh0Coq9BTqSiqOUWobhfMtpE9VqagdaaA8gZcTPF33EVrxwizh82T7x6V0c2hWsV2147jHXb71zl9KJ7kqn3RXRB31OefYS2jLsZ5OTWmiMRk0ltGNik9MVcqkcknd3KxjJBFYAGyZ4j2NdRWFfKIboSEcN1pM0pdULYvsnMR7dK2D1rDsR5l2XHQVvbaI7Cq/EVZ03IRTtCfZcbPU4qWRflqppvyX6j3rOqtDqwT95ryO4PWkoPWisDoCiiigAooooAKKKKACiiigAooooAKKKKACiiimAUUUUAFFJRQAUUUUhhSMqSIYpBlW4NFFArHG3FvJplwVPMLnIPpmrsZDLlTkV0UscVxGYphlTXMz6ZeWLGSzO+P+76V0RqX3OSdCzuibGKSqqajFnbcKUP0NXVu9OIyWP5U3IhU2yMnt1p4t3cb3+RRUgvbcHFrGXb3GKeLW5vCGvDtT+4KhzNYYe+5nx25v5hHHxCnU+tdOirGoRBgCmIiRKEjGAKcTWMpXOyMFFWQ7NNJppNJUXNEitfS+Vau3tXJWS75mlPQc1ta5NshEfqayrdDHZk/xOSorohpE4qvvTsamnrvZ7g+pWtKobePyoFTvjmpqaMJu7Ciiql3OIIie5polsp3U/mzrbp+NWmPGBWTYAySNO34Vou21SaYmrFWZstj0qlIC+Il6tUrN+tT6dF51wZT91OlE3pY0pLW76G3EgiiVB2FSUUVJLdyC4O1Aacp+UH2qG9OIc1JGcxr9KZJJWHrj4hVPUitusHU8SXUcZ5HFGw4RvNIowo8yiOIYHc1q29uluu1eSepqbAX5VGAKKaOh+Rg3a/Z79JR0bNayHF7EfUGq2pxb4N46qadC++WB/Y1EtyqbOiasU/69xW01Yp/4+ZPpVox+2OpKWig0ErHvR/pKmtisi/4nQ00QwNWE6D6VXNWVHygevFMLX0NvT12xFv73NW5DtQmmwrshVfQU2U5ZU9eazRm9WTRjagFPopKAYtFJS0CEJwCar2w+Ut6k064bbEadENsQH40DXUkooooEFFFFMBKKKKACiikFABRRRQAhrEuvvGtysS76mmiXujU0MfIxrfrC0Qfuia3awjsenW+IQ1DMoaMg96kJ5pkh+U1aOeSurHCsDYahkfdJ4rqlYOoYd6xNWh8yLeOoqxpNx51uEbqtD0ZMPeh6GrVC6GyVJB+NX6q3a7oSe4qnsZxfLJSNu3ffEDU1Zmmybo8Vp1kjsktRaKSloJCiiigApaSloAKKKKACiiigBaKKKACiiigApaKKAIJLeKUfMKWGIQrtHSpqKYrLcKKKSkMWiiimAUUUUAFFFFABRRRQAUUUUAFFFJQAtJRS0hBRRRTGFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFIQUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAlFFFMYUUUUAFFFFIQUUUUARynEbH2rm2+8a6Kc4hb6VznetI7EPcKKKKoQUUUUho2Lb/VCp6gtv9UKsVmavcSoLr/j3f6VPVe74t3+lBJz+een4VuWX/Hv6c1iRjJOOa3LJSsGG9aqQkWqrSzlHCKPerVV5LcSNvBwalFMjF2NpYjocU8XKZAPBIzTDZ8YDUfZiTknoOKYiT7RGSF5yaXz4/XpVcQS5BI6Cm/Z5QuCO9FgLodfWnblPQ1QaOQFjjrSBGCjjpRYRoZHrRkVmYkEZIzmlJZcbSc0WA0qWs8ySeZtJOMUCSTnJosFzQoqiZn3AdsUqzOT7A0WC5coqr57EnHagXBwCR1osFy1SVXE5JwRQLgEZIosFyxRUHnjGSKXzlosFyWkqLzkxS+anrRYLklJgUzzE9aN6+tAXHECm4FG5fWjK+tMBNq+lN2J6U7IoyKAI/LQHgUGOMnOKfkUmRQBG0SP1FNMMZXbUu4Um4UAMMSEAHtTiikAEdKNwo3CgBPKXvRnHFLvFN96BATmikopgFLmkooAXNJmiigAooooAKOKKKACiiigAooooAKcn3hTacv3hQxrc2R0opB0FLWZTCiiigQUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUDEoopaAEopaSgAooooEFFFFMD/9HraKKKBi0tJS0AFLSUtAC0UUUhi0UlLQAtFJS0ALRRRQAUtJS0AJS0UUCClpKKAFopKWgAooooAKKKKAClpKKAFooooAKKKKACiiigApaSigBaKKKBhRRRQAUUUUAFFFFIQUUUUAFFFFMApaSloA5LXrmaK5VY2xisUahdj+Or+vnN8R6AViVpyJ7oycnfcvjUrsfxVINVuR1NZlLSdKHYPaS7msNXn7jNSDWH7pWLS1LoQ7D9tPubo1n1SnjWI+61gUtT9Vp9h+3n3OiGrQnqKeNTtz3rm6Wp+qUx/WZnTDUbY/xU8X9qf4q5elpfUoB9akdULu3PRqeLiE9Grk6X8TU/UY9x/W5djrRLGejU7ep7iuRyfU0oZvU1P1Fdx/XH2OuyPWjj1rlA7joTThNKOjUvqL7j+uLsdTRXMi4nH8VPF1cD+Kp+oy7j+ux7HR0Vzwvbj+9ThfXH96l9Rn3H9cj2N+isIX9x604ahNU/Upj+uQNuiscajL3FO/tJ/wC7S+p1B/W6ZrUVlDUj3Wnf2iO61P1Sp2K+tU+5p0Vnf2in92nDUI/Sl9Wqdh/Wafcv0VSF/FS/bYfWp+r1OxXt6fctnpVuO0j2CstbuFiFB61vJwoFdFCla/OiJ1E/hZB9ljpyW8aMGHUVPRXQqcexm5MWkooqyQpaSigBaKKKYBRRRQAUUUUAY0uluzs0cmA3bFMbS5eMPnFblFO7FZHPjTblVZQc7qWewuZI0QL9z3rfoouFjnVsbtQ3ydRjrUYsbobQU+77101FFwsc0bWfczPGSD05qQpKtp5QBDZFdFRSuFjliGAO0EE1L5mMIScV0fHoPyo2r6D8qLgc2J2BU7z7jFPS5k8xyXO3HHFbxjjPVRTGtoG6rRcDBN3c7Gw+NvNbdnI8luHkPNI1jbN1WlFsFGIztFAFqiq3lzr0fP4UZuR2zSGWaKredMPvJR9px95cUAWaWq4uYj1OKkEsZ6GgCSikDKehpaBBRRRQMKKKKACiiigAooooAKKKKACloooAKXNJRTACAeoFQyWttL99AamooEZcmjWEn8GKpP4dtz/q2210NFF2KyOYj8PtFKJA+4DtWk0Eq9q1qKyqUlN3ZrCbirIxSCOtFbBVT1FQtbRHtXM8L2Zqq3dGbRVxrQ9jVJ3jjYozcispUJroX7WPcWimiRD0NLuB71nyvsVzLuLUU3+rNS8VHL/q2+lVT+JE1PhZLpXQ1sVj6T0NbFeoc89wooooMxD0NeY6rLMb+QFjwa9OPSvMtYGNRk+taR2MpP3yK31G7tVKxueauW2u30LhpG3j0rFop8qNOdnQyeI718+X8tWofE8qqFlTcfWuUpaXKg52dhF4ozJiRML9as/8JPbZxt71w1FHKg5/I70+JbMHAGatw63ZTIXzjFecUZPSlyBz+R6SmtWDoXD8CnLrFg2MP1rzSlo5B8y7Hp51KyBAMnWp/tMGM7xivKsn1NLvf+8fzo5A5kepLeWrjKuDinfarf8AvivKtzdifzpdzf3j+dHILmR6l9stf74qJry2PRxXmOW9T+dGW9T+dHIHMelm7tsZLjFBurYdXHNea5bpk/nRub1P50+UOY9HN7bKNxYYzihr23XGWHNecc+p/OjnuT+dHKHMeim9tlO0uM006jaLyXrzz8T+dGPrRyhzHoB1OzUEl+BTf7WscA7+tcDijAo5RXO4bW7EEjf09qrvrtruwpyK4+inYLnRya5CylQmc1nNqjdESs2imK5Ze+uX6HFV2klf77ZptFAhu0d6MAUtFABRRRQAtKv3hTacv3hQC3OgtPuVaqraf6urVNDnuFVL7/j2f6VbqpfH/Rn+lMzkZEf+oVR1bitCXFrZ47kVW0+Iy7Xboo4qPVZt8giHQc0zNq75Srp43Tbj6Vs1l6cvLNWpSiXV3CiiiqMgooooAKKKKACiiigBCMgiqEy5QitCqsy4z70AjBTgmpaQISGx1BoByM1KNmFLSUtMQyT7hrTg/wBSKzJPuGtOH/VCl1G9iUVLYrulaU/SoHO1Sa0LJNkH1OaT3sVT0TZbooopgFcxcK0ly7jqprpj0Jrno+ZZD/tUMTdi3BL5sYbv3qWs1WNvNn+ButaXB5FNGcl2A0lFFMkcDg1MDkVXpytigCcHsaWmj5hQrc7T1pAPqKeMSwlaloBxQIwrZyCYm6itRGIrPvojDMJk6HrVmKQOAw70Fvua0bZ5qhfrghx9alifY+09DUl2m+E+tKSuiqcuWSJotPi1O0E8J2yr1qhHJdadcgzKQQfzqzoNz5UzQMeG6V1ksUU67JVBBrkPU5rehPbzpcRCVDkGpqzrO1+yMViP7s9vStGrRzzVnoFKKSigg4nX4biK6D7j5bcfjWSse013up2gvbUx/wAQ5H1riEDDMT8MvBrppu6sc1VWdzXh/wBWv0qSq1s+5dh6irVWcj3Co5YY512yDIqWloFchht4oBiMYqfFFLQMjkHyGqNkM6gmPWr8p+SodJTzL0v/AHayq7Hbgl77fkdYaKD1ornOkKKKKACiiimAUUUUAFFGKMGkAUUUUAFFFFABRRSUAFFFFABRRRQMSiikoAKSikpFWFoBIptJmlcdhrxQyffQGoxbWy8hBUuabnNF2VyoUBR90AUZpCe1FIqwuaTrSdTilPpQAlLSVDcSiCBpD2FNK4N2VzltUkNzeiJelW4033EcI6RgNWfZL5kz3cnRTmtqxT5WmbqxP5V0taJHmp6uRoHrSUUUjMRmCqWPauVvrkzSYHTOKv6leYHkoeazLOEzT5PRev1qtgivtM2baLyoVTvio53ydo7VO7hFJNZsjHp/E1NC3ZGzEn5evaujtIBbwKnfqaytNtxLJ55Hyp933rezUbu5rL3VyhRRRTMijqBxb0+A5iU+1Q6mcWxpbVswL9KYuhazWBcfPqCj0FbhNYSHffMfTNJ7F0PjuaRpKWimakUqh4mU1l2BJaNT/Dmtg9DWRZjF6U9DUzKp/FY6pqxF5uJDW0xx+VYcHLO/rVIzjq7k1JS0lBQlZOof61PwrWrJ1H/WJ9RTExDV+FN8qJ+NURywHvWzYJmUv6DFKQLRXNc9agX5p8/3akY4BNR2/IL+ppGUerLNFFFIQUtJRQBUujkpH6mrfQAelUSfMvAP7vNXjTH0QUUUUCCiiigBKKKKAA0g6UjdKWgAooooAKxbzqa2qxbzvT6MX2kbGijFuT71tVk6QMWv41rVhHY9Kt8TIM/vCKjmbtSK371qhlbJq0c8tjG1BsR7fUiq8KGwukB+7JU9ypmuY4R9av6xbbrYSIPmTGKHqEPdSfcs01xuUg1BZzie3V+/erNOLuZ1Y2uitpj7WKH1roK5uL93ekdjiuiByM1m9GdKd4pjqWkooELRRRQAUtJRQAtFFFABRRS0AFFFFABRRRQAUtJS0CCiiimMKKKKACiiigAooooAKKKKACiiikIKKKKACiiimMKKKKACiiigApKWkJCjLHAoE2LVG6vY7cY6t6VSu9SxmOD86xSSx3McmtY0+5yVMR0iaC3s0s4JPGeldIOgrj4f9Yv1rr1+6KVRF4dtp3HUUUVmdIUUUUAFFFFABRRRQAUUUUAFFFFIQUUUUAFFFFABRRRQAUlLRQAlFFFMYUUUUhBRRRQAUlLSUAV7o4gNc/W7fHEFYVaR2Ie4UUUVQBRRRSYI2bf/AFQqeoLf/VCp6yNXuFVb3/j2f6VaqC5jaaFo1OCaaJMSJnCsfQVt27BoQaqrYkIVLckYq7FH5SBOuKbYIfRkCiqMwkMnI4pDL1FZ6l1Vu3pTvNZYlweaYi9RVM3D+Yq+3NHnvk4HAoAt0VVNxgE46U/zxxkdaAJ6MD0qATr1PFO85PWgB5VT1FJ5aelJ5qetL5ietACeVH6Unkx9hT96etLvQ96AIvJTsKTyExipty+tGRRcCDyBnOab9nHTNWcijii4rFYwEjGaQwHGAas8UUXCxW8k59qZ5LE1bzS0XCyKXlNgkikMTYBxV09KYcnFO4rIqeWwNIY2yatMCcU0ht2aLisVgrY96NrDOanGTyO1REkgk0xWI8EDmlIwcUpY4xSZzyaBD8YopS2RikoKCiiigYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABSr1FJSjrQNbmyv3RS01fuinVmUwooooEFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAJS0UUDCiiigAoopCQOtABRUZmjHeomuVHQUuZD5WWaKz2u2+lQNcE96V+yC3dn/0utoopaBhS0lLQAUtJS0ALRRRSGFLSUtABS0lLQAUtJRQAtFFFAC0UUUAFFFFABS0lFAhaKKKACiiigAooooAWiiigAooooAKKKKACiiigBaKKKBhRRRQAUUUUAFFFFABRRRSEFFFFABS0lLTA4PXDnUH+grIrS1c5v3/Cs2tlsYPcWiiimIKdSUtABS0UtAgpaKKYhaWkpaBC0tJS0Ei0tJS0xBS0UUCFpaSloEFLSUtAgpaKKBBS0UUAFFLRTEFFFLQAUUUtAia2GZ1FdoOgrjrMZuFrsqwqbndh17oUUUVmdAUVjXl3cRTlIyAAKppf3m3dkdcdKqxHMdLRWDFfXTyBSRgn0qa51GS3k8sDPvRyhzI2KWsaPU2aIyspwDinx6osh/1ZGKVmHMjWorOGpQN2xmhNTtXOCcUWHdGjRUMc8Mv+rYGpqBhRRRQAUUUUAFFFFAC0UUUgCiiigQUUUUAFMaRE++cU+msiuMMKBiqwYZU5p1MVVQYWnUALSYHpRRQAhRD1FRmCM9qmooArG2TsSKTyHH3Wq1RQBW23C9DmjfOOq5qzRTAreew+8ppwuE78VPgelN2KeopANEsZ708Mp6GmGGM9qYbdOxNAFiiq/kuPutRicdwaALFFQeZKOq5pfOH8QxQBNRUQmjPenhlPQ0AOooooAKKKKBBRRRQMKKKWmAlFLSUhAelcZeEm5c5rsz0NcTcnM7VrTObEdCHJ9aXc3qaSitLI57seJZB3pTPLjGajpD0pOK7FKT7nR6RyprarF0f7prarnPTqbhRRRQQFeb68u2/Y+pr0iuA8SLtulb1zWkDGfxI56kopaooKKKKBBRRRQAtFFFABRRRTAWiiigAooopAFLSUtABRRRQAUtJUmE2+9ADKWnjbjJp5WLjB+tAyCirEiwjmOpIFtSCJs59c0gKdFacbacgy6lj9aqSmIzExDCelAWK9JW8tzp6xr8nI61XS7tw7ArwelFx2Rk80oRz0Fav22EREBRnPFK1/HsACjNFwsjL8mY9FNPFtcE4CGtBr+IgYXkUh1CPO4KaAsiiLW4IzsNPFlc5wVxVk6gOcKaQ6hJkYFAaEQ0+c5zxilGnzAg5p41CcsSOlNF/PmmLQ1LZCkeG61YqC3dpIg7danpoJ7hVDUDmIRjqxxV+qCj7Rclz91en1pmTdtSVFW1tR7DP41zEjmR2kPc1s6pPwIV+prEPTFDFTXVmrYLiIn1NXqgtV2wL7ip6a2FN3kwooopkBRRRQAUUUUAFFFFABUUq5XNSUEZGKAMa3H+kOh75qsymNzGfwq0o2Xv4VNew7l8xeopLY26mfRSKdwzS0CI5PuGtWH/AFS1lyfcNacBzEtLqN7BL9zHrW1EMRKPYViycug963V+6PpS6lr4RaKKKYhrnEbH2rn4Orn1Nb03ETfSsKD7hPrR1JlsPdA67TSWspU+RJ1HSn1BNGWG5fvCm+5KfRmgaSobecTLtb7w61NTTuS1YWikpaBD1bFSMN4yOo6VBTlbFAEiSbvlPBFS1XkQuN6feFLDMJPlPDDqKQW6j5ohNGUP4VixM0Ehjfsa36zr628xfOj+8KBxfRkj/Mm5frVy3lE8WD1HBrHs7gH909ThjaXOT91qEO3QgkLWtzuXgg139pOt1brKtcXqMW5RKtX/AA/ebHNq54bpXNVjZ3PSoy54W7HXA4qZXzwagorNMGrlqiolfsak4PStLmTjYWua1bTyr/bYB/vCukoIDDa3INVF2ZEo3VmcIvaaOtCN1kGR1qW+06S2c3FsMofvLVBCrfPGee4roTTOGpBx3L1LUCyno9OM0a9TiqMrE1FMVmf7ilvpVhbS5lGSNg96lzRpGlJ9DPuJMAitTRbcxQmVurE1WNvHLILeD5gPvtW+iiNAi9AK55yuz0aNP2cWurH0UUVBQtFJRQIWiiigApwFNFSUALRRRQA0imVLTGoAbRRRQAUlLSUAFFFFABSUtJQMSiikpDCkopvWkUhOv0oPoKU8U0eppFoQ8cCl6CkHrQeTQMQepoJpTSAdzQMUcCm9eaUntRQIK5/W5ydtpH1brW3NKsETSv0Fc3Zo11cNey9O34VrTjd3ObEVLRsSCERW6Wo+85wfxrXRQiBB2GKoW4864a4PQcD6itDNavc5HorC1QvrtbeM88npUlzdJbxl2NcpJM91KZH+72pohLmfkIWZz5jdW6Vu2sXkwgHqetZ9lD5r+aw+VeBWlPKIkyep6CgqT+yiKeQZ56CqyRyTyiFfvN1PpTTu4zy7dBXQWVr9liy/MjdTSk+iLilHUsxxpDGIk6LT6KKRm3cKKKKYGbqn/HtTLJs260/U+YQPeq1g37n6GmLoX3OFJrFs/mnkf3NaszYjY+1ZmnD5Xb1alLoXQ+0zQooopmolZtqn/EzatKq1gu69lk9MUpBB2bZqXMnlxM3pWZbjEIz3JqbUXLIIR1ejG0ADsKOpMF7txKSlpKYxKydR++n1Fa1ZeoffT60xPcSPl/pXQWK7Yy3qawYBlifXiuliXZCq+1S9xS0gJM2EPvxUsK7YlFU5zudUHfmtDoMUPczXwi0UUUhBRnAzRUFy/lws3tTEytZ/PLJL68VoVUsV224J7kmrdI0nvYKKKKZAUUUlABRRSblztzzSGNbrinUwctT6YgooopAFYt73rbrEvutPoxxV5xOh0wYtVrRqnYjFqn0FW6xR31fiZnqcyuPeleMgbjSW/Nw/1qbUJRFbk9zxVmD1VkZFknnX5k7LxW9KgkQqe4qhpcPlwb26tzWlU3NJxuuU5KxJtrt7VumeK26y9XjMFwt0v41pRuJEDDvVbMzfvRTKVx8k8cnqa34mygrDvF/d7v7tatm+6IfSlPcdH4LF2iiipLFopPrS0AFFFFAC0UUUAFLSUtABRRRQAUUUUAFLSUtABRRRQAUUUUwCiiikAUUUUAFFFFABRRRQIKKKKACiiimMKKKKAEpaOnJrLu9RSLKRctTjFsznNRWpdnuIrddzn8K526vpbg4HC+lVZJXlbc5yaZW8YJHDUrOQUUUVZiSRf6xfrXYJ90fSuOj++PrXYJ9wfSsap24XZj6KKKyOsKKKKACiiigAooooAKKKKQgooooAKKKKACiiigAooooAKKKSgApMjOM80tZV9DIrefGT700rik7K5q0Vk21/n5JvzrVByMihqwRkpbC0UUUhhRRRQBQ1A/ugPesWtbUT8gHvWTWi2I6hRRS0wEoFLRQwW5sW/wDqhU9QW/8AqhU9ZI1luFFFFMQUUUUAJSGnVVe4CyGPHQZoAn4owPSq63KOM4xxmlW4RgD0zQIm2r6Unlp6U0TIW2Z5FIZ4wMk0AOMSHOR1pPJjOD6UvmR+vWnb09aAIzAnbtQYRnNSeYnrRvT1pgR+Vzmk8ngjPWpd6HvS5X1oAh8k9c0ghIyCfep8g9KM0CK/lNj3ppifI9KtUUBYrLGwbnpRsbJ/SrNFAFTY3Oc5pdjYFWaWgLFVVO7npTD1PNXCRTCU6UxWKhbIxTd7Yqw2zGMion2lflNMhojDsOc07zGzmlATjNMOOcUC1FLtmm7zTeaUfdzQK7FLbuKdgdzzTQBwaaeuaB3Jcd6Sm5zTqC0FLSUtAwooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAClHUUlKOooGtzYT7gp1NT7gp1ZlMKKKKBBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFMAooopAFFNLKOpqMzxildDSZNRVNroDpUDXbetLmK5TTzimGRB1NZDXBPeojMTT959Be73NdrlB05qFrs9qyy7Gm5NPkfVi5l0L7XTHvUDTk1WoqvZrqL2j6EplY0wuxptFUopEuTYZNFFFUI//T62lpKWgYUtJS0ALRSUtAC0UlLQAUtJS0hhS0lLQAUtJS0AFFFFAC0UUUAFFFFAgooooAWiiigAooooAKKKKAClpKKAFooooAKKKKACiiigApaSloGFFFFABRRRQAUUUUAFFFFIQUUUUAFLSUUwPO9SO69kNVFR2+6M1PeHddOferVmPkNXKVkTRpqcrMz/Lk/umjy5P7prcwKUAelZ+1Z2fUo9zC2N6UbT6VubV9BQVTHQUe2E8Cu5iUVsmJMdBSeRH6VXtTN4N9zIpa1fs0Z7UjW0SjNP2qIeDl3MylrTFnGRnmj7ElP2iM3hZmbS1fNqgYLnrSmzHY0/aIn6tMz6Wr32M+tIbQjqaftES8NPsU6KuGzf1FH2SQdxT50S8PPsVKWrP2WSkNvIOKOdEuhPsQUVN9nk9KTyZPQ0+ZEujPsR0U4ow6ikxTujOUWt0FLRRTICiiigdhaKKKYgpaKWgRbsBm6WuvrlNNGbpa6usKm530PhCiiiszYx7uzmmnLr0IxTI7CQEqxG0jFbDuka7nOB70iOjjKkGncnlRlwafLFJuZgQOlZ+oMrTn2rpiw6Z5rkLrLTuSe9VEmexIHVbMoTjLA1LbqWgcrzzWlYQRTWm2Re9aMcMUS7UUYNDkCh1OXLKCD2xiqhwD8vWuz8iAjGwVn3tlAIi4+U0KQSh1MfPlxq6N83tXVxksgJ9K5NSYZDtw2PWuotpfOhWT1okECeiiioNAooopgFFFFAC0UlLSAKKKKBBRRRQAUUUUxhRRRSEFFFFMYtFJS0gCiiigQUUUUAFFFFAwooooEFLSUUALSYB7UUUANMaHtTTCnvUtFAyDySPuml2zDoRU1FAAM45ooooEFFFFABRRRTGLRSUtIBrfdP0riZ/9c1ds33T9K4mf/XN9a2pnJX3RFRRRWhgFBooNAI6LSPumtqsTSOhrbrlPVqbhRRRTICuU8QWE906G3GSK6uuR1rUJ7K+iZT8vNOLIkrtHOtpGopwYmP4VA1jep96FvyrsrfxHbSSFHB471Yt9b0+5bywOc96d2NxR5+YZx1QimEMOoxXppvdLJ2kx5qVI9PnGUVG+go5g5TyzcB1NJvX1r1U6dYv/AMs1/AVGdGsGHMY/KjnDlPL9y+tLketektoGnN/CfwqufDunn+9T50LlPPsilruz4asT0LfnUZ8MWnZm/OjnQcrOIpa7I+Foezn86YfCy9n/AFo5kHKzkKK6w+Fm7OPzph8Ly9nFPmQcrOWpa6U+GJ+zim/8IzddnWjmQWZzlFdEfDV5/fWmnw5e/wB5aOZBZmBRW7/wjt8O603/AIR++HcUXQrMxaK2DoN/7U3+w9Q9B+VF0FmZNLWr/Ymoen6Uf2JqH939Kd0FmZWKK1P7F1D+7+lL/YmoHt+lF0FmZOKMVr/2Hf8AoPypf7Cv/aldBZmPigjpWhcabPbMqykfN6VJ/Zoxy1MLGZt5xTcYFao01P7xo/s2L1NAcplUpYZzWsNPh7k04WEHvQHKY24AmgMua2xY2/vTxaQDtQOw60OYQas01VCjC8ClLBRmmiZb3Ip3IXYvVuKYStrAWP1/GnqvJkfrWLfXPnPsX7oqjn+J2KTu0jl270zGWVfU0tS26b5wPTmpZtE2lG1AvpS0ppKswCiiigAooHIzRQAUlLRQAlFIOuKWgBaKSigDLuhsuFcd60SARjsaq3y5QP6GrER3xq3qKS3sa7xTMW4i8iXj7rUytueETRlD+FYeGRjG/UUbBe41+VNaNscwrVAjg1dsjmEexNLqPoSn/Wp9a3B0FYh++h962x0FJbmn2ULRRS0ySC44hf6Viw/6sVs3X+ob6Vjw/wCqFNESJKSlpKZBVlVo286PqOtX4ZlnTK9e4qI+lUnVoH82PpUvQta6M1aKihnSZfQ1KRiqTIasFLSUUCJFbFRzxE/vYuGFLUitQHmNguFlGDww6irfsazbiA/66HhhTra9WT93Lw1IbV9UVr20aNvPh+pFCTJdw+W3DjpW1jIweQaxruxZG8+2/KkNO+jJ7SXzYzbS9RVAF7abI4Kmo1n+cMeHHWrcuLhfMT7w61M1dHRSnyO53Vjcrd26yDr0NW64nRL7yJ/KY/K3H0rtvcVy+R2yXVC0oJFNooJJRJ61IGU1WoqrkuCLeAeDzWbcaVBMd8fyt+lWMn1pd7U1Ih0rmHJpt2hwcMPYVRS3YXqiZCEXrXV+Y3rSHDdQKp1HaxKoJO5U+1bflggP1xUbQ3l1/wAfDhU9Bwavg46cUuai5qo2GxQxwLsjGKkpuaUUCY6lptLTJFooooAWikpaBAKlqKhZUZzGD8woGkS0UUmQKBC1GTmgnNJQAUUUUAJRRRQAUUUUAFJRRQMSkpaSkUNPpR0paQ0ikNPJpD6U7GBSe9IoQ8CgDFJ1OaCaBidTinHgUgGKDzQAg9aKWsnUr0xD7PBzI36U0rkylZXZR1GY3k4soT8o++akuSLe3FvF95uBTrS3W2jLufmPLGo7fNzcG5b7q/d/CulK2iPNnO75mX4YxDCsY69T9ahubqO1Tc557Cq95fx2ynnL9hWKkM16/nXBwvpT2JSctXsV555LxzI/CDpSxRNKwiTv1+lOlIeXZGOBwB71qQhbSPA+eVucDtSLbtoidvLtIgv5D1qi7FD5s3Lt91aUsQ+4/vJT0UcgfhWrZ2G0/aLn5pD0HYU2xJJasWwsyn+k3HLnoPStPrRRUibuFFFFMQUUUUAZ+ocoq+9Z+nH92w9GNaVyN0qJ71l2R2zSR/7RNV2EtmWrttsLfQ1XsBi3z680l+2IT78VNartt0HtSe5rRXutk9FLRQWNY4Un2pmlKfJaY/x1DeOVi2r1YjFXJGFjZhB16D8aTJd/hXUq58+8Z/4Y/wCtTnrSQQmCEbvvN1pTQi5aOyG0lLSUxCVlah99a1aydQ/1iD3FMl7osWabpFX3zXRPxxWRpiZkL+gxWpIcAn0FStyKzsrFZPnu/wDdrRrNsfnd5PU1pUgnpZBRRRTICs7UX/dCMdWrRrJuT5t7HF2B5pDgryRqRrtjVfYU6j2ooB7i0UUh4GTwKBBSOyxrvkO0DuaoTajGh8uAeY/tzTI7Se5bzb1sDso4/OmUo9x/2iW6bZaDC93PSraRrCmBye5NSqqou1AAPamSHtSBvsInrT6ROlLTEFFFFAhaxL7mQD3rarGuRuuVX3pPZmlJXqROpthi3QewqemRjEaj2FSVmjqnuzPtv+Pl/rVK9c3V2lsvQdfwqXzhDJI596NMiLlrp+rdKpk0/wCY1lUIAo6CnUUVIzP1OAT2rDuKydLl3x+W3Va6Vl3KVPeuPQm01Ap2Jpva5Efice5s3C7oWFS6a26L6UrDcv1qvpJ++p7E05bBS0ckblLUC3ELNsVskU/zQDjBqLmri0UNTd1UBTiobS+IIjl6djVq/Qyw7gOlYFZSbTOylGM4WZ1455FFZun3PmL5TnkdK0q0Tvqcc4OLsLRUDXESnGajN5H2qHViuo1Blulql9sHYU03bdhS9tEfs2X6Kz/tMp6Ck86c9qXtl2H7NmjRWbvuD2NGLg+tL2z6IPZ+ZpZFJuUdTWd5c59aX7PKe9HtJdIhyR7l7zIx3FIZox3qp9lk7mnfZG7mlzVOwcsO5MbiMU03SelMFoO5p4tE7mj94x+4MN2Owppu27CpxbR04W8Yo5KncOaHYqG6kPSm+fKaviKMdqdsT0FL2Unuw9pHojN3zE1pLnaM9aXA7ClrWnDl6kTncKKKK0ICiiimAUUUUgCo5ZY4V3SHFVrq9jtlx1b0rnJp5Lhtzn8K1jTvqzmq10tEW7vUZJjtj4Ws7ryaKK3SscMpNu7CiiimSFFFFAD4/vj612CfcH0rj4/9Yv1rsE+4PpWFU7sLsx9FJS1idYUUUUwCiiigAooopAFFFFAgooooAKKKKACiiimMKKKKACiikpCCggMMHpRRTGYN5amJtyj5adaXjRny5DlfWttlVxtbpWJdWTRndHyK0Uk9Gc0ouL5om0CGGRyKWsKzuzE3lydK3QQRkVEo2NoTUkLRSUtSUZOon5gKzKv6gf3uPaqFaozCiiigYtFFFDBbmxb/AOqFT1Bbf6oVPWSNZbhRRRTEFFFFABUDwBn8z8KnooEVBahc4PWgW2ABn7tW6SgCuICHL5HNRm1JUrkc1copgVWgYsGyOKBA6sGz0q1RQBT+zvjGaUwvuDA1booAqLC6sSe9HlSYxmrdFAip5cgXANN8qXj2q7RQBVCSc5pmybNXaKAKgSQHvim7ZeetXaKAKJSXHejbL6GrtFO4FDbJtxg0bZPQ1fpKLisUNrjjBpu1x2rRoxTuLlMwo/oaTa3oa1OKMCi4uQy9rehpNrehrVwPSlwPSi4ciMra3oaXa3pWngelLgelK4+VGZg+lLz6VpYHpRhfQUXHYzeaK08L6CjavoKLhYzaK0sL6CjavoKLhYzaK0tq+go2r6Ci4WM2itLanoKNiegouOxmZozWnsT0FJ5aelFxWM3NFaXlR+lHkx+lFx2M2itHyI6Ps8XvRcLGfRWh9mi96X7LF70XCxnUVo/ZYvej7JF70XCxnUo61ofZIvej7JH70XBInT7gp9IAAMClqCmFFFFAgooooAKKKKACiiigAooooAKKKKACiikJx1oGLRULTxr1YCqz38K/xClcpQbL9ISB1NYzanHnANNNyzcihNvYJQ5fiNgyoO9RNcoOlY5lY0wsxp8sieaJqNeelQNdE96o0lP2fcXtOxZM7GozIxqOiqUEJzYu4mkooqrEhRRRTAKKKKACiiigQUUUUAFFFFAH/9TraKKWgYUtJS0AFLSUtABS0lLQAUtJRSGLS0lFAC0tJS0AFFFFABS0lFAC0UUUAFFFFAgpaSloAKKKKACiiigAooooGFLRRQIKKKKACiiigApaSigYtFFJQAtFFFABRRRQAUUUUhBRRRQAUh6E+1LTJDiNj7U0DPOZzmZz7mrtr/q6qSIS7Eepq5b4SPB61VVOw8JJKd2yz2pR0pmQRjNPDDHWuezPVU49woakBprtziizByViU08VWDHODU5fB4qtTNtExAwDUMnQU/f8tRPyoNCRM37pLH90Zp9QxnqDUgIJxQ0SpJoZJ99T71LUMvBU+9OMgzinYlTSdmPFNk7fWlBBFJJgAH3pLcuT0JRTHPT604HimvjAJ9aBN6EtRPw4qUdKhkP7wChbinKyuSinYpinjNPoKuVnljBKkciqshVjkCnz/fNQ11QitzxK1aTbTEwKMClorSxz3NCIWxAyKti3gIyAKzYUyCSeK04P9XxXl4iPLqmeth5c2kkMNpCe1U7m2iiTcua1qp3o/dfjWVGpLnSuaV4R5G7GPto206lr2DxS/pa/6SK6aud0ofv810VYT3PRo/AgoooqDQwtYcgxqeFzzUUd6iIEHY9q3J7eK4XbKM1mNo0WfkdhVpqxm073RSkuf3nmxlunQ1Qcl8uep5rZ/sc/3zSf2MSeW4p3RLjJl+y2xWau54xml+3Wx5zTpLfNr9nTsMc1hmxuUAUqDj0qUky22tjUfUogdqAk1n3V+tzFsUEMKgMM6vnaaZ5UhY/IfyqrIhtkZPJJ4NdPYqVtVBrItrCSY7pRhRXQABQFHalJlQi1qx1FJS1BoFFFZst25l8qDHHUmmkJuxpUVk/a7lW8sgZ7VEdTnV9pUcHFOwrm3S1kf2jIFLlRgU631NZ13bSB9KVmO5q0VmLqkLuECsSfap5byKEbpAQKLBcuUVQTUbdzwG/KpPttvjOaLBct0VDHPHKNyml86H++PzoAlopokj/vD86UMp6EGgYtFFHNAC0UUYNIAooooEFFFFABRRRQAUUUUAFFFFABRRRQAUtJS0DCiiigAooooEFFFFABRRRQAUUUUAI33T9K4ib/AFzfWu3b7prnxpZuCZA2M1rBpGFWDk9DEorbOiSdmph0WfsRV86MfZSMeitU6Pc+oqNtKux0xRzIXs5djQ0jv9K26xdMRo3KP1ArZrnPSnuLRRRQQJVK90+3vlAmHToR1q7S0CORm8KwZzA7An1NVI/C91byGVWGR0rqr6zkuwDHI0bL0waWyiu4lK3TBh2NVcDz+50XUrctgFyecjmm28eqwgBEkA78GvQri4nilVI03KepqaaaO3i86UHHtQM87tbzU7a5Z3EhX0NOGu6lHMZGB25xtPpXdpc2UxGF+Zumac1vp5bbKibj2NAHHDxRc/aVVgAh61oP4qiUMducHFbkmk6fKeYlHvioZdCsJU2bce4pAUU8T2RQM4IzVs69p/GW61VfwxYsoXc3FVZPCVu53K7Zo0A6KO/tJQWjkUgdeaWO/spThJVz9a5qLwxJCGCykbveseTwzqcDfuWHXrmiyC56L5kf94YpQ6N0YGuQu9P1FtPSOJiJF61kRWWuRuDvajlC56RRzXnc765FJhS5FXri41RCmzdyBmiwXO2waTmuB/tXVlHCseferdrq+ovuMicKPeiwXOy5oya5CPXb4yBGjGD3waml8QTRS+X5QP4GiwXOoyaMmua/4SIKAXjIB9qe3iCJAC8Z56cUWC50eTSZNYSa9bsu4q35Uv8Ab1pkqVbI9qLBc3MmkyaxxrdqccNz7Uja5aK20hs/SizC5s5NGTWJ/btpu2kN+VJ/bltno35UWYXKesHNwv4VX7Cn38y3DiVQce9Rj7oNa9CELSUwyKKjM65wAaCieiovNXvSh933aBElLTBuPSkAkPWgTkkTZwKZ1OTTJJI0GWIrNluJrj93ACB61Rzu8th17dg/uYvxNZJ6VrQ6Yx5lOPpRqFvFBANg5zRcpRsjHq5p6ZZpPwqiTxWzaJshH+1zS6jbtG5YoooqzEKY56KOpp9MT53LenSgES44xTafTT1oASiiigBretKORmlqMfKdpoAfRRRQBFMu+IrUNi2YSp6rxVrrxVGI+VdFOzc1L7mtPW6NDFUL2281d6feFX6WqeotjnEO4e9S2B4ZPSp7y3MbefGOO4qnZuPPYDvUPctbGk38J9DWyhyo+lZLDMbe1acJzGD7UdS1rElpaKKZJXuv9Q30rIi/1S/Ste7/ANQ30rIj/wBWv0poiQ+iiimQJUE5AjOasVQu3+ZUFJlRWpGuYzuFaUNwHG1/zqkBxg0zBTkcikVvubJFJVGK4K9eRV5WVxladyGgpaKKZJKrZ4qheWhb99Fwwq1Uqv60mgTsZttfSRriXkD8614riKUZVh9KoXNmJQWi4b+dYr74xgZVhU3sa8qkdHc6fDcfMvyt7VkmC7s3yV3L7VOt1dQQibh0PXuRVqHV7eUbXyKE0FpIxnkCvvTj2rutGvxdwbGPzLWG9vY3QyCAT6VHBby6fMJrclh3FZzh1R1Uay+CR3NFZMWr278OCp+lWxfWx6NWB0WLdFVvtUJ6c04To3ABoETUUmaKBjqKSgdM0CF70tN7ZpRQDFPpT6YPWnUyWOpabS0yBaWkpaYgpaSloEFR+UnmeYOtSUUBcM0UUUAFFFFABRRRQAlFFFABRRRQAUlLSUDEpKWikMSm9TTqSkUhDTT6CnUmKBoToKaBzk080lIoSjFBwoyxwPesq5vmY+TajJ7k9KpRb2InNRV2SXt6tuvlxfNIegHas22tyhM85y7evanKkcJMkh3Oe5qldX6qpCnJ9q3UUjgnVdR2RZuZvPbyEOF/iNUrjUBGv2WyGT6iqUAuLpcr8qHqe9acNtFAPlGT61V+wuVLcqQWRLedcnc3pVy5cxwHaOowKmpyWbXR+YkIKNhXu7sxLaIKfMc5PXC9a1IrS5n+6NgPUtwa2YrO3gHyKCfU1YJJ4pD5uxUtrKC2GVG5u5PWrVFFIm4UUUUwCiiigAoopR1oEZ7Hde49ADWXH8mounqM1pw/PO8n4Vm3Q8vUw3qoFUOPVEWoH5VX1atGMYjUe1Zd9zJGv+0K18YAFT1N4aQQUtJVW6mMYEUfLt/KmD0EiAuLsyH7kX9aswqdQut3/LKP9ao5YBbG35Y/eIrqYLdLS3EaenNZt3di4qy52Z05y1VjU8pyxqA1ZmhhpKcaSmMSsi+5mUenNa9Y9z811j2xR0J+0kbunLtgLepqS6fbET61JAuyBV9qo378BKUdjKrrOxa09cRZq9VWzGIR71apIqo/eClppIXk8UwyqOxoIsS1i2rLLqEjk8KBWo/nuv7oDn1rmJLKW3lcySYPU4NBpS0u2dO1xbr1kX86aLq3P8Y/OuLMq7vXHrSu7yDIGB6CnYbimdNcaxbQ8J8x9ulU4xqGptuY+XF7cUzTNJMuJ5xgdhW/JPHDiGEZPQAdBSv2FtsJb2kFquIxk+p61Ypq5wN3WloICoHOWqY9Kg6tTQmTDpRRRQAUUUUAFZBG6/Rfetes22XfqX05qZbG2H/iXOpAwAKU8AmiqV/cC3ty3c8CpRrUdkzCm3XF35CdCcmt+RhaRqidqy9HhLubh+pq7fNl8elC1dxv3YqIG8b0qE3cpqvTxtHXNWSP86U96xtQUrIsvetjd7VQv03wk+lG6sRLS0jTgk8yBWqOz/dXUie2aqaTJvt9p6rVsfLen3AFSnsaTXxNECfuNSwejV1GB1xXM6iNk6TD1ro4m3xK3qKztZtHRN80IzB1DoV9a5WVPLkZD2NdbWFqcW1xIO9TNaXLw07OxQgkMcysPpXVjDDPY1yA6j611cP+pXPpRArFLVMT7PFnOKUQRelS0U+Vdjl5mMEUY7UuxPQU+inZCuxu1fQUuB6UtFOwXEpaKKYBRRRQAUUUUAFFFFABSUtFABRRRQAUUUUAFFFFABSUtFABWbfXwgHlx8salvboW0fH3j0rl2ZnYuxyTWtOHVnJXrW91AzM7bnOSaSiitziCiiigQUUUUAFFFFAEsIzKv1rr1+6K5S0XdOorrK56u56GGXuhS0lLWR0hRRRTAKKKKACiiikIKKKKACiiigAooopjCiiigAooooAKSlooASiiikAUdeDRRQBQuLCOX5l4NJamaE+TKMjsavkgd6Y0kfc0OoloyVT1uiSiq5uYxUbXf8AdFZOtBdTVU5Mzb1szmqlTTKzuXPeq5Uij61HoJUH1HZFJuFMIptS8T2RSokm8Um+o6Kh15FKlEvRXzRrtxUv9on+7WZRU+1kXyI1P7R/2aX+0V/u1lUUe1kL2aNb+0U9KX+0I/Q1kUU/bSD2aNj+0IvQ0v2+H0NY1FHtpB7NG19vg96X7dB71iUYp+3kL2aNv7bB7077XB61hYFGBR7dh7JG99qgPel+0Q/3qwcCin9YYvZI3/Pi/vCl82P+8K5+in9YfYPZI6HzE/vCjev94Vz/AD60uT60fWPIXsjf3j1FLuHqK5/c3qaXe/qaf1jyD2Rv7vcUZPtWDvk/vGjzZP7xp/WEL2RvZNGTWF5sv940vnS/3jT+sIXsmbmTR81Ynny/3jR9om/vUe3iHsmbeWpMt6VjfaZvWl+0zetP28Q9kzY3H0o3H0rH+1TetH2qb1p+3iL2bNfefSjefSsn7VNS/a5aPbRD2cjV3n0o3n0rL+1yelL9qk9KPbRD2cjT3+1Lv9qzPtMnpS/aJfSj28Q9lI0vM9jShwelZwml9KkE8g7Uvbw7h7KRfzRmqXnyelHnSego9vDuP2Ui7mjNUvOl9BS+bL6Cl9Yh3D2Ui7mjNUvNl9qPNl9qPrEA9lIu5oql5kvtR5ktL6xEfspF6iqXmS0eZLR9YiHsWXqWqPmSUeZJS+sRD2LL1LVHzJKXzZKPrEQ9ky9ThVATSVLHM7NtNVGtFuwnTaLdFLSVsZhS0lFIAooooAKKKKAFopKKAFopKKACiiloAQkDk1DJcwxDLtiif7lc7qI4Whl01zSszVfVrZemTVR9Z/uL+dYNFSdqoxRpvqty3TAqq13cP1Y1WooLUUug8ySN95iaZRRQUHStSM5QVl1ftmyhHpVwZy4qN43LNFFFanCFFJS0AFFFFABRRRQAUUUUAFFFFAgooooGFFFJQAtFJRQB/9XraWkooGLS0lFAC0tJS0AFLSUtABRRRSGLRRRQAtFFFAC0UUUAFLSUtABRRRQAUUUUCClpKWgAooooAKKKKACiiigBaKSloAKKKKACiiigAooooAWiiigAooooAKKKKBhRRRQAUUUUgCobk4t3PtU1V7zP2V8cnFNEvY4Y9T9TS0pRwTlT+VJg+hrpOOwZpQTTaXNFh3Y7JpcmmZFLmlZBzPuPyadub1qOlo5UHO+5Jub1pdxqOlo5UHPLuP3HtRubrmm0UWQuZ9x5Zj1pMmm0tOyJcm9yVZSoxQ0hYYqKlpciK9tO1rjxIwGBSb2PBNMoo5UT7SXcnWdl4601pCzBj2qKihQQ5VptWbLAn6ccCpPtPtVOlpOnEaxNRdR8j723VHS0lWlYwbbd2FFLRTESLJtXbgH3q7DdRom01nUVjOjGW5tCvKOxrfbYqinuYpY9ozms6lrOOEgndFyxc2rMSloorpOY1tJH70mt+sPSR8zGtyuae56dL4UFFFFSWFFFFABSUtJx60AFLk0UUAGTRRRQAtJRRQAUUUUALWDdRPatuUZBOa3qjkjSRSrd6aYmjEiZ5mE46jjFU5izsxI25brXRpDHGMKMVHPFHImHGapMTWhho7mB43GR2NMgkaK2Kqck9DXQxxRmHyyOMUxbO3QYCjFHMHKc2m9H8xWwas3Ny1wnlt26mtCe3iWXaq43VZFlAFwRk+tO4rGJC7QlQRuDdjUZBjYqRkDmt2K0SNyzfNjpntTJLBJHL7iPalcLEBnia2PlDa2OlZBXIz3rXistzEk4x0pJNPbcDHzzk5o0BpsySzohEhI9MVsxloLDfk5IyDTJ7N3IO0McY5pTHPJEYSOBwKLgkZ32ieMB/MY5q0t1NtwXPIzTUtLhRsIGO9Na0mKnjGOn0p6CsxsF1cBxukJz61YN5cec3zcL79aqi3cj5VwajZCZACpyfajQZrtqTCMYALGq6arcKwLopBqk0JSUDHXpQ8ZWUoegoshXZrSaqUbaqg+tTJqcTcEYrFRTvyePakChldRSsh3Zrtq8ajIXgVK2p26KGbPNc/j917ikk4RQPmPWnYLnVxXMMyb0PHvUA1G2Mnlc5rn7bcxIAwSKfGv7wxyL9MetKw7nQyXsEeMn8qkFzCw3BhiudETAs+AxHHJqBlIiO4Yyc4pWC51iyxv91gaf2zXGRtNHzHkZ9K0998owjbs+9Fh3NxZonJVWGR1qTK+orkpY5klJPys3pTlMzRNh2osFzrBg9OaK5G2u7q3bDMSB61cS9vVnjDjKuaTQI6KijtmikMKKKKBBRRRQAUUUUAI33TUNqMIalf7pqO2/1dMF1LFFFFIAooooBmXbjF29aVYlyzxTlkODTk1CVfvKDVcoKdzZorOXUYj98Yqwt5A3Q0rDLNJTRJG3RhTsg9CKQWFooooAM0x0SQbZBke9PooAh+zwAhggyOhqOSzgllEzD5h3q1SUARTRtJGUVivuKrG3uEiVY5CWHUmr1FADRuCjd1ozTqKAG5pcmjFGKADJoyaa27I20hLAcCmMfn1puAeooGT1paQhnlx/3RSeVCOiDmlEkbdGBp3FMCIwW/8AzzFMa1tmO4xjNT0UAVms7Vl2GMYpr2ds6hWQYHSrVFAFMWNqAAEHFM/s608wylRk1epKYimNPtdwbaMjpTZNPtWfzSozV6mt0oAzWsrYnJQUn2S2H8Aq0aaaYjE1AKrBVGBUBG6PHtU+pff/AAqFfuj6VfQUSJU2gKaZtxKB2xVg9RSYG7NAyNohng03YIhuFWKgn+5QIY8jblVOpp/k3D/ebb9Ki/5bpWnTItcppZRg5c7z71aVVUYUYp1FIoKxdWbhV962q5/VWzKF9KaIlukZiLvlVB61vgbQFHasywj3OZT24rTpruRN9AoooqjMZI21afGuxAKhP7yUL2HWrVIfQKQjIopaYiOilIwaSgApjrkfSn0lADFbcPenVG4KncKeCGGRQAtUrxSNsy9RV6mOodCppMcXZ3HI4kQOO9PrPs3KEwN1HStChM2kuoEAjB5BrCngFrdq6/dJrerO1NMwhx/DRLYUdyYDK49as2jZjI9DVSFt0atU8B2ylexFS+5cOqL9LSUtMRWu/wDUNWVH/q1+lat3/qGrLj/1a/SmjOQtFLRTJGkhQSayNxeXee/Sr1yxY+Uv41TxjbioZpFWRapaKXFUSRlO68GlSQqfQ1JSlQ3WiwXLKXAPD1PwRkVlEPH15FSo5HKHFK4WNCioFuOzj8anUqwypp3JsPV8dabNbxXK5PB9aMUKSpp2FsUE82xzG43RNVC6tRDJvTlG6Guk+VxhhkGofsyqCqjKHsahxNlPuc4uV5Q4qdbq6QcSGp57F48tHyKpexqbFp9UdxozwXlviVAzD1rXFrbDpGK4XSL1rS5Az8rHBr0EEMAw781hJWZ3KXMroYIYl+6oFSDjpRRUgLRSUvQZoAD6Up9KRfWl6mgA9qU+lA9aByaBDhSikp1MlhS0UUyBaWkpaYgooooELRRRQAUUUUAFFFFABRRRQAlFLiigBKKKKACkpaKBiUlLRQAlJS0UhiUlLRSGUZbxITgqx+grNl1hhxHCx+oNdBzRgVSE7vqci93cXB+ZX+mDinKuoSDbDEFHvxXWUEmq52Z+yj1OYXR7mbm5kK+wpmpWtrY2RSNQWfuetdTiuT1x/Nu4rcdic0R1eoStFaDbdPLhVPxqWjoAKsQRbzubpWpyPuCRYjLt+FaMYCoAKry8lEHrVvpxQyVtcKSiigYUUUUCCiiigYUUUUgCms21S3pTqq3jbLZvcUxEdoPkLerGs3VRtlil/wBoCteAbYl9xmqGqputw3905p9AvaRl3B3XUQ9wa3COa5j7UiXEcjjICjpV2W8uLhSw/dJ7dfypHRsrF24u0hOxPmc+nas4GQyFU+aVup7D6Gi3t5ZSTGNq93PU1swwxwLtTr3PrSKSS1e5Z0mzWEkn5m7mtmc4U1BYJhC1OumwpqYlVG9LmQ5yajNPPWmGrMxpptONNpgFZCr5l6B6GtY8AmqWnpvu2b0pS2FH4ze6DFYl02+fHpWw5wpPtWDndIW9TT6GC1mdBbjEK/Spqji4iX6U+khz3ZHIvmOqHpTp5Iok+b8hUUkTNIHViuPSnCFOr/MfU0gvoU/7TmIykfA9eK5e5mmmmLSd66rUpNkAReN1QQaXG582U5yOlO9ioPS5zUdvLKcRqSa6Kx0wQ4luDk+natBntrRdqAD6VAhmvjk/JGP1ouNsna4eYmC2GAOren0qeGBYR6sep71IiLGoRBgCnUiWwooooEMc4FRp1p0h7UJ60xD6KKKBhRRRQIKo6UN99I/oCP1q4xwpNRaIoAlmbgbj/OpkdFDS7N52WNS7HAFctNK1/OX/AOWa8VZuJ5dSn+y2+RGD8xqeeJLZFt4x0HNSuxU/dV2XdPULHkVUum3TN9av2vy2+fastzucn1pxHPcWOMtUvlY6kVAKdTEiTgVHcJvgb6UAVYjidwRjihCmrxZhaS2y4eI961HOLtG9TisSMmHUR7mtmb/Wxn3qepdP3oX8iXVl/cBh2rT09/MtFNVdRXdaH6UmivutNvoaUviuXSd6NuzNiql7F50BA6jmrVLSYouzucvb20skgBGMHnNdOBgADtS8UUoxsXUqOb1CiiimZhS0lFAC0UlLTAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAprMFUse1OrM1Oby4dg6tTiruxFSXLFsxLuczzFu3aq9FFdSPKbvqFFFFMQUUUUAFFFFABRRRQBo6Ym64z6V0lZOlQ7UMh71rVyzd2epRjaKClooqTUKKKKACiiikIKKSigBaKKKACiiigAopCQO9MMsY6mpckupSTJKKrm5jHSozdjsKh1oLqUqci5RWebpz0FRmaQ96zeKj0KVFmnketNMiDvWWWY9TTazeK7ItUe7NI3EYqM3S9hVGisniZspUolk3THoKjM0h74qKis3Vk92WoJDizHqabRRUXZVgooopDGkVAy1YphFNMRSZajIq2y1XYVqmS0RUlOIpKsQlFLSUAFFFLQAlFLRQAlFLRQAlLRRQAUUUUAFFFFABS0UUCCiiigYUUUtAgooopAFFFLigYlFOxS7TRcBlLTwhp4jNK4WIcUYqyIqlEQFLmHYphCakEJNWwoFOqecdiBYB3qURoO1P5opNsBAqjtTqSipGLRRSUALRSUUALRSUUALRRSUwFoopKAFopKXBPamkxXCipFidugqUWzd61jSk+hDmkVqWrq26jrzUojQdBWscO+pDqroZ4Rm6CpokKS4PpV4cVXXmYmtlSUWiHNsnpKKK3MQooooAKKKKACiiigAooooAKKKKACiiigCGf8A1dc/fj5Aa6Gf/VmsC+GYqb2LpP8AeIx6KKSoPSFpKKKACiiigAq1at8xFValhbbIKcdzKsrxZp0UUVueYFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB/9braKKKBi0UUUALS0lLQAUtJRQAtLSUtIAooooGLRRRQAtFFFABRRRQAtFFFABRRRQIKKKKAFooooAKKKKACiiigApaSloAKKKKACiiigAooooAKWoJp44Blz1pI7qGX7ppXK5Ha9ixRRRTJCiiigAooooGFFFFABS0lFADSiN1FRm2t26oKmooEVTYWh6xiozplmf4AKvUtF2KyMs6PaHoMVEdEgPRsfhWzRTuw5UYB0Nf4ZD+VRNoko+62a6SinzMn2cTljo90OnNRnTLwfwj8662jNPnYvZROONjdr1SozbTr1U122aTj0o9oyfYo4cxyDqp/Km4Ydj+VdyVU9QKaYYj1UU/aC9h5nEc+horszaWzdUFRHT7Q/wU/aEug+5yNFdUdLtT0XFRnSLc9Din7RE+wkczRXRHRo+z1GdG9Hp86JdGRhUVsHRpezZqM6TcjpzT50S6Uuxl0VoHTLsfw1GbC6H8NPmRLpy7FOirBtLgdVqMwyjqpp3RPK+xHRTtjjqDSYPoaLk2YlLSfhRTELRSUtIDb0kcMa2qyNJHyMa165pbnq0/hQUUVSvrhoIgU4JpJDbsrl2iufiub9nBBLL3rZeYRx7m64zRYSl1K2oTtCgCHDGsT7TPn75ommluJeec8CtKLSyyZkbBIq9EZ6yehnpeXCkncacb+43L81XxpWFI381Un06SFQwOaLoHGSJo9TkB+YZrZikWVBIvQ1y2BHkEfQ1vaaD9mGaUkVBu9i/RRRUGgUUUUALVRpVM2z0FW6jMcZOSOaYCb1prFWGKk8tPSk8qP0oERh1Hy0/cBS+VH6UeXH6UDGHa3zUbhTvKj9KQwoRigQZB6UuaaIVXpR5RzkNQBFApXJPep6TYw75pMSelAC4qPeM/SnkuOoqMhtxOKAJgcjNLTFbA5pd6mgYgYHIFKAM571CpCMc9DUhkUNigQjqhOSMn1oMETHeVyfWnkqaN4FMCpJbReYpC/WnNaQsu1RtzVlsEYpiNliD2oArCxULsDcVDJYEKFVs81rcVXYv5wA6UXCxSSwdOj80n2WZJNyDkjGa1sd6QENwKLhYw2spVJfGajaKd02svHat9ztWmxgkZYc0XFY55PNi6LzUouHhZXKnI61srhpSKkdVzhqLjsc5PO8hLgEH6UjXDGAIMjPXiulxGGAYD2pDDCeNoouFjlppUbJUHIFbmlss0Ac8sKmnt4HXYV61Pb28dsmyMYFJsaRYooopCCiiigAooooAKKKKAGSfcNNg/1dLL/qzSwjEYpjXUlooopCCiiigDEvh+9qhWjfj5xWfWyMkNpuKdRQUJ06U8Syr91sUyiiwXZYW7uF/iNSrqEw6jNUqKVkPmZprqZ/iSpV1KM/eGKxqKOVD5mb631u3epVuIW6NXN4opcgcx1IdD0IpePWuWBYdDUgnmXo1LkHzI6Wlrnxe3A/iqVdRlHUZpcrHdG1RWUNS/vLUy6hEeoxSswuX6Kqi9tz/FUgnhbo1AyamOCUIXqaUMp6EU6kBgx20lsrEpySTnNSQXMcafvS24t6VtU1o43GHUGncRVE8DdGp4Know/OmtYWjnJSojptv/AAfLQBYx6GjDVWNg4+5KRTfsl2Puzn8qALfzUc1SMOoDpJmkxqI7ZoAvc1UublLfAfPNRl9RX/lnn8ahle7kGJIQfxpoTFW7hchQeT0qfI9aynhn4dYeV5HNRM94OTHj8aqwhdR+9+FV0+4v0qK4adx8ydqaszqgUp0qhRLBGaQdag+0H+7R55/u0FFioJ/ugU37Q39ymtJI/SOkIXH79K0qzo0maVXZcAVo0yEFFFFAxRXKag++cgd+K6iRtqE1y0SfaLwt2Xmgh/EaUEYihVfXk1LSnrSVoYt3YUjHAJpagmPRB1NAkrsktx8pc/xVPSAbVCjtS0kNhRRRTEIeRUdS1GeDQAUUlFABVc5ibI6VYpCARg0AICGGRS1XIaJsjpU6sGHFA2incoUYTp1HWr0biRQ4701lBBBqnExtpNjfcbpUvR3NYO6saNRzp5kLJ61L7ilqhGPYtmMoeqk1cJ2sr+h5qgg8i9aPs1aLDKkVC2LvaVzRByAfWioLd98f04qemhtWZXuv9S1Zkf8Aq1+lal1/qW+lZcf+rX6U0YyHUyRxGhY1JVdUNzOF/gXrQ2EUIsZW1e4f7zVTKYhWQ1r6gQtttFU5UxYD2pFojFWoYwwOarJyoq7b96ZmyB4yhporQZQwwapshU4phcQVG0OeU4NSCnigLlQOynbIKlGOqHFWSoYYIqu1uRzGcVNh3HieVPvDcKlS4ibg8Gqm904kFO/cydaB+pogjqpp4asryXTmJqPtM8Zw43YouFr7Gxmqs1nFPyPlb1qBNQj6OMVaS6gfo1F0xpNGJNazW5zjOO9dvo159qtgrfeXiswFXGOCKSFGtJfOt+/UVnUhfVHTRrJe7I6yiq9vcx3C5U4PcVYrnOoOpo6nFL0FKowKAFPApvalPPFL3oEHQUo4o6mlpiuA9adRRTIYtLSUtAhaKKKYgpaSloEFFNfOxtvBxXHG4vpZPL80kk0PRXLhBydkdllR1I/OmmSMd6w4bJgA07ljVxYo16Clcr2fmXvOj9aUSxnoaqYFFFw9miZ7uJODn8qpvqE3SGLd9eKnoouP2aMeW/1LeN6+WPY5roLeXzoVfvjmqFxEssfI5FN0uQhmgb60r6jcVy27GtRRRVGAUUUUAFJS0UDEooooASilopAJRRRTGJRS0UANPAJriHf7RqcknZa66+mEFs8h9K4/T1yrSHqxq4rQwm9TTjQu2K0gAo2ioYE2ru7mp60OSbIfvXAHpzVuqkHzTO3tVukW1ZJBRRRTJCiiigAoopKAFoopKQwrOv2yY4h/EcVo1kSN5uohf7mDTBbo01GFA9BVa8TfbuPardRyDdGw9RTiTPuchbW1zLzEowDjdWzFYIpEkx8xvftTtOOEkj9HNXTUo63psJ7UUU5BlgKG9Airs27ZdsIqldt2rRA2oBWNcNlzUxWgTd5FU0hpwpDVkkZpKcabTAilO2Mmk0tMI0h7morxsQketX7NPLtlHqM0n0RMdpSFum2wn34rHj6ir983yhPeqCfeH1quhjS3udIn3BTqav3RTqkctwopKWgky70hp4kPQE5pHvJJT5FtxjvUN4plu9g7VKke7/R7fj+81PQtbWGRWwnk2A5A+83r7VtqoRQq9BTYolhQInGKkqQYUUUUCCiikPSgCF+tSLwKi6mpqYgooooGFFJRQIhuG2wsfas+y8+6j+yw/KhOWarV+2ICPXitnT4litUCjGQDWctzror3Wyxa20VrGEjH1NZF2++c/XFbjHapb0Fc7nfP9WoRM9ZI1mby7YD2rLq5dPyEHaqlUhvVkkcbSHC1bWyY/eOKhtm2vWx1qbl8qSuV0to0681ZAAGBRRSBnDamvlahkdua05Tny2qnri4u93rUytuhjb3pvcnC/C4m7cjdaMP9ms7QW+V09Ca1HG63x6isTRDtupE+tE+hWH2nE6miiipAKKKKACiiimAUUUUAFLSUtABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFcxqMvmTley10crBI2Y+lce7F3LHvWtJdTjxUtojaKKK3OIKKKKACiiigAooooGFT28DTyBV6VJb2cs54GF9a6O3to7dNq9e5rKc+x00aLbuySNBGgQdqkoornO8KKaWUdTTDNGO9S5pbsfK2S0VVN0g6c1Gbs9hWbrwXUpU5F6krONzIenFMMsh6ms3io9C1RZpllHU0wzRjqazCSeppKzeKfRFqiupoG5QdKjN2ewqnS1k8RNlKlEmNzIenFMMsh6mmUVm6knuylFICSeppMUtJU3KCiiikAUUUUAFFFFABRRRQAUUUUDCiiigAooooASilpKAI2FQMtWjUbCqTEykwplWWWoitaJk2I6KfikxVXEMop+KMUXAZiin4oxRcBtFO20u2i4DKKftNG00XCwyin7TS7TRcLEeKKl2mjYaVwsR0VL5Zp3lmlzDsQYpcVOIzThFRzBYrYpdpq2IqcIhS5wsU9pp2w1cEYp20VPOOxTEZp4iq1gUtLmY7FcRU8RipaKVwGBAKdgUtFACUuKOtXoolUAnk1rSpObInPlKywu1WFt1H3uasUV3QoRic8qjZXmCrHgCqVWrk9FqrXHXfv2N6a0CiiisDQKKKKACiiigAooooAKesbt0FOgXdJzWnXXRoqSuzGdRp2Rni2c9eKlFqO5q3RXSqMV0MXUkQiCMdqkCqOgp1JWiilsTdhRRRTEFLSUUCFqvH95j71OelQxdz71L3RS2JaKKKokKKKKACiiigAooooAKKKKACiiigAooooAjm/1ZrBuxmI1vy/cNYVyMxGn0HD40YVFFFQeoFFFFABRRRQAUqnDA0lFAmro11OVBpajhOYxUlbnktWdgooopiCiiigAooooAKKKKACiiigAooooAKKKKACiiigD/9fraKKKBi0UUtABS0lLQAUtJS0gClpKKBi0UUUwFopKWkAtFJS0AFFFFAC0UlLQAUUUUAFFFFAgpaSloAKKKKACiiigAooooAWiiigAooooAKKKKAMzUkJVXHas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ikopCOL19s3ar6VFZ9CKj1h9+oEDtin2n+sI9qupsjPC/G2dcP8AVr9KwbD/AJCbf571vD/Vj6VgadzqTn/PWpl8JdD+JL0OuNFB60VIwooooAKKKKYBRRRQAUUUUAFFFFABS0lIWVRljik3YB1JUJuIx3zUZul7Cs3WiupahJluiqBunPSozPIe9ZvExRSpM06aWUdTWWXc9TTck9TWbxXZFKiaZmjHeozcxjpWfRWbxMuhapIum79BUZupO1VqKh15vqUqcSY3Ep71GZHPem01ztUmpi5SaVwlaKbMq4YtISeahpzHJJoVSx2rya+girJI+bb5pNjanhtpZzhBx61o2umk4ef8q2URUGFGKzlU7HVTw19ZFC306KL5pOTWiAFGFGKKKxbbOyMVHRBRRRSKCqty/RatZxzWbI25ya5sTO0bG1KN3cjpaSlrzzpCikooAWikpaQBRRRQAUUUUDCikooAKKKKBBRRRQAUUUUAFFFFABRRRQMKKKKACiiigQUUUUAFFFFMAoopKQC0UlLQAUUxpI0++wFU5NTs4urg/SrVOT2RLmluy/RWBJrsI4jQmqi65O0gyBtzWywtS1zP20b2OqopqsHUMvQ06uY2CiiigYUUUUAFFFFABRRRQIKKKKACiiigAooooAKKKKACiiigAooooAKKKKYBRRRSAKUDJxSVLCu5x7VcI3aRMnZF5BtUCnUUV6yOMKZIcITT6guDhMVFR2i2OCuyjRRRXlHaFFFFABRRRQAUUUUAFNZgoyadVCVy747CtqFL2jMa1XkRvWv+qz61YqC2GIV+lT16SVlY5m76hRRRTEFFFFABRRRQAUUUUDI5fuU5PuimTdAPepB0qVuxvYKKKKokKKKKACiiigAooooAKKKKACiiigAooooARvumsZx1raPQ1jP1NUhPc51+HNMqWUYkIqKsz1VsLRRRQAUUUUDCkoooAlhbbIDWpWODhga1wcgGtIbHBiVrcWiiitDmCiiigAooooAKKKKACiiigAooooAKKKKACiiigD//0etooooGLRRRQAtFFFAC0tJRQAtFFFIApaSloAKWkpaBhRRRQAtFJS0AFFFFABRRRQIWiiigYUUUUCClpKKAFooooAKKKKACiiigBaKKKACiiigAooooGFLSUtABRRRQAUUUUCCiiigAooooAKKKKQBS0lLTAKKK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nzJmxN/rxWHrLAbF+tbsnM4zwPWop9NimJlb5wtcdCSjJNnRU1VjjVVj0FTpbTy8ItdUkcMcG6JACKha+SPpya7Payfwo5+VLdk2k28lvARKME1qYqtDLJJGGfvT23FTg4rglrLU64q0dCnq5AtCM1xIrrr23Sa3ZjyyjNcj7V20klGyOaTu9QrubD/j0j+lcNXdWIxaR/7tc2M2Rvh92W6KKK846wooooAKKKKACiiikAVYtly+ar1et1wmfWujDxvMzquyLFUrizWT504NXaK9I42rnOjdE+G4q315rQnt0mXnr61niNo/kbtXJiYacxtQdnYWiiiuE6gooooAKKKKACiiigAooooAKKKKAClUZOKSpYRmQVdON5JEydkXwMACloor1jiCiikJwpNDegGfKcuajpWOWJpK8iTu2zuWwUUUVIwooooASiiigCtctwFqpUkrbnJple1RhywSPFrT5pthVi1H79agqza8TBiOK0k9BQTudCetFRGRj91c0fvT3xWNztsS5FMMiDvTPKz945pwjQdqNQ0GGYn7ozSfvm9qmyopN47UrDuReTn7xzUgjUdqMsegxRgnqadkK7HfKKYzjaQKUItI+AhoewluEX3Kkpkf3BT6a2B7hRRRQIKKKKACiiigAooooAKKKKYC1nXFozNvj71oUUJ2E1cxo7eUuMjpWz2ooobuCjYKo3g5FXqp3g4BoQMxLwZiJrIrbuBmIisSlLc7cM/dCiiipOkKKKKBBRRRQAUAZIFFWraLJ3npVJXIqT5Y3LqDaoFOoorU8sKKKKYBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAH/9XraKSloGLRRRQAtFFFAC0UUUAFLRRSAKKKKAClpKWgYUtJS0AFULo5lA9Kv1myndOfYU0SxKWkpaYC0UlFAC0UUUALRRRQAUUUUALRRRQMKKKKAFooopCFopKWgAooooAKKKKAFooooAKKKKACiiigAooooAKKKKAFopKWgAooooAKKKKACiiigAooooAWjJpKWgBdzDvS+Y4702igCTznpwnaoaKBk/n+op3nj0qtRRYC156U7zUPeqdFFgL4IIyKWoofuVLSGFFFFAgooooAKKKKACiiigAqG4/1JqaoLn/UmhAVoP8AU1lrzIPrWnD/AKms2LmUVSA2B0paSqlxdCL5R1qbDLRdV+8cU3zo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zSxMnsaqkluVRaRnmU7zU6Rxx8IMU+isXNvdmiilsLmikoqChaKKKACiiigAooooAKKKKACiiigAooooAKKKSgBaKSigAooooAKKKKACiiigAooooAWkoooAKKKKACijNAOelUot7ITaCinCOQ9FqQW8h68VqqE30IdSKIaKtC2H8RzUot4h2rRYV9WQ6y6GfkU8K5+6K0QijoKditVho9SHWZniCU9RipVte7NVyk3LnGa1VGK6Euo2L0GKKiaaNeGNN+0Ifu81qQT0VB5kp6LRtmbq2KQE/FMaWNepqPyAfvHNPWKNegoAZ9oB+4M0m6dug21Y4pCQOtAEHku333zThbxDnGaUzRjvTDOT90ZpiJwAvSlzVXdM3QYpPKkb7zUAWC6L1NRm4QdOaYIF781II1FGgyIzuegxTf3r9TVoAClouBWELHrUghA681NUbyxxjLtijUTaQoRR0FOrOl1W1j4U7jWdLrLniNce9UoNmUq0V1Oi+tQyXMEQzI2K5KW+uZfvNxVUknkmtFS7mMsT2R00usQJxEN1Zsur3L8J8grLoq1BIxlWkySSaaXmRiaixS0lWZXYUUUUAFFFFABRRRQAuTSqCzBR3pKv6dF5twM9BUydkaU1eSR0tvGIoVT0qaiiuQ9MKKKKAGuu9StZZGDitaqFym1tw71y4iGnMjejLoV6KSmsyoNzVxJN6I6G0tWOJAGTWXcXJf5E6Uye4Mp2r0qtXqYfC8vvS3PJxOK5vdhsFFFFd5wC0o5NNqaBd8yr6mplsXBXkkdbZJ5duoq1TUG1APanVynphRRRQIKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigChejoazDWteD5QayTVxIYlFLRVAJRS0UAJRS4oxQAlFOxS4pXGNoxUgQmniImk5IfKyHFGKtrbsasLanvU8/YfL3M4KTTxGTWotuo61KI0XoKV2OyMxYCasLanvV6ilbuO5Atug61KEUdBTqKLCuFFFJTELSUUUAFFFFAH//0+spaSloGFLSUtABS0lLQAUUUUALRRRQAtFFFIAooooAKgujiE1PVO9PyBfWmhPYrp90U+mjgCnVQBRRRQAtFFFIApaSigBaKKKAClpKKAFooooGLRSUtABS0lFAC0UUUALRSUUgFopKWgAooooAKKKKBC0UlLQMKKKKBBRRRQAUVGJYy+wHn0qSmAUUUUAFFFFABRRRSAKKKKACiiigAooooAKWkooAWiiigAqeD71QVYg60MaLNFFFSAUUUUDCiiigAooooEFFFFABRRRTAKKKKACiiikAUUUUwCiiigBa52+Obg10Vc3dnNw31qoEyK1FLRWhIUUUUAFFFLQAlFLRQAlFLRQAlLRRSAKKKKYBRRRSAOaXJ9aKKAFDuOhp4mlHeo6KLDJhczD+Kni7nHeq1LSsFy0L2anC9k7iqdFFguXhfP3Wnfb/AFWs+iiyHdmj9uHdacL5P7tZtFHKg5man22P0pftsNZVFLlDmNf7XB60v2qD+9WPS0co+Y2ftEP96l8+H+9WLRijlC5t+dF/ep3mR+tYVLz60uULm7vU96XI9awcn1NLlvU0coXN6isPc3qaXe/qaOUdzcxRisQSSetOE0vrSsFzYorJE8vrTxcS+tFgNOioLd3k+9VkilcbViJ+wq2vCgVUblgKt0B0FooooAKKKKACmv8AdNOqOU4jNCJlsZtvzOa1ayrTmUmtWh7ldEFJRRQIKDwD9KKG+6fpQDKVqTsLVZiTYDjvXOY1TLG2I2Z4GKes+tp95M/QUNFdDo6TNc/9v1JOGhJqRdVuB9+BqLCNzdRuFY41b+9Ewp41a2P3lIoA1dwoyDWcNUse7YqQahZH+MUAXMKeoFMMUTdVFRC6tW6OKkEsTdGBoAaYIWPK0xrS3bgrVjIPeloApHT7Q/wn86Z/Zlt/DkfjWhiloAzP7MUfccikNjcL9ybH4VqUUAZfkaiv3ZQfwpM6kvbdWrRQIyPtV+v3oCfxpP7Sdf8AWQlfxrXpNqnqBQBljVrb+Liua1W7F/crDEcoOtdRqDwQwklRnHpXGWw3Frgj754rSKMajLJwOB2pKKK1MAooooAKqTTtuEEPLtxSzzMCIYeXbgVt6bpotl82bmQ1nKXQ6KdP7Uhunaatsvmy8yGrkp5xVsiqUn3qlGsmRUUtJVEBS0uKSgBDwCa5uM/aL15uw6Vs383kWzN3rItE8uEZ6mmZzehf7ZqqTk1oRYKVFLCD8y1HtFexv9Ulyc6KdFB4orU42FFJRTAKKKKACiio5JBGu40ARzOciKP7zVdhiEEewde5qG1hK/vpfvHp7VbNLzKb6ISiiimQFFFFACZwM1SiH2i4Mh+6nFPupNq+Wv3m4q1bxeTEF796l6m1NWVyakpaKChCQBk9BWJcTm6fYv3B+tSXdwZm8mI/KOpqJVCjAo3JbsKBgYFFFFUQFOtG8uYx9jTajJ2SK47GpZcexsPwy/WpIV8/UAOyjNRuwID+nNXNIQnfOe5IqJvoa0Vq5HSKu+EiswjBxWxajIIrPuE2SkUosJ7kXamkU8dKQimMjop2KSmSZuoxb4d69VqojbkBrbYBhtPQ1gkC2dkc4Uc1SM5EuQBk1k3WpBcx2/J9aqXd687eXFwoqmABUt9iox6sG3SHdIcmlooqSwpKWkPSgaOjtzmBfpRcNtgY1HZnMC02+OLZq0WxhJe9Ywo2KEOOua6SKQSxhxXNDpWhYz7H8puh6VCdmayXMjZoopK0OcdRSUtAC0tNpaBCkBlKnvXMyeZHMyA9DXTZrE1GPbKJB0PWlI0pvWxUEs4PDVJ9puk/iqCnhv4WqDayLaahOv3uaJJIbjkja3rVIgqcUlFxcq6D2hkTn7w9aiyKkDsvQ08urffGfpQPUhpcmlKr2OKTFIA4NFJS0wEqaCB7h9i02ONpXEadTXVWtstvGAOvc1L1NErasS3tkt02qOe5qyFoZljUu5wBWBc6jLOSkHyp60xN9zaluLeHiRgKgF/YSAozg5rm8oDmQkmjfAetOxHMTS7YZtiHKMeKVQ5fy41yxqv5cbHKN+dTpJcW4Lr+dIaa6mtbaWB+8uDuPpWsFCjCjArkxqV4P4hVmPWJ1P7wbhSuXo9jpKKp21/Bc8DhvSr2KdyWrCVzur2ojYXCDr1ro8VDcwieBoz9aZLOOHNLTACpKHqKfQBfnP8AoaYrobKLybZV7nmsNQGt0z2NdOF+VcegpRNZ9w3BRuPauLupd87uO/FdBqlz5EPlr95uK5b61TMQpR95T70lHTmkCOsQ/u1PtXM+U95fMq9Aea3RIFsw3cjAosbfyIzI33n5qn2M1pdltEWJBGnQUtBpjNimQwJpBSCkLYpiHE1EzUhYmmUALmqspLYUdzipnbAx3NRquZgvpzQxo0EG1APQVUhT7Xd5P3E/mKluZNkeB1bgVcsofJgGercmob6GtJfaLdFFFI0EpwFJjNTIhNAIVFyc1PSquBxUUk8UXDHn0pDbsTAVm3V4zE29tyx6n0odri44+4np3p8cSRDCj8apIwnU7EVvbiAZPLHqasUUlUc7dxr/AHcetWFO1Ao7VBjLfSpM0ra3NVL3eUcTTTRmkoLiwooopFmZKf30jelOsBiDPqTVedvllb6VctBi3UUdRU/hbLFI33TS0jfdNUiZ7HP2/wDrn+pq3VS3/wBa/wBTVumiJblW6fbHtHVuKlhTy4gtVT+/usdl/nV6kPZWClFJTh1FMDWthiMU5jlqRPlQU1eealEvcdS0lKOtAFpeFFOpB0opALRSUUCIJj8yipjVaRszBfarFA1shaKSigRy+tQH7SjDjdSQwJCuB17mtDWU3QiQdVqmp3KDTtqVB+7YWiiimMSiiimAUUUUAJRRUbtj5R1PApN21HGLk7Imt4GvblYF6d676KJYYxGnQVlaPY/ZYPMcfO3JrZrC99Trdl7qCiiigkKKKKQBSUtJQAUUhIAyeKj3luIx+NAEhIUZNNAZ+vApyx45bk0+gAAAGBRRRQMKSlprMFGWoAR0DjBqsZXh+STkdjUpnB+4M00q0gxJ09KRSdtyJHlKlR0PeplUKMU5QFGBS0JA3cSq1wOhq1UE4+TNUiGUTTTTzTKokSkpaKYCUUUUAFJS0Hjk0AJRUZmToDk0fvn+4pHuaiVSMd2NRb2JajaWNPvGnC1dv9a2fpU6QRJ0GfrXPLFRWxqqL6lQSSP/AKtM+9OFvM/32wPSr4AHTiiueWJm9jaNKKK6WsSc4yasAAcCiiudtvc0StsLRSUtIYUUUUAFFFFIAooooAKWiigAooooAKKKKACiiigAooooAKKKSgBaSiigAooooAKKKKYBRRkCgc9Oaai3sJtIKKeIpD/DUgt3PU4rVUJvoQ6kUQUZFWxbJ/FzUgijHQVqsK+rIdYoDJ6U8RSn+GtAADtS1qsNFbmbqspC3c9TipBbL/FzVnp1phkQHBNaqnFbIlzbEEMY6Cn4A6CozMvIAzUZnI7Yq7E3LNFVN8hJ5pArt1BpiLJkQdTTTOvUc1EIWK4PFSeT6mgBDMemKZ5khIycVN5Sd6eFUdqAKQ3Ox706CJ1ld2GAelXeKYZF5A5IoAPLQHOKdgdhUW+Q9FxRtlPU0DJunWmGWNeppvlL/ETTgijtSEN88H7ozSb5W6LipcAdqKYEOyVvvNSiBf4ualpaLgMEaDoKdinYqF54Y/vsBQDaRJijFZ0mq2qfdO76VQk1pj/qlx9apQbMnWgup0OKY8kaDLnFclJqN3J1bFVGkkb7zE/jVql3MniV0R1cmp2kfRtxrPl1o/8ALJcVg0VappGMsRJl6TUbqXq2BVNnduWJNNoq0kjJyb3CiiimSFFFFABRRRQAUlLRQAlFFFABRRRQAUUUUDCuk0mHbGZT/FXPRoZHCDua7OGMRRKg7VjVfQ68NH7RLRRRWB2BRRRQAVFMu+Mj0qU+prHvdSVAY4eT3NHJzKxLqKGrIJJliHzdazJp2lPPSomZnO5jk0lXRw8Ya9Tkr4mVTToJRRRXScwtFFFABWlpke+4B9Kza6LR4sK0h71nUeh0YdXlc26KKK5ztCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAK90Mx1jkVuTDMZFZJQ5qk7CsQYoxVgRE1Itux7Uc6HysqYpQprQW1PepRbAdaXOx8qMwIaeIia1RDGO1SBVHQUrsNDMW3Y9qnW1PertFKw7kC26DrUoRB0FOoosK4UUUUxBRRRQAUUUUAFFFFABRRRQAlFFFABRRRQB/9TrKWkpaBhS0lLQAUtJRQAtFFFAC0UUUALRSUtIAooooAWs+8OXRa0KzLg7rgD0poUgooopgLRSUtMApaSlpAFFFFABS0lLQAUUUUALRRRSAKWkopjFooooAKWkpaACiiikAUUUUALRRRQIKKKKACiiigBaKKKBhSE4FLSMdqk9aBGIWxPv5HNXRfLnG01WfdM25V/Cosc46Y9a1tc5rtbGutxERknFSBlbkHNYTHLCnglW6mlylqozcpawlmlVuDnFXRdsI9xFS4lqomaFJVSK7V22EEGrdIpO+wUUUUhhRRRQAUUUUAFFFFAC0UUUAFWYO9VqtQdDQxonoooqQCiiimMKKKKACiiikAUUUUCCiiimAUUUUAFFFFIAooooAKKKKYB2rmrg5nf610h+6fpXMy8ysfergTIjpaKKokKKKKACilooASilooASiloxQAlLRRQAUUtFACUUtFACUYpaKACilooASilooGJS0tFAhKKWigYlFOooASilooASlpaKAEopaWkA2lpcUYoGJRTsUYoASlp200u00rjsMpafsNGw0roLDaUCnbTTgpzSbGlqaNquEzVojiooFxGKmPSpRUtyt1lFW6rIMyVYpiFooooAKKKKACobg4jNTVWuziOhbkS2Kdl/rDWpWZY/eJrSoZbCiiigQUjfdP0paQ9D9KAKVk3yEe9X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gCMQxjtTwoHQCmmWMd6jM69gaYE9FVHmkxlAPxo3ScEn8qLAWsjvTDImcZGarsDvzyacqckhetADzOuQACaTzJCDtGMetKsbDrx9KcIwO55oAibzGAyQKCAW3ZOR2qcIoGOuKdgelAFYKM/dP40/ym9hU9FAEQjHUnmnBFFDSRr95gKrSX1tH1bP0os2S5xW7LeAOgorHk1iIfcBNU31iZvugCrVNmbxEEdJnHWomnhT7zgVyb3txJ1Yj6VXZ3b7xJq1S7mTxXZHUSanAhwvzH2rQQkqCe9cdZx+ZcKtdn2qJxS0RrRm5K7CiiioNxa4/UZfNuT6DiuslbZGz+griJG3SM3qTWtJdTjxUtkMooorc4gooooAKKKKACiiigAooooAKKKKBhRRRQIKKKKACiiigAooooGFFFFABRRRQAUUUUAFXtPg86cZ6CqNdRpkHlQ7z1as6krI6KELyuaVFFVJ72CAcnJ9BXOlfY7nJLctEhRljgVz+oX5kPlQnjuaq3N/NcHGcL7VRreELbnHVrc2iCloorQ5gopKKAFpKWigBKWiigAooqzaW7XEoUdO9Ju2pcY8zsja0m32RmVhyelbNMRBGgRegp9crd3c9NKysFBOBmimSfdpAPHIzS0g6CloAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACkpaSgAooooA//1usooooGLRRS0AFFFFAC0UUUALRSUtABRRRQAtFFFADJDtjJrKi+7mtC6O2E1RjGFFNbE9R9FFFAxaKKKAClpKWgApaSigBaKSmuxVSRyaAH0tUvtRCbmFH22Mfe707C5kXaKgW5iboaeJozxmlYdyWim7l7EUuRQAtFFFAC0UUlAC0UUUAFLSUUhi0UUUAFLSUUCFooooAKKKKACq9zE8qbU61YopoGrmbBHKk/zDitKiihu4kraBRRRSKClpKWgAooooAKKKKACiiigAo4oqrdRyuAYj0poTdkW6SspJrmHhwTWlHIJF3CholSuPpaSlpFBV2P7gqlV5PuCkylsPooopCCiiimMKKYN+85+7T6ACiiigAooooAKKKKACiiigQUUUUAFFFFABRRRQBFOcQt9K50Cuguf9Say1tmKg4pqVhWuypijFXfszelJ9mb0p86DkZTxRirn2dvSk+zt6Uc6DkZVxRirXkN6UnkN6Uc6DkZWxRirPkN6UvkN6Uc6DkZVxRirXkN6UeQ3pRzoORlbFGKtfZ29KX7O3pRzoORlTFGKufZ29KX7M/pS50PkZTxRirwtX9Kd9kb0o50HIZ+KXFaItGpwszRzj5DMxS4Naos6cLRaXOHKjJ2ml2GtgWyU4QRijmYcqMcRmnCJq2RFGO1KEQdqV2FkY4gY9qkFs3pWtgDoKWlr3HddjNFo1Si09au0UcoXKwtkHWpBDGO1S0UWFdjQqjoKdRRTAKKKKYBRRRQAUUUUAFFFFABRRRQAUUUUAFLSUUALRRRQAUlLRQBB5PzZB49KmpaSgBayb05fFa1Yt0cyU4kS6FuxHyGrtU7L/Vn61cqTSW4UUUUxBRRRQI5q4kvI7iRbUccZpLe+v2kZHU/KO9akbFL6RR3xV8BfvAcmm2NbGAup3gj8ySPGD0qU6tMDgxHGM9K2WVG+8M0bV6YpXGYn9tMBuaJsfSnjW48ZZGH4Vr+XGeCoppggPVBRdCM1dbtiMlWH4VINYsz6j8KuG0tj1jFMNhZnrGKLoCIapZn+KpBf2h/jFNOmWJ/5ZimHSbE/wAAo0AsC8tT0kH508XFueki/nVE6NZHoMUz+xbXsSKNANMSwno6/nTt8f8AeFZJ0aLtIwph0j+7M1FgNrcnYijK+tYf9kzD7s7006bdjpO1FgN7Iqrcy7IyB1NZBs79f+WxP41EYrkH945NWoomTFdsAue3Nc9Bl5ZJj3PH0rS1CQxW+O7HH51ThTZEq+1aI55vQkpKWqNzKxIgi5ZqbdiIxcnZAFe+uBBH90dTXVwwrBGIoxgCq2nWa2kIH8R6mtRUz1rBu7ud9lBcqIljZqm8oDvU2AKQ4p3IZF5YppjWpsio3IxQIpOq5qEgVKx5qM1QhhUUoUYoNO7UxMhkcRoXPYVzUJMrvcN/EeK0dXnKxiFOrVSVQiBB2qjFvQdRRSVRAtFJS0AJRRRQAVHI+PlXljSu+PlXljViCDyxubljSHsJBB5Y3Nyx61MzAdaZLKsYx1J6CmorH536+lMRKKWiigQlBIUZNMeRYxlqoPI8p54HpSGSSzF/lXgVDaw/aJtzfdWmPk4jT7zVsQRCCMIOvWpfY3pR6sqyyXQO2MAAdzUK2zynfOxPt2qe6kLMsC9SefpUvCgKOwoRbFVVQbVGBQTRSUyRCdoyajiXkyN1NIx3vt7DrUi/epFEgrKk4uXrUrLk5uWpkS2FopKZI4jXNNmSV9COViSIk6mtGKIQxYHXvVezgP8Arn6mrczbIyaS7mr7IhsDm+De9dpGN0oHvXD2LBblSxx3rvNPxK5kHIHFZPqb/YRrdOKKKKgRnXQ+aqLMqKXbgCtC5+9XFa7qJH+iQn/eNa9DK+tjJ1S/a9nIU/u16VnUgGBilpFJWCkoooGFFFFABRRRQAUUUUwDoQa1Lk77MNWXV0tusiPShET2RQHSlwWYKvU03OBWvp1r/wAtnH0peRpHuy/awLBEB3PWrVFFWiW76hVOe0WQ74/lb2q5RQIzI7lkbyrkYPrVvtkcipJIo5l2yDNUGjntDlPnT0oWhMo3LdFRxzRyjKnB9DUlWYtENwMx1Fwy881YcZUiqkf3BUspbFSW1/ij/KqvIOG4NbNQyQpIOevrSaNFLuZtFOeN4jg8im9elSUPB4we1LBGZZAvqaYBnitnTIRuMh7VMuxrTXU2YkEcYQdqc7iNC57ClrK1efy4RCvVj+lCCTMfebm5aZumeKkX7xNMhXYhoCOU3J1pkCTx7146iqIOPrWgsnZxg1HLCr/MvWmxEazcYNP81arGKVe1IElPakBJJLkYFRxIZHAH41KltI33uBV6KNYhhaBj7bgEfhWa/wDrG+taVv8AeP1rOk4lb60AMpD0paKBHW+F5sSmI967avNtAl8u+HvxXpJrNG9bVKRWvF32zr7V5vbancWQeGMAgnvXpso3RMPY15PcrsuXX3rTockXabQk88ty++U59qioooNAooooAQnHNathHtjLnqTWWFLsEHc1vouxAvpVRRnUfQfRSUVoYhRRRQAtV3Ysdi96V3zwOlT2sJx5jdTSZSVtSWKHgKO1WJpBCmxOXPQUkkgiG1Buc9AK1dO0sr/pl3yx6D0rKUtbI3p07LnkcukMguB5nXrWjaxedcFvQVDNLumkk9CR+Vbejw4h8xupNEuxVHrNluK2c/eYgVcSCJeiipKbJIsalm6UJBOfViSSKi5Y4FYN1fM+Qp2rUd3dlzz07Cq6W28ebcnC9lpt2IhBy1ZWUTTNiBc+5rTtNPt3f/S5Pm9O1J58iDFum0egoSa2mOy5TY397FRdvY30jozoUs7aMfukH1pxj9Ky0ku7PlT5sX61r29zBcrujPPoetHMQ6fYrtFURiNahUU0oDT5iHTMoxGm+Wa1DGKTyhT5ifZGX5ZphQ1q+UKY0QxT5hezMkikqeRcGoaoUUJTX+4afTW+6aSKexz55kiHua2x0FYf/LaP6mtwdKFuyl8ERaaxwpJpapX8vlQEDq3AqiGY9rHvuZJj0BOKvk5NNgj8uEep5NLQiGFFFFMAq9FxDn1qjV9PuKvrSYFhRhQKdRRSJCpo+lQ1OnSgB9ITQTTKBENxKIYmkPYVxaEz3BkPOTWtrNyWIt4z05NULFMvn0FJ72NKatFyLjDy2Rh2Irp1begb1Fc5OMrWzYP5lqv+zxQ9ynrT9C5RSUUzEWqt7H5tsy1ZowCMGgTOYtm3RYPUE1PUESMlxJCB0/rWnFZSyctwKEzWW5TyB1qjLcO58q3BJ9RW/Lpm8Kqnvz9KvQWtvbgCJAD3NDYk0jm7PTLhvnZQCe561ux6ei/6xiT6VoEk9aSkDk2MWGJPuqKk47UlFBItFJRQAtFJS0AJSbVPanUlAEZiQ00wDsanooFYovbA9QDVZrRRyo2n2rXpDz1phqtjJVryE5jkJ9iauJq9zHxOgI9RzU5jQ9qjMHpUuKZaqyReh1S0m4JKn34q+ro4yjA/SuXktA33lzUIjmhOYnZfapcOxoq8ep19Fc1Hql3DxKoYevetODVbWbgkqffipaaNk09maVFIpV+UIP0qZVx1pDBVxTqKKACkP3T9KWg9DQBlHrSU5vvGmmsa/wAJpT3EooorzzpClpKWgApKWikAUUUU7BcKKUKx6A1IIZD2q1Sk+gnNEVFWRbnuakFug681osNJ7mbqopU4AnoDV8RRjtUgAHQVosMurIdYzxHIegp4t5D1xV7IpC6jqRWiw8CXVZWFt6mni2QdacZox/8AWpn2gfwg/lWqpRXQlzZIIox2p4UDoKr+ZMfuqKMTt14+lVYm5azTdy9yKr+S5+85pfIXuc0wJDLGO9MM6D1pRDEP4RTwijoKWgEXn5+6po3zE8AVPRQBBtnPXAo8lyMMxqel49aBEHkL3JNOEUY/hFP3L60wyoKYDwAOgp1QGdc4FNMznoBSAsUVWLSH2pqbmPJNAFrcvrTPNSoVjOTx1p6xEZzxmmApnGMqKaZZM8AU8RLjBNP2L6dKAK/7wk7ifwpPLYgZyatYA5pc0XCxX8npwKf5QzmpaKLjG+WtLtX0oLKOpAqF7qCP7zD8KLNkuSW5PRWXJqtsv3cmqb6y/wDAoqlTZm68F1OgppdF6kCuUk1O6focfSqrTzP95iatUjJ4pdEdc95bx9WH4VSk1e3X7uTXM9etFWqSMniZPY2n1mQ/cUVSk1G5k/ix9KpUVaikZOpJ7skaWV/vMTUdFFMzuFFFFMAooooA19Ij3T7/AO7XTVj6NHtiZ/WtiuSbuz1KMbQQUUUVJqUdRfZat7iuQrpNZfESr6mubroprQ83EO8wooorQwCiiigAooooAKKKKACjk8CnIjOwVRkmuktNOjhUPIMtUSnY2p0nMwktJ3UsFwB61WIwSPSu1lwIXx6Vxbcu31pQlzDrU1C1hKKKK0MAooooAKKKKACiiigYUUUUAFFFFABRRRQBas4DPOF7DmunluYLZME9OwrmIbpoEKx8E96rszOdznJrJwu9TojV5Y2iadzqcsvyx/KKzCSxyxyaSirSsZSk3qwooopkhRRRQAUUUUALRRRQAUUU5EaRgiDJNFxpX0QqRtIwRBkmussrVbaMf3j1qKxsVtlDty5rRrnnK56FKnyrUWikpazNQqOToPrUlRSfw/WmMmHQUUUUhBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUlLRQAlFFFAH/1+sooooGLRRRQAtFFFAC0UlLQAUtJS0AFFFFAC0UUUgKV8f3YX1quvAFSXpyyrTKonqLRSUtAxaKSigBaKKKAFooooAKWkooAa0aN94ZqFrSJ+2KsUtO4rFVbVUbcDmqxsWyWBzWnRRcOVGObW4QZFGLpMcnmtminzC5UY5nuo+pJqT7bOmNy5rTIB60hRGGGGaVw5X3GQTecm4jFTU1VVRhRgU6kUgpaSigBaKKKBi0UlLSAKKKKAClpKKAFopKKBC0UlLQAUUUUAFFFFAwooooAWiiigAooooAKKKKACiiigAIDDBpAAvAGKWigQUUUUAKOtaC/dFUB1FXx0pMroLRRRSEFFFFAwooopgFFFFABRRRQAUUUUAFFFFABRRRSAKKKKYgooooAr3P+r/Gpo+Ix9Kgufuge9WF4UUMELRRRSAKKKKADijiiigYcUcUUUAGBRxRRQAUtJRQAtFJRQAtFJRTAWiiikAUUUUAFFFFABRRRTAKKKKACiiigAooopCClpKKAFopKWgYUUUUCCiiigYUUUUwCiiigAooooAKKKKACiiigBaKSigBaKSikIKwpzmQ/WtxzhSa5+Q5c1a2Je6NazH7r8atVXtR+5qxUGktwooooJCiiigDB1CU210JVGSarQ61JGp82M/lWjqACTRTN0HWrZNm2MhapgjJGuoRnyz+Rp/9txYyVP5Vq+VaEdF/Om/Z7X+6v50DM1dct8ZYEfhUg1q2Pr+VWzZ2bdUX86b9gs/7q/nQBCNZsycZP5VKNVsj/Efypv8AZlkf4V/Ok/sqy9BSAmGo2Z6NThf2n9+qv9lWfbFMOjWp7D86AL3261P8Yp32u2P8YrNOjQDoo/Oom0mEfwD86ANj7Tb/AN8fnR58H98fnWEdOtBwwxR/Zlpj5adgNz7Rbj+MfnTTdW4/jFYDadbio/7Pgp8ormxLewdAwqi08bn5TVYWNuO2af5UMSlgMY5qkiJMx79vPulhHQDNS1WhPmTSTH1wPpVmtEc83qQzyiGMsam0izZybyYcnpWeiNqF4Ih91TzXcRQrGgUdBWM5XdjspQ5I8z3IhmnZarQUCnYpXEVPnpCr1dpaLhYz9j0xwQOa06qztximmJmYajqwajqxEdLnAJPan1m6ncC3tyB1bj86aIlsYrubm8aQ/dTgVNUNunlxgHqetTVSMmFFFFMkKKKCQBk0hhUZcsdkfJpBvnbZHwO5q9FEkQwvXuaA2GwwCPk8se9MuLlYRtXlj0FR3N2I/wB3Hy1QW1uWbzZOTQFurJoImJ86XljVyikJCjJpi3FqvLOE4Xk1DLcFvlTpVagdhSSx3NyaCQoyaKWKI3Mu3+AdaluxcI8zLNlCSTcv36Cr0kgRS7dqfgABV7VmXbmRxAnQcmpOjyQkJG4zOfmPT6VZBB5zmqgRDwab5YB+Q7aSkX7Iv0x22jNVC9xH23Ck812cGRcAVXMRyNbltBtTcep5pY+59aga5jbjkfhU6OhHBpqxLJe1ZL83D1rZwCax1O52f1pkS2HEgDJqOCM3Mm9vurUb7ppBCn41sRxiNAi9qndglyoXpwKzrqXewiXoOtWrl5o1JRcisyM5ye5obBK2o5lB/CtKx1S6sCAhyncVnUUnFMqM3E9IsNVt75PlO1u4rUryWOR4XEkRwRXW2HiNNmy84IHX1rNpo1upK8S3rd8tnATn5jwBXmzM0jmR+Sa0NUvm1C7aTPyA/KKz6oxiurCiiigsSilpDQAUUUUAFFFFABRRRQAVMjfuXSoaMkHjvRewWvoS2kBuJQOw610yqFXaOgqtZQCGEccnrVunFdQl2QUUUVQgooooAKUdaKM45oEzmrnK3TtGdpBqxDfH7sw/Gqk53TufeosZ49aluwRjzaM6FHSQZQ5qqowzL6U6PTWESyROVY1Qknlt5ik3J71V9Lsjls2kX6Kijnik6GpqdxWEIDDB5qjLbFfmi/Kr1FDQJ2MpDyfWuqtIvLgA9eaw5IAx3LwRWla34GIrgbSOAaza1OmE01Y1R6muUvJTcXbHsvFdPcuI7V5Ae3FcjDlhuPfmgmZO3Ef1qVBhAKifkhKsUAMZFb7wzUfk4+6SKnooAr7ZR05qQb+4qSigBvNLRRQA23+8frVCfiY1dgPzH61Sn/11AmR0UUUCLNi/l3SN7ivV0bcgb1FeQIdsit6GvVLCTzbONvao6m8taSLbcqRXl+qJ5d/IPevUa878QxbL7f8A3qtbHH9tMw6KKKDYSilpMFiEHU0CLtjFucyHoK1qihjEUYQfjUtapHPJ3dxKKKYzhetAh5IHWq7OX4HSmli/WlAwKQ0rEkMXmNjsOtXy5LCGAbmqC3SWZvIthknqa62x0+KzTPVz1NZyl0R0wp296ZBp+mrB++m+aQ+vatC8k8q2d/QVPWPrcuy22D+LiiC1FWm7HLIpkYr/AHjXZ28flwqg9K5nSofNuFPZea6/gc9hSWrbNJWhBRGO4jXc1c9e3pY4HPYCpdRvP4Vqla25J8+X7x6VUnyqxjTg6j5nsPgtwp86b5nPQelacNs9w2cfjVm2svMPmS8CthQqjaowKy33OpyS0iV4bWKDoMn1ptxZW9yMOvPrVqincyeu5zjxXOmnIJaOnxxQXY8+yby5R1A71vsoddrDINYlzpTxP9osDtb+7VXvuJXi7ongvnR/IvRsbsex/GtQEEZByPasiC4hv1+y36YcetNeO6035oW82L09KlqxaalsbNFU7a+t7ofKcN3B4q5SE1YTFIRxS0UxGZOuDVI1p3C8ZrNPWtEZdRtIehp1J2pg9jm24mj+prcHQViTjbMn1raHQfSn1YR/hodWNct9ouhGPupzWpNIIYjIe1ZVqp2mRurH9KZDZYfpUNSv0qKmSLRSUtACjqK0EHzD2rPX7wrRi5yaTAmooopEi1MvSoKmHSgBSaq3VwttC0h/CrBNc9cMb+6EK/cQ5NGwox5nYp+Uxge6k6uTj6U7ThwTWvdxA2xReiisnTuEYe9HU3ezLrjIqxpj7WaE9+ahNRIxhuVfseKJ9xU+se50VFBIJyKKDEWikooEZE/+j6jHN2Y81vk55rF1SMvB5g6pzWjay+dAr+2Knqa7wTLNJRRTIFopKKAFopKKAFooooAKKKKACilooASloooAKKKKACiiigAppRW6inUUCK7W6noapy2ufvDNalFO4rdjGTz7c5gcr7VoQ61PHxcJketStGjdRVd7b+7ScUzRVZLfU2oNStbj7rYPvxV4EHlSD9K4mS2GeRTo7i7tv9U5I9KhwZtGtF76Ha0djXOwa7j5bpMe/WtqG7trgZjcfjUGvoU3+8aaasmBmcntTxbDuamcOZWHGVmUqWrwhQdqeEUdBXOsL3Zr7YzwrHoKeIpD2q/kUhdR1NWsPEn2rKgt3PWpBbDuakM0Y70z7SnbJrRUYroS5scLeMdeakEaDoKh86Q/dSjNw3tVqKRLbLPAoLL3Iqt5ch+85pfIT+LmmIkMqDvTDcJ2yaURRjoKkAA6UAQ+bKfurR+/brxU9FAEHksfvOaXyI+4zU1GQKBDBGg6Cn/SmmRR1NMMy9qAJqKr+cxOAKaZHz1xQFy1SZHqKq/O3fNHlE9RRYLlgyIO9NMyCmCL8Kd5Q7nNGgaiecc8CmmV/TFSeWnXFPAA6UAV90jHAJpBHIRzVrNFFwsQCD1pRAvrU1FK4DPLXrS7F9KdRkUAJgelKAB0phkQdWH51Xe9to/vNTSYnJLcuUVjvq8K/cGaqPrMh+4uKpU2zN14I6KkLKOpFcm+pXT/AMWKqtNK/wB5s1apGTxS6I697u3j+81U31a3X7vNcv1oq1SRk8TLobj6y38CCqj6pdP0O36VnUVSgkZOrJ7sme4nk++5NQ9etFFVYzbbFopKKYC0UlFAC0UlFIBaKSimAtFFFIAooooEFHfFFSQrvlVfehlRV3Y66xTy7ZVq3TVG1Qo7U6uNnrpWVgoopRSGc1rL5mCenNY1XdQffdMapV2RWh5NR3k2FFFFMgKKKKACiiigAooooA1tIjVpix6r0rpK4y3uHtpN6fiK0H1eVhhVx71jODbudlOtGMbM3bggQOT6VxZ+8T71ZkvLiUYdiR6VWq4R5TGtU53oFFFFWZBRRRQAUUUUAFFFFABRRRQAVPbxedIF7Dk1BWrEBbWpkP3m4qZMuC1uyjcbBIVQcDioKXryaKZLCiiigAoopaAEooooAKKKKACiiigApaK07TTnnIeThamUktzSEHJ2RTgtpbh9qD8a6e0so7VcjlvWrEUMcK7IxgVLWEp3O6nSUQoo+tMaWNepqDWw+iqrXkS9OartfN/CKAsadRPgsv1rLa5mbvUlrlpck5pgjWopaSkIKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBKKWkoA//Q6yiiigYtFFFAC0UUUAFLSUtABS0lFAC0UUUALRSUtIDLuTm4A9KKjc7rhjUlWSgpaSikMWiiigApaSloAKWkooAWikpaACiiigBaKSloAKWkooAWiiigApaSigBaKKKAFopKKAFpaSsfUb+W2kEcXXGaErg3Y2aK5ZdXuh1OatR612kT8c0+Vi5kb9FQQXEVwm+M5qekMKKKKQBRRRQAtFJRQAtFFFABRRRQAUUUUDFooooAKKKKACiiigAooooEFLSUUAOX7wrQqgn3hV+kyugUUUUhBRRRQAUUUUDCiiimAUUUUAFFFFABRRRQAUUUUhBRRRQAUUUUAVrnqo96sjpVWfmRRVqmwWwUUUUgCiiigAooooAKKKKACiiigAooooAKKKKACiiigYUUUUAFLSUUALRRRQAUUUUCCiiigAooooGFFFFMAooooAKKKKAClpKKQhaKSlpjCiiigAooooAKKKKACiiigAooooAKKKKACiiigCKY4jNYDHLVuXRxEawurVXQhayN63GIhU1Rw8RD6VJUGktwooooJCiiigDK1eMvZsR2xUFtZWtzAs5H61rzRCaNoz0IrgLi9nsYpNPORjp+NWtRLQ64WFmfuH9acNOgHQ/rXnNlJOJjIrHjmrMerXilvn78UmijvGsIl6sRT10+IjO+srR7ma/siJzk5IzVfUdVe1cWtseQOTSsBv8A9nx9no/s8Z/1lcZDrd7C+XbcDVSbVL55SyPjccY+tOzFc7/+z1PPmU5b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7100S7IlX0Fc9bJvmUV0tZTOugtLiUUtFZnQJRS0UAZuqTeXaMAeWGK4qtzXJCZxHngVh1SICiiimAUUUUALRSUUALRRRQAUUUUAdv4fXEea6euf0JcQ5rfrE3nuFFFFBAUUUUAFBOBmikYZFADVGeTT8CmIexp9AEZG1gRUlRuegqSgYUUUUCCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACkpaSgD//R6qlpKWgYtFFFAC0UUUALRRRQAUtJS0gCq1222A1ZqjftiLb61S3E9jMHSlpKWrIClpKWgYtLTaWkA/NOBqOnA0ATA1IGquDTwaQy0rVxeqPvvX9jXWFsKT7VxFy++4dvU00hSIq29CXddg1h10nh5cz7vSlLYuludpRTaWsxi0tNpaAFopKKAFopKWgAooopgFFFFABRRRQAtFJS0hBRRRQAUUUUxhRRRSEFFFFABRRRQMWikpaACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooEFFFFABRRRQAUUUUAHXrULW8Tc4wfapqKBpsrySGBeRkVVa+P8IrQdQ6lTWDIpRyp7VEro6KMYy3J3u5W6cVXZ3b7xNMzSZqLnWopbC0maTNNJoKsOzTc0maSgdhc0maSkoHYWkopKBjw7L90kUF3b7xJplFMVkLSUUUhhRRRQBsaYflYVqVlaZ0atWtVseZW+NhRRRTMgooooEFJRRTARjtUt6CvMNRm8+8kf3r0e9k8u1dvavLCdzFj3NXEze4lFLRVAJRRRQMWiiigQUUUUAFLSUtAxauJdBYxEFAx3FVKKVgubIuon2CN2QD73ap/t7C4WQSfKvXmufxRjtSsO52y6uucMVI7Yq6L6DcqllO70rzzOOlKGYHINLlHzHo/2iInAYdcUocu+xccDNedCaUchjUiXlyhysjZ+tHKHMegCTIO7jaagy1y/lqwUe3WuIN9dn/lo351v6CJJJfNkJOBik9C4q9zqI4kiGEH41JRS0hDSQoyapvKWPHSnSFixz0FMVQevGKYhoUkgEdakWDJOelTF1HKjNLuPfpQAxY0+tP2r35pnmqBhaVHLN7UgJd20dKQPuGafSYFADFfccU+jApelAGTqThdoqhJOhypUlWByadqEyvOF9KrXbFIG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V2Luf1pMn1oooC4c0lLRTEJiilooAKKKKQBSUtA54oA2NIh3OZT26V0NU7GHybdR3NW65pO7PRpxtGwtJS0ySRYkLucAVJbdtRWdUG5zgVy188ck+6PpRd3b3D4/hHSqddEIW1PPrVubRBRRRWpzhRRRSAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKYFqzj8ycD05rqqwtKjyxkPbityuao9T0aEbRClooqDcqX0vk2rv7V5oTkk+9dzr8vl2ez+9XC1a2M3uFFFFMAooooAKKKKACiiigAoooHJxQBqWy7Yx71PUalVUDNL5sY71qmcTTbH0VEZ4h3phuox05ouh8jLSDc4X1NdRGNqKvoK5awl8+5CgcDmurrKbudNKDitQoooqDYKKKKAFrB118QqnrW9XM683zItNEs56iiiqEFFFFABRRRQAUUUUALRRRQAUUUUASwDMqj3r06yGLZRXmtmMzqPevT7cYhUe1ZS3N18BLRRRSICiiigAooooAKKY0iL940izRscA0rorlZJRRRTJCiiigAooooAKKKKACiiigAooooAKKKKACiiigApG+6aWg9KAOXvB8x+tUa0LwfMfrWfUy3O/DP3AoooqTcKKKKACiiigAooooASilpKACiiigAooooAKKKKACiiigD/0uopaSigYtLSUUALS0lLQAtFFFAC0UUUALRRRQAtFFFAC0UUUAFLRRQAUcegoooAaY42+8oqBrG1f7yVaoouFjLfSLVvujFVX0T+49b1LRzMXKjln0e5X7vzVVewu06oa7OinzMnkRwrRSp95SKj6etd6VU9VH5VC1rbv95afOL2ZxQYjoaeJZB0NdS+l2j9iPxqs2iQn7jYquZC5GYQuZR3p4u37itF9EkH3HFVn0m6XoM0XQWkRC8HdakF3GevFV2sLtesZqEwTr95CKegrs0RcwnvTxNEejVjkEdRRxRYOZm2HU9DTqw8+5pQzDoaOUOc3KWsUTSjoaeLqYd6Vh8yNoVItYovZh1qQag46ilYfMjcBqQVirqY7rU66lCeoxSsyuZGpS1QW/tj/FipRd2x/jFKzHdFqlqETwno4qQOp6EUhjqKTNLQBYg7mp6hh+7mpqQ2LRSUtABRRRSEFFFFABRRRTGFFFFIQUUUUAV7mEzR7AcVm/2bJ61tUVSk0RKmm7sxf7Nk/vVG9myHBNb1Z0py5qlJkulEz/s3vS/ZverVFPmZPs4lX7MPWj7OPWrVFHMx8kSt9nX1pfs61Yoouw5F2K/2dKX7OlT0UrsfKiHyEo8hPSpqKLj5UReSnpR5SelS0UXDlRF5SelHlJ6VJRSuOyI/LT0pDGgHSpajkOENJsqEU2kUT1pKWiuc9hCUUtJQMKKKKACtuzXbCPesUDJAroIV2xKPaqic2IeliSopII5R8wqWirOVNrYyZbFl5TmqRVlOGGK6So3jR+GFS4m0a76nPBATnFXIbZn6jArRW1iU5AqcADgUW7hKr/KRRwrGOKmooqjBu4UUUUCCiiigDj9etfLmE6jhutc/XoWoW4ubVk79a8+IKkqeorWLMmrMSiiiqEFFFFACUUtJQAtJS9elLsfGcUWFdDacZHK7SxxTaSgYU4MQu3tTaWgBKcrFTleKSikCdtUdhpOrBwIJzz2NdL15FeVglTkV1Gl61txDcdOxqGrG+k9tzrKKarq67kOQadUkWCiiimAHpXm+tLi+avSK8+19cXmauJhP4kYdFFFUULRRRQAUUUqqzHaoyaBpXEq5Z2U17IEjBx3Na2n6DNcEPcfKvpXZ21rDaoEiGKhyNErEFhYRWMQVR83c1oUUVAXCiiigQUUUUAFFFFABRRR9aYGRrNyILUqDy3FcIOnNbWsXP2m52D7q/wA6x62tZWMY63YKSpDDtXS20omiDdx1rma1dLZi5QdKnqarY1yQqlj2Fc5bEzTyXB7nitbVJvJtTjqxx+dUbWPyoFXvVIyk9B8ziOMuat6ZbmOLzn+89Z+w3V0sA+6PvV0eAAFHQVDd2bRXLHzYUDrSUZxTREtjkLg+ZqLn0FOPWoh811I/1FS1ojGQlJQaO9BJdQfKKrzHLAVZXhRVNzlzQCJIj81WapqcEGrlAMWopZkiXLdajnuFiHHWsl3aRtzGgEh0szzHLdPSo6KKCxCcDNJbLljIaZKeNo71aiXYgFLqVsiQmm0tJTMwooopgFFFFIAooprMq8scUALTGIAyahadm4iGfem+UznMhouO3cRpsnEYzSCJn5kP4VOFVeAKKLdwv2ECqowBS0UVRIUUlFAhaKKKAFqSP74qMc9KuwxbfmahETlZFkUtFLVnMFFLimF41+8cUrjUW9h1LUDXVuvVhURvrcdDmlzotUZvoXKikhjlGGFVjfxDoKT+0Yu4pc8S1Rmugx7N15jPHpVQgqcOMGtNb23bq2KlP2acYLCjR7FXkviRkVYjlbG2pJbGRPmiO4VVBIbDDBpD0ZYLE02lJpKokKKKSgoKKKKQBRRRQBQlXypMjoaiIw1aEqeYhHes7J6HqKzasdMJXQo4NTVDUw6UkNhRRRnJwOSad7CSb0QtFWTY3qrvMRwaakSswSU7PrS5kVyO9ifTLVrm7Xjgc13wwBgdBWdpVrBbwAxOHZuprSxWa7m83oooWikpaZmI6pIuyQAg1y+oeHwczWXB/u11NLQKx5S8UkcnlSLtOa6SNdqAe1dJe6bbX64kGG7EcVzM0F1pzbJhuj7NTQdLFlOtaMI6Vmwsr4ZTkVqxDkUpGlPc3I/uD6U+mp90U6pJYtFJRmmAtGajLelIMsaAI5bO2uPvrzVF9EiP3G21sqMCnVLSLjUktmc9/YJz/rP0qVNCiH33zW7RRyor28+5Sh060h5VcmrvAGAMfSiimZtt7la5s7e7XbOucdKzH0cx820m32rcopNJlRqyjszmwupW2Q6eYveppdYYRGLZ5RPHSt6q9yItnzID+FLk7GqxCbvKJkWKWkKG5kYPIeaZbQvqlybmb/VKeBUo06zuTgZUn3qwlrfWMey2cMo7YqOR9Tb20WnyvVl64uUhfZgBYxk1lWNkl7M95crweFH071SuGvZpt1zEwXvWp/aFtHZmNW2sRgChvUShyxtDdmffWNmZUjtvl3NgnrTLzTIbODez8j9as6daoVN5cHJU5AzSOX1e8VVH7pDk0mkzVSlF76IrR2epSQLPCcA9FpY5NStCZZYz7mutMYVVVeFX+Vcvq2otcP8AZoPur1PvSlFIKVWdWXLbQzPNMkpunPJOa111SOV0WdMRqPzNNs9LiNqZ73hTyO1H9kiSMy2soKdaSjJaoudWlN2l0JRcxapceSTtjX+lPmi/tOX7JA2IY/1rDCPg7FJxwSKltJzbzgwtj1zRztfEhOit4M29RlWztltYflLcVBo1kDI91IM4GRmsdmlu7g3E/IHQVsx+IIYFEfkkAcU4tN6inSnGnyRW+5dibTdW3h1AZCQQOOlYeq2dvZ7RbHJY421rQS6ZamS+jcZcfdqhAr3krX84wi9M+1VOz2MqHNBuTdkihcaZcxRLIRwwzVRRPb8hiprpmae4kWaQFYk6fhQzPJfLC6jYwz0qfZrobRxMl8Wpz4vJPNEs/wC8AGPStxb61vkFov7rNPu7LTmnELnY56VialpsliwdWyD3FHvRBOnVdtmdWIbbTbPBYdOvrXOxbtQm8iH7mck/SoLadbvbbXz4Tsa7C2t7a1hC2w4PeqXvGMv3N+7JAqxoI16CkpTTa1OEr3JxEaxtPG+9dvQCtS9OExVHSFzJJJ61T2RNPeTLc3N1GPetasmXm9Qe4rWNQXa0UhKKKKYiOX/VmuV1bmJB9K6qb/VmuW1PkIParWxla87Gfp7eVdMvY10i9K5lwYnimHcV0kR3qD61HWx1J80EzXjOUBpGG5Sp70yE/u6kqjnkuhxepgpaSwn+Hn8640dK73Xotodh0cYrgh3+tNkQdwooooNAqMtxtFPJwM1GOeaAHDgUtJRQAVcJ8uADuaqKMsBU8zZbaOgpDIasRcVBS7scDqaYFh5QOnWui0XQ2uGF1eD5eymn6JoRci7vBx1Cmu4ACgKowBSYxFVUUIgwBTqKKQBRS0UgCqdzfW1ouZXGewqzIGaNlU4JBArzySKRLh4pyS4PemlcR2jJcXiAs3lI3auD1OwNjelCSyt90muy0q6M9r5Tn54+PrTdUsRf2xC/6xfummgZw3FGfSkAZSVcYYdaKsk6Gy1SRo/LcbnT7tUp2luXLXL55+70rJ+YsNmc+1b9rpl1Ov7z92p9aVhmedijAq7b2l1cJhIyAe9bUdlZ2SFgMsO55q7NNFDZC4HzZOMDikFjEi0JRzPJn1FaUFlZQn91HkjvVAX8wTzoCGbONnfFX4NTWNwl5gNIMj29qTKNeHAQllAAqOK9tpZDCjfOO1Vrq63RlIT8w5x64rDUPdOl7bRsJV4YD3pWGrnY4NLVO2F7965YY9MVbpA0LTDIBT6qnrQIkMlMLsabSUwFqq/yShqs1FKuRmmnYTVyjqCbHS6XsQDWxG4kRXHcVSZBcWrRnqBUelSkwmFuqcVMtJXLh71Oz6GLrMHkXQlH3ZOT9agspfLl2N0auj1W2+02ZA+8vI/CuPViQG7itGuaJhTlySOk08+VM9ufwp0X+jakV6LJ/SqAmKmK5H41pagAEguV7Vle6udUlr6mjqgAs8ntWbpRMTtbv6ZH41bvpfPjit05L9ajvE+zTQTL64b8KdyIbWZp47GuU1CM21yHHrmur6gH15rJ1iHzLfeOo61UexjLTUhRg6hx3Gar3kInt2XuORUOny74th6ir9WjKSszlIydu1uo4NSA4ORUt7F5FzuH3X5/GoqpCfc2YXDoGrKuW3SNU9tLsyp6VUf5jn1ND2FFe8jatRtt1FT02MbUAp1JBLcWo5W2xM3oKfVW+fZav7igS3KemXIdWiY85JFa9cZG7RkOnBHNdPZ3aXKejDqKlM1qQ6ot1iSiS3vMRttWTn8TW3VK+h82DK/eXkfhTaM6crMX7PL/ABPmnfZAfvE0+wnE1upb7wGD9auZFI6bFMWkYp4tYh2qxuUd6aZYx3oDQaII/SnCKMdqTzkpPtMY6mgNCURr6CnCNfQVX+2Q/wB6l+2J2GaQFsIPanBR7VUF2T0jJp32tx0iNIZfjjHU4qcKo7CswXkv/PFqeL2X/ni1IDSCgdhS4HoKzRfTf88Wo+3yD/lkaLBc1MD0FLx7Vlf2g/8AzyNH9osP+WZosFzVorL/ALRP/PM0f2j/ALBosFzUorL/ALS/2DSf2l/sGjlYXNWkrL/tL/YNJ/aR/uGjlYXNWkrKGouTxGaP7QlJwIzRysOY1qSsv7bcHpGaX7TdkcRmiwcxqpT6hty5jBk4NTUhsKp3f8B96uVUvPuA+lNC6otjoPpS0i/cH0palDYUUUUwCkf7jfQ0tBGQR6igTOKk/wBY31plasul3BkLLyCai/sy69K6VJHnunLsZ9FX/wCzbr+6aP7Nuv7po5kHs5dihS1e/s26/u0v9m3X92nzIPZy7FCkrQ/sy69KX+y7mlzoPZS7GfRWl/ZVzSjSZ/WjnQeyl2MyitYaRN3YU8aO/dxRzofsZGNVuxh864VewrSGjD+J6v2tlHa5Kck1EproaQoO92XBwMUUUVidgtc7qV0XfyV6Cuirk72NY5yAc5rSktTlxLaiVKKKK6TzwooooAKKKKQwooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoopyDc4X1NDGld2Oi06PZBn15q/UcK7IlX0FSVyN6nrRVlYWiiikM5DxJLl0iHbNcvWtrUvm3zD0rJrUyQUUUUDCiiigAopKWgAooooAKKSloAXJ9aKSlw3pQAUU4I56KaeIJj0Q0AbOhR5lZ/auorF0iMwQkycEmtfzF9aksfRUfmJ60ealAiSiovNSjzlosBNXIa0+brb6V1PnJXM39pPcXTSoODTQmYlFX/7NufSl/sy49KYjPorSGl3FOGlTetAGXRWsNJl/vUv9kyf3xQBkUVs/2Q398U4aOf74oAxKWtwaOO704aOndqAMGiuhGjxd2pw0eD1oAytPGblfrXpkYxGo9q5ey0mGOUOp5rqxwAKye5tf3UgooopEhRRRQAVDO+xeOpqaqt0DgGplsXBalM88miiiuc6i7byFvlParNUrYfMTV2uiGxyzWoUUUVRAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUtAHN3o+ZvrWZWtej5mrJqZHZhn7oUUUVJ0hRRRQMKKSloAKKKKACikyKTcKdmS5pbsWimFxSbzT5GZuvBdSSiotzGm81Xs2ZvEroibIpN61FRVezRm8TLoP8AM9qTe1NoquRGbrTfU//T6iiiigYtFFFAC0tJS0ALRRRQAtFJS0ALRSUtAC0UlLQAtFFFAC0lFLSAKKKKAClpKWgYUUUUALRRRQIKKKWgYUUUUAFLk0lFAgpNqnqB+VLRQMjaGFuqj8qgawtX6rVuii4rGa2kWbdiPxqBtEgP3Tj8a2qKd2LlRz7aGf4XFQNotwPusDXT0tPmYuRHINpV2O2ahbT7xf8Alma7XJozRzsXIjhDa3K9YzUZilHVSK7/AAPQU0pGeqj8qfOHIcBgjqKK7wwQN1QflUZsrVuqUc4uQ4fJ7E04O46MfzrsTptof4TUR0izPY/nT50HIzlhNKOjGpVvLgEAGugOi2p6Z/Om/wBiQZyCfzo5kHKzStCTbqW6mrFMjQRoEHQU+szUKKKKBC0UlLSAKKKKACiiigYUUUUwCiiigAooooAQ9KzX5Y1ot901nHrTRMhtFLRTJEooooAKKKKACiiigApKdSUDEopaKAEopaSkAlQzH5cVPVWc9BUyehvQjeZWopaKxPSEopcUYoASilxRigB8K7pAPeugAwMVk2ceZN3pWvVxOOu7uwlLRRVGAUUUUAFFFFABRRRQAlLSUtAgoopKADrxXD6za+RdF1HytXcVmata/abUkfeXpVRepMlocHRR04orUzCinKjP0q0luq8tzTSuZzqKJWWNn6CrC2wHLc1ZAA6UVaic0qzY0Iq9BS4FLRVWMrspTRbfmXpVaup063SYt5gyKkn0OByWiOPrWMmrnfTTcbs5KityTQ51+4wNVW0q8X+HNK5ZnUVcOn3Y6oaQWF0eAhoAqUZxWvDot7KeRt+tblp4fhiIec7j7Um0UrlPQ2vS/fy/euupiIka7UAA9qfWZbdwooooEFcH4iH+kg13lcL4k4nBNXAxqfEjnKWnRxySnEalj7VrW+h30+CRsHvVXLsY1SRxySnbGpY+1dnbeGoEw1w24+1b0NnbW4xGg+uKnmHynF2nh+6nw03yD3rq7PSbSzHyrk+p5rSoqblC9OBRRRQAUUUUAFFFFIQUUUUAFFFFABVHUJ/s9qzdyMCr1YOu5MCgdM1cFqZ1HZHJkkguerc1BUsh7UsED3D7V6dzWjCKEhied9iD8a6W2t0t02r17mkggS3XanXuamJwCfSoKk9LIwdTbz7uO3HQdfwqSVxFGWqrAfOu5Zj2OBUhU3V0sA+6OtNuyFCPNJI0dLtzHEZ3+89aVGAoCjoKSpRpN3YU1zhCadUUxxCx9qpbmctjkoeXkb/aNS1Db9HP+0alrRGD3EoHUUUq/eFMRd6CqJ5YmrjnCmqQoAdT5bkRx8ctVeSQIPeqZJJyaTGkBJY7m60lFFBQUUUjHCk0AkRD95N7Cr1VbdcKXPepWljXqaSHLVklFQecT91SaX9+3Tj60XFYmpCQOpqPyXP3m/KnCBB1Jp3CwhljHemmb+6CamEaDtS4A6CgNCtmd/8AZHvSiBerHJqc0hosK4zAHSkNKaaTTEFJRSUCFpKKKYgooooAKcAScChVLVIZ4oBx8zUm0txavSJahgx8xqR5oY/vMKymluZ+p2LUsGnyXDYRWkPqOlZurbY1hg29ZsmbUEHEYzVc3l05wgx+FdTZ+GGbD3JAHoODXSW+lWVsvypn3bms3OTOiNGlHpc84isdTuzhVbHrWzB4VuXG6eTHtXehEUfuwAPasbUbLUJxm1lC+xpW7l3tsjKXw5pkAzcP+tPMPh+3GNwOPeuR1C21W3c/aS5HqCcVlZJ6kmnyk853b3uiJwqBvpiqb3ujtx5dczA6g4YVed4EGcCnyE+08i48WjTfdyh+tVm0wHm0lD+w60+30+7vf9TEQvqRxU02hanajzI8t/u0rFc3dGcTe2jYYEfWpftyScTp+I4qRNSkiPk3iZHfI5q2ttp16M27eWx7MarmaIcIPoUQ0LfcYfSgjFLc6XcwcshK/wB4dKpDzU+4c+xq1UMnQ7Fqiq/nsP8AWIR71KskbfdNWpJmTg0PooopkhRRSUALVG5Ta28dD1q9THUOhU0pK6KhKzM2pV6VCMglT2qRazR0sfWzoln9on81x8q1i4LEKOprvtOtxbWqr3YZNRLsa01ZORpbh0wMVDJb204xIg/AU6loJM19GjU77SQofQk4pnmapacSL5yj+6K1hTw5HFFguzOh1K3kO2Q+W3oa0AQwyvIqOW2trgYlQA+o4NUTp09v89lJx6NzS1HdM06WspdQkiOy8jK/7XatGKWKYbomDfSncOUkpSqSKUlAYH1oooJMG60WSFjcaefcqeaitbwGQRTjy3B6GunVivSobmxtb5cSDa3ZhwaGVGTROv3RilrlruLVtNXEJ8yMdO5rI/tjUDxuH0pWYNo9AphzXnx1e/7sKZ/bF+P4hRZhePc9DCk9qmVcCvOF1zUF6MKtx+Jr5PvgH8KLMenc9AorkYfFSNxMh/CtmDWrCf8AjCn3pXHyPoa1FMR0kGY2DfSn4IoJtbcKKKKACiiigAqvc8pVioLj7lNCZStuJK16x4DiStikU9hCAeozUEttBMNrqPwFT0UCTa2MiXR4mGImZfxOKfZw3OmoY1xICc8DmtSlpcqNHUk1Zmbe38n2V1WNlY8c1ztg0aEGbqOTnvXZkK33gD9arSWlvKMMoH0qZQubUq6jFxsZUzzamwix5cIqe4nEMaabZ/eIwfpSPpKqM28jA+54qn9mv7WQy4EhPoKn3kXFwezNSQw6ZZBFAZz681UlijGntcXIAZvTiqMt15sqfa42CrUjXMN/MBK4WJei9zScr6FRptaktlpEE9qsgLB2z1NRT6XNE4jSVCT0BFbtxIsViWteuOKx4Hlih8+bmWTgA05KPYUJ1N7mC8LvMYypOzrjpVxb2QbUdhsX+EVoyRNZ2TAjMs2efrV1ba0tbNTcAfNySahR10N5V1b3lcqTarHcxJBGNvatJoiYUkjILr6VnLpUF188BwPWsaWW4tpCFc/IcflVOTW5nGlCppTNmOxu7u48+7G3acis3Vbtp5jCDlU4qOXVL6VAHO0Edqzu+TUSlfRHRSotS5pMStfT9UktSI5PmSsimmknbY3nTU1aR6HHc28ygxuDntUuK83WSRDlGIq5HqV3H0bP1rVVe5wywH8rOo1BsKfajSU22u49ya546hLcIwl611logS2RfWtuZO1jz3SlSUoy3K55v1/CtY9ayU5v/wAq1j1pA9kJRRRQSRTf6s1yuonLL9K6qb/VmuRvjmVR7VX2TOP8VDp4DLpysvVeat6dLvhU+nFWLNQ9tsPpisuxPkXUlsemeKUtlI2pO05UzqYuBipKjj+4DT6CHuZWrxebakjqK8wPDEe9eu3Choyp715XfRGC7dD9aroYx0m0VqKKKDUjc9qXpTRy2adQAUUUUASR8Nu9KTOeTSZwMetNzQA4nsOtdjoOh5xeXY/3VNRaDonmEXd0OP4RXdAAAAcAVLZQoAAwOAKWiikAUUUUALRRRQAVh6vp32lRcw8SIPzFblFF7AcHY3fkXSy9FPDCuzGM5XpXMavp5t5vtEY/dvyfb0ra0p3ms1LDkd6p9xdDG1fR5JpPtFoOW+8Kgt/D0zDddNtHp3rrmcIu5jtHqaqXF2IoGni/e49KLgkQ29hbWkZMSge7c1BFeWxu1tvMBLGs97ia6tkuFOSrEso9PSqjqlyouoRyDhgOoxTuOxdubm+iuni+Vk7DHJoSUXULW+wxuvzAHvVeO7YKVm+8Blc9TV2O2urt0ljXytvOW71LA597RZ9ziTy3Q4YZq81tJcpEtvGzYAy3Xmunh0i2jkM7jLtyfTNaaqiDCAKPai5Rk2mmBAsl026ReOOPzrXVUTOxQM9cUUUhAaSkfft/d9fekQsw+YYIoAfVdxhqsVFKO9AiGkoopgFIeRilpKAIojslwehqo3+iahnosnNWpOGDCm6hF5tsJV6qQaJaxHTfLO3c0cAgqehGK4i8g+y3bxfwt0rrrObzoFfvWfrVp50QmT7yVUGZVo2dzDhbMTRN+FajTLJpRDHlaxIpAqFm4pIJTIRCc7M1Mo2vY3pS5lZnUaVExX7TL949Ae2Ku3qeZbPnsKsrgIoAwMUyXb5TZ9KViE/eGW7b4VPpxROgkhZD3GKisgRBz6mrVC7hUV7o4e2cwXLIexxW7WJfR+TqDD1Ga1LeTzIh6jitepzvWKZDfQedAcdV5/KsJG3KCeveupIyMHvXNzx+TcsnZjkU0QNpUG5wKbVi2G6dRRLYunubVFFFBkFZGrybIVT+8a16oiOO61ERSDKpg0my6auzmARinRzNE4eM4IrvTY2OfuU06fYsPuVNzoMizv47kbW4cVfx2NVbnRo/9ZaEqw5qK1umJ8i4G1x+tNMwnHqimwNvdmPosmWq2CDwMk/Wn38JaLzF+8hz+ArXsJYbq1WVVG7HNDdjWHvRuZiQTN0Q1YWxnYckD61sZx0ozU3KsZq6Yv8Ay0bP0qdNPtl5wT+NXOaMgdaVx2RGtvCvRRUoSMdFH5UwyxL95gKjN5bD+MUh6FnC+g/Kl49BVJtQth0YGmHUYB0yaLCuaXHoKOPQVnf2lD6Gj+0ov7pp2A0uPQUYHoKzTqUX900v9pQ+hosBo4X0FG1fQflWeNSgp39o2460WC5e2r/dH5UbU/uj8qpDUID0p32+A8UWYFvan90flSbE/uj8qrfbYaX7ZDnGDRZhcsbE/uj8qTan90flVf7bFR9shoswuiztX0H5UYX0H5VV+2w5xR9siziizHctYHoKD0qr9shoN3E2FHU0WYJl1elOpF6ClpDYVVvP9VVqq91zCaaIkTR8xr9KfUUJzEKlqUXLcKKKKYgooooAKM0UUAHNHNFFAgzRmiigAzRmiigBc0lFFABRRRQAUUUUALVa4uUtly35VZrC1SaNyEXqKqCuzKtPljdEE+oyy8LwKzySxyTmkorqSS2PNlJvcSiiimSFFFFAwooooAKKKKBBRRRQAUUYoxSGFFFJlfWgBaKaXQfxCm+dEP4hRcfK+xJRUJuIR/FTDdwildD5JdizRVM3sY7Ui3yswUDrRzIpUZPoXaOtaKRwqASCTVgTKv3UH4ipczVYd9WZSwyt91SanSxun/gIrQ+1Sdgo/CkN1Me9Tzs0VCPUjTSJm5ZgKuRaXFEwd2yRVQzynqxpvmSH+I1LbZrGEFsjeyo70m9B3rB3v6mky3qanlNOY3vNj/vCmPPEEOGGcViZPrSUcornN3FpdTTtJtPJpg065PaunoqhHNjS7g96eNJmPVhXQ0lAGENIfu4p40j1YVtUUAZA0iPu1PGkwDqTWpRQBnDS7Yev508adajsavUUAVRY2w/hp4tYB/DU9LQBEIIR0UU7y4x/CPyp9FADdqf3R+VLgegpaKAFooooAWkoooAKKKKACiiigAoopaAEopaKACiiigApaKKACloxTwuaQCAVKqE09IiTWjDbgctWbn2NFDuFtDtG41bo6UUhthRRRQIKKKKACkZQ42mlooGUHt3U/LyKFt3J54rQoqORF+0YxECDAp1FFWQFFFFAgooooAKKKKACiiigAooooAKKKKACiiigApaSloAwr4fOaxTW7fjDn6Vz7PzQ4t7G+HqKKaY+iot5pMmhU2avEx6EuRSbhUVFV7MzeJfREm8U3eabRT5EQ682LuakyaKKqyM3OT6iUUtFMkSiiigAooooAKKKKACiiigD/9Tp6WkpaBi0UlLQAtLTaWgBaWkpaAClpKWgApaSloAKWkooAWlpKWgApaSlpAFFFFMApaSikMWiiigBaKSloAKKKKAClpKWgAooooAKKKKACiiigQtFFFAwooooEFLSUUALRSUtABRRRQAUUUUALRSUtABRRRQAUUUUAFLSUUALRRRSAKKKKACiiimMKKKKACiiigBj/cNZ9X5PuVSxTRLG0lOxRTENopaKAEopaKAEopaKAEopaKQCUlOpDx1ouNK4lFMMgHSozITUuSNo0ZMmJqrINxp+SaSocrnRTp8mpDtNLtqWkqDe5Hso2VJShWPQUC5iPaKNvYVaW3kb2q3HbqnJ5NNIzlVSC2i8tOepqxRRWiOVu7uLRRRQIKKKKBBRRRQAUlLRQAlLSUUALSUZooAKDyCD3oqKaaOBd0hxTSvsJtLVnFatZNbXBZR8rdKpxwE8vW1fXpujtA+UdKoV0xj3PPqVr6REAC8CloorQwCiiigAoopyjcwHrSBHQ6cmy3z681fqOFdkKr6CpK5XuepFWVgp6ozUIuTVmkURCBe5NSBEXoKWigB2aSkpaACikJA5NIrbulIB9FJRQAtY1zawXN0vnDIFbNZrc3QqlsRa8kXI7W3iH7uNRj2qxSUVJYUUUUwCiiigBaKKKQBRRRQIKKKKYwooopCCiiimMKz9SgM9uQOo5rQpDyCKE9SJq6POY7WWaXZjAHU10EUKQLtQVdmVVY7Riq9aMFsFV7uTyrZm9sfnVisnVnPlLEOrEGhCkUrf9xabz1atPS7cpGbh/vPzVHyjPKlsvReT+FdBgKAo6CluzSK5Y+oUUUUEhUFz/wAe7/Sp6guOYH+lNbky2OStvuN/vGpqht/uN/vGpq0RgxKVfvCm05fvCmIsTHCVSZwi5NWbpgq1llixyaQ0gJLHJpKKKCgpKDx1qPzCx2xjJpXGlckJA5PFQOxkGyMZqwlqzfNMfwFWlVUGFGKW49EVEt5CoDnA9qnWCNe2frU1JTsK4mAOgxRRRTEJRRRQIKbS0wnFAATTC1NLZptMkXNJRRQAUUUUCCiikJCjLcUALTWdU+9+VRlnf7gwPU0+GBpX2xAyMfxrOVS2xtCk3uNzLL/sLV2z0+a4bECFv9o9K6bT/D3AlvT/AMBFdRHHFbR4QCNB36Vi3fc6klHYwLLw4iYe7bJ9BW+sVvapwFjUd+lYWoeJbW0zHb/vH9eori7zVb2+YmRyAew6VSRDmdvf+JbO0ykP7xvUciuRvNd1C843bF9uKxgoHNOqrEXNvTteurJtspLoeueTXd2WoW98gaFhn0715VUkMslu++Fip9qlrsWpdGeuyRpKNkqhh71gXnhmyuSXiyjfpVDTfEqnEN6MHpuFdbFLHOu+Fgw9qXMNo4GTwneq3yOuK3NN8ORW37y7PmN6dq6bJop3JshFCoNsYCj2pwJpKKAM690iyv1w67W9RXE3/h+9sWMkPzr229a9Hpc8YPIouKx5bb6xeWp2SfOB1D81rLcaRqIxMpif1HArp73RbG9ByuxvVa46+8N3ltlofnX25NO4aoll0KcjfaSLKvp1NYk9nJCcTxsh9RxQlxfWLcF0x26VrReIWZdl5Grj16miwcxggyJ9xgR6GpFn7OMVvN/Y14MqTEx/CqM2kvjNvIsg9M5NUm1sJxiyqGVvunNOqnJDNAfnVlpFuGH3uatT7mMqLWxdoqNJUfoakq7mLTW5SuU2sHFRLWg6h1Kms4ZUlT2rOSszopu6saulW/2i7HovNd104HasHQoPLgMp6txW7WS7nTPS0R1FJTqZAtLSUtAC04EjpTaWgBzbZBtlUMPes2XSl3eZZuUb0zxWjSigE7GOL66tG8u+jJH95RxWpDPDcDdCwb2FWCUkXZKAw96yrjR8HztPfY3Xb0FIq6e5qYpwrBi1SW3fyNRQqem4DityJ45l3xMGHtRcHEmDcYPI9Kyb7RLW8y8fyP7cCtWlFMg85vNOubJiJl3L/eHSqGyNxlTXqzBZF2SAMD2NczqHhyOXMtkdrenaqUiHHscSyFaZVi4gntX2XCke/aq/WmAlJjHIpaKBrQtQ313bnMbn866C08UTJhblcj1FcrRUuKLVR9T1C11eyuvuttPoa0xyMryK8cXKnKnBrYs9bvLQgE7l96lpotOMvI9LorFstctLsAMdje9bIwRuXkUkwlFrcWoZ/wDVmpaZLyhpoh7GZGcSVsDkVhg4krajOUBpPcv7I+iiigkKWkpaACkpaSgApaSigBrJG/31B+tUptNtJuq7fpxV+ikylJrZmG+kTL/x7ynHoxqrIt/DMsky7wnTaK6ejNS4Jm0cRJb6nLTXxmuEknUqqc4NXmvbO/ia0lYAEcGtZ4on++gP1qjLpNpKc42f7tJQa2L9rB7qwsZj0zTyiNuPauciia5uUTrlt7fStWXRpicRSZUdmNXrDTxZ5dzuc8fhS5W2rlKrCEHyu7ZbltraVDEUAGMZxXIXunTWbZA3J2NdoOTTmRXXa4yPernFMxoV5U35HnHXpVuyS0lkMd0SuehrobvRIpMvAdp9O1YM2l3cR5Xd9Kx5Wnc9JV6dRWvY2F8PW0vzRyZH1qQeHYFPLH86wIk1ONgsQce1djEZo7VTOfnxzWsEn0OLESnTWk7mFcWVvb4ji5JPeulRdsaj0FYDfPdxofWuj6DHtVpK5ySb5Lsy4Ob0mtc1lW3/AB9sa1KS3Y5bIKSlpDTJIpv9Wa5G7/1y/Sutm/1Zrkbv/XKKr7Jkv4qNrTeYyKzNRX7PexzjoetaWmH5W+tR61Fvtdw6qRSWsbG1R8tXmNSBg0YIqWsnSZxNbD1Fap4GaSHVVmyOc4jNcF4gt8Mtyo69a7GeQsKyr6AXNq0ffHFapHK9+Y4GkY4FLgoxQ9QcUxuTipNQHSloooGFFFHSgBSa6XQtGN04upx8g6D1qjo+lvqE25+I1616XDGkSCOMYUVLZSRIqqqhVGAKdS0UgCiiigAooooAKWiigAprEKpZuAKdWLcWWoTys28BB2zQBYNwb5PLhT933ZulZl3ctYp9khGSehFSwTShWWID93xtHSs+7luw3nzqgHfHUUxkNxuUwyQOzpJncCc09ZPLudkGQH6qaieDLo9gGb/0EVrzaPcXIScsEkH92gDNRZoWd7RdwP3k9Kfb6VdSu5TMSSDkHg10VhZCyUknLtwTV8kmi4GfbabbW6jI3tjq3NX+2OwopakAooooASiiimAUUUUAFMcZFPpKAKlFOYYNMpkhRRSUDEYZGKfDh4mib6U2kU7JA3rTRElpcp2P7id7Q+vFazKHQqe4rKv1MNzHdL06H8a1VYHkd6haGtXVKXcwLfRozKzTN8ueBWnNZQ/Z8RKAV9KsMQr4PQ08OI+W6VXqRfTQiSUG2yOo4xUS28kw3THHsKbbJulaQfc7Cr9Iq9thqqFG0dqdRRQScprybJY5fUgVDaSbZNvY1f8AEK/uEb0bNYcbEBXH1rXzMYdUdCaytUizGJl6rxWij70DetNlUSRsh7ihGb0OeByMirliuZC3pVFAU3RnqvFalguIy3rTfQtaJl+lpKKDICQoLHtVbS+BLeN3yPyNMvpCsJRercCrUkf2bTvLXqwz+dS+xvSVk2EF1LIzFyAByKlS6mYF2A2j0qiIcpsz8zKMVcgidojABg9yaGaLVGnGwdQw71n3tityu5OJByDWhBEY4wh7CptuKi4NHNQzMSba4GHHH1qGzn+wXpib/VvzWzf2Szp5icSL0Nc7NmdMPxKnWq3RnF8ktdjo3vJd5WKIn3xxShtQfoFX6iodIvfPi8h/vrW3U3NnFp6mWLW8f/WOB9KBpmeXkb8DWrRii4rFBdOtx1LH61KtnbL/AAg/WrPFGVHU0h6EX2eAdI1/KnCGH+4v5U7fGOrCkMsSjJYYoAPKi/uL+VL5Uf8AcX8qb58P94UhuIAMlhimA/y4/wC4v5UnlRf3F/Km/aIDwG60faIM43cigQvkQ/3B+VIbeA9UH5Un2mDswpftEHXcKAD7PB/dFN+ywf3af58J/iFL5sX94UAQ/ZIPQ0n2ODOeaseZH2YUeZH/AHhSuwsV/scPvSGygPrxVjzI/wC8KPNi/vCjUZX+xQZzzS/YoQc81N5sQ6sKQzxD+IUahoRfY4RnrzSC1iUgrUpuYOm4ULcRO21Tk09Rq1yyOlLRRSEFQ3AzC1TVHMMxMPamtyZbCWxzEKmqtaHMX41ZpFy3CiiigQUUUUAFFFFABRRzS4NACUUYpCyjqQKBC0VEZ4F6yKPxqJr60XrIv50BctUVmvq9in8WfpVZtfsl6BjTsK5t0VzbeI4B91DVZvEj/wACD8aOVhzI62lwa4lvEV2fuqtVX1y+fuB9KfKLmO4upPJhZ+9cqxLMWPesaTULuXh3P51XM8p/iNaQdjnqwc2b2RSFlHcVgGWQ9WNN3ue5q+cy+r+ZvmSP+8KaZoh/EKwsn1pKXOV9XXc3DcRDvTTdQjvWLRRzsfsImubyL3ppvo+wNZVFLmY/YRNI347Cmm/bsKz6KOZleyj2LpvpOwFNN5KfSqlFK7H7OPYsG6mPemGeU96ioouVyrsP82Q/xGk3ue5ptFIdkLk+tJRRQAUUUUAFSR/6wVHT4/vigqO52I+6PpS0i/dH0paBMKKKKACkpaKAEopaKAEopaKAEopaKAEopaKAEooooAKKKKACiiigAopaKAEopaKAEpaKKACilooASilooASloooAKKWigBKKXFLigBtLTgpNOEZNJtDsyOlxVhYSanW2Y9qlzRSgyiFJqQRk1ppaetWFgRannfQfKluZaQE1cjtfWrwVR0FLU6vcq9tiNI0TpUlJRTEFFFFAgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAydQHzfhXNN9411GoDv7VzEn3zWkSOoyiiiqGFFFFABRRRQAUUUUAFFFFACUUUUAFFFFAwooooEFFFFAH//1enpaSigBaWkpaBhS0lLQAtLSUUALS0lFAC0tJRQAtFFFAC0UUUALRRRQAtFFFIAooooGLRSUtABS0lFAC0UlLQIKKKKBi0UUUAFFFFABRRRQAUUUUCFooooGFFFFAgpaSigBaKKKACiiigAooooAKWkpaACiiigAooooAWikpaQBRRRQAUUUUxhRRRQAUUUUAIRkYqIxDtU1FAFUxGm+WauUlIRT2Gm7DV7ApNophYpbTSbau7RSbBRcLFPbSYq5sFHlilcdinijFW/LFN8ui4WKbEKMmqrOWqW4Pz49Kr1nJnbSppK4tFJRUmwtSKjN0FMArViAVBiqSuZVJ8pUW2c9alFqvc1boqrHO6kmQrBGO1ShVHQUtFMm7FopKKBC0lFFABS0lFAC0UlFAhaSiigBaSiigYU3r0oY4GKAcDFMQ4AUtMyahubhbeIu3XtTSuTKSSuxl3eR2qc8t2FcrPcSXD7nP4U2aZ53MjnrUddMY2PNqVHJhRRRVmYUUUUCCiiigYVYtU3zqPxqvWppceZS57VE3oaUleSN48cUUGkHWuY9IsoMCn0inilpALRRRTAKKKKADAPWlpKWgAooooAWs3rdVpVmj/j6o6CXxI06KKKQwooopgFFFFAC0UlLSAKKKKBBRRRQAUUUUxhRRRQAUUUUhGRcfeqrVy5HzVTrQiOwCsC8kEl7g9EBH41vMdqlvQZrl4Fa5u2A/ibNDdkNLmkkbunQ7IzO33n5q7T8BQFHQUykjSTuwpKKKZAUyUZjYe1PpDyMU0J7HHQcbx/tGpKYBtuJE9yafWiOdhSrwwNJTHbauaAIrqXzHwOgqvR70EgDJpFWCo2kAOF5NCiSc7Y+B61eigSIep9aVy+W25VS2eT5pTgelXFRIxhBink02hITYU2lpKokKKKSgApKWkoAKSlqNmxQIGbFQk5pCc0UyQopKWgAooooAKKRmVRk0ixvMNzfKgpNjt3E3knbGMn17U0hVb5vmP6U/lv3cIwvTjqa6fStC3YmuhgdhWMpdDphS6sybDSrm/YfwJ6niu5s9PtrBNsKgnuxouLuz06L94QAOiiuL1HxBc3eY7f92ntUqLNHNLRHV3+uWtiNud8ntyK4i/1m8vmO5iq+g6VlHJO48mmmrtYybuPQNIdsalifSt228PX9wNxAUe9aGianpkQWHyv3p74ru1OVB6ZpXKUTgT4Tu8fK4z9ayrnRtStD8ybh/s816two3McD1Nczq3iWG1UwW3zv69qQNI89Ysh2uCp9DTd1E0slxK00nVqjqyR55q7Z6ndWDgxMSv92ls9MvL1sRLgep4rtNP8NQW+Huvnb07VLKjoaemaj/aEO9kKsOvHFadIkcca7Y1AHtT8etSNjaWgDPTmjB7UBYKUjAyxx9azL6XUo4ybWNT9TXn19qGsFys5dfp0piZ6XJd2kX35F/Os6bxBp9uPvFvpXl5aWX7xLGrENlczHCRt+RosFzo7/wARWdxkR26tnuwrlZHV2L4C57Cugt/DN/Ny4Cj61u23hO3TBnck+lFxWPPgN33Rn6VdtrbUiwNurr+FeoQ6Tp9uPliXPqatGa1gGNyr+NFx2OMt7DVpV23CKw/2utE3hqWT5kAU+1dNLrVjF1Yn6c1nyeJrdfuKx+oouOxx9xoGoW/IXP0rO/0q3OJEYfUV27eKm/hi/MVQn1w3PDQIfrTTaE0nuc4k6NweDTJYtzqV/iOKs3HlzHOxU/3agixFIrElgpzVczas0ZxjGMr3O6tkEVuij0FWKwF15AAPL6D0qUeIIx/yyP5VNn2NHOLe5uAGnbT6ViDxGnaE/kad/wAJIP8AnifyNFn2FzR7m3tNGDWKPEg7xH8jUg8SW/8AHGw/Ciz7BzR7mvRVCPXNOl4O5fqMVoRy2s4zFIPzpXKt2ClqUxEdKZtNO4gp6sQeKbiloEPlit7tPLnUN9awJtNu9OYz2DFk7oa3hU6MRSGm1sY9lq0N1+7l/dyDqDxWvis++0m3vRvT5JOzCsyK9u9McQXylk7OOaWxekjosUU2KWKdA8TAg1JimmQ1YrXNpBeJsnUH3ri9S8OzW+ZbX509O9d5S5p3FY8dYFDtcYPoaSvTL/RrS+BJGx/UVxF/o13YHONyeo5p3FYyqKM0UxBS0AE1IqUxDVBzleDW3Zazd2ZAc7096zVUCnYpOKZUajieh2Wq2t6o2na3oa0WHymvKQGjbfEcEeldJp3iBoyIbzkdN1Q00a+7Ne6a8nD1qWrZTFZsrRy/vIjkGrFnJhtppS7ijs4s1KKKKBBS0lFIBaKKKACiiimAUUUUgCiiigAooopgFIaWlUd6ABVxTqKKQCUuaKKYDWbbyBzVCZi3WrUhqjKeaqJnPYp2679Qz/drfNYmmDfNJLW0albm1RWil5Gfa/8AH05rTrNtf+Ph60qSCXQSkPSlqNzhapEMjlPyVyV5/wAfArqnOY65S9/4+RTfwszX8SJsaX91/rVrURm2aqmldH+tWtQ/492pxNMT8Rz2kTGC4aFujdK648rXF3EbRLHdJ1XrXV2c6zwhx3FRszV+9G5VmHyGqwNX5VyGFZoNao5bW0OQ1i2+z3PmqPlasgetdzqNsLq2ZB94ciuGwUJRuoOKTKi+gtFFFIoKt2NlJfTiJBx3NVo43lcRoMknFekaZYx6ZbAt/rGHNS2aQj1Zet7eKxhWGMVoIMDmqUAMzb26VfqUOTCiiimQFFFFABRRRQAUUUUALRSUUAZMmlHzGkhkZC5yQKdHpMSktLIz56g9K1KKBjY4ooV2RKFHoKkpOnWk3DtQAtNyx6U7nuKXmgBAoHPWloo5oAKSl+tNLoOrAfjQAtFMM0I/5aL+dJ50H/PRfzFAElFM8yI9HU/jTgQehBoAKKXBpKAIpF71BVvrxUDoRyKBEVFFFMQU1hkUtFACTp59oy915/Ko9PlMlsueo61NEdr4PRutULYi3v5LbsxyKUu5UNU4GlOB5e89qp+Z9scRpkIOp9avSLvjZDUVht+zgAYxTZMXZtFoKFG1eAKWiigQUUUUhmJr65s8+lcvEcxL9K67WhmyauOgOYhWq2RzrRs2LKXIMZq/WDE+yQNW6CCMihDmupgXy+TcEj/lpWpbJsgVTUOoxhkWT+6RVwEMoK9KOpN/cFooqKaQRRlzTJIEX7VfBB92Pk10ZiVxhhkVmaPbMsRnb7z1pGGZurY+lZt6nZa2goihU7toBpd0Y/iAqP7Hn70jGn/Y4e/NSAhuIV/iFRNdxD1P0qwLS2H8AqQRRLyFAoCxnm+jHSNz+FYd8klzJ59vGykdeOtdeMDoKdnPFCE4p6M4CKVoJhcx8EHkV1D30zIr26bt1ZGq2f2WUzL/AKt+vtUukXvkyfZpD8p6GqfccLyTg90XlfU35wop3k6kx5YD6Gtg0lK5KRkfYbxjlpMfjTjpsrHLSt+datFK47GZ/Zg7yNTjpkZGC7Vo0UDsUBp8Q6saDpsBG3JxnNX6KdwKI0+ENuyeKadNiJJDH5q0KKLiM3+zIgpUE8006YmzZuOK06Si4Gb/AGcA2QenSk+wP83PWtOkouBliwlVCu7r3pTYy4UbuladJTuFjONjIX37u2KaNPfqWrT5oouwsZg05iPmbmnDTwO9aPNLii7CyKH2CM/eNWIbZInytT4pyD5qVxxSJqKKKQBTX5QinUh5BpomWxVsTmMj3NXCQOtc5cSTRWrtCSCCelc6by9frI1CjccpWPQjLEOrqPxqJry1XrIv5156Xnb7xY0zy5D/AAsfwquUnmO7fVrJOrZ+lVW1+zXoGNcgIJT0jb8jUgtLk9EP5UcqFzM6JvEUf8CfnUDeIpf4UFZI0+8bolSjSb5v4RTsguy02v3Z6Koqs2sXzd8fSpF0O+bsPzqZfD92fvEfnRoFpGa2oXrdZGH41Ebm5b70jVuL4ck/iepl8Oj+JzRdBys5gu5+8xNMOO9devh6AdXNTLoVoOpJo5kHKziflpOK71dHsl/hzWDr9rBbhBCoXPpQpA4nPEik3CmUnemKxJSUUUAFFFFAgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKfH98Uynp98UFR3OxX7g+lLTU+4PpT6BMSilooASilooASilooASilooASilooASilooASilooASilooASilooASloooAKKXFFACUU7FRzEomRSbKjHmdhSyjqaAQehrOPPWlVmXkVHOdbwytozRpcUsAMq7hVtbdj2p86OVwadipil2mtFbRj2qdbT1qecfIZIQ08RE1sC2QdalESDtRzMdkY627HtU62jHtWoAB0FLS1DQorZ461MtugqeilYdxojQdBT+lJRTELRRSUCFpKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAC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HH40gF+2dx20WHc0cio5AkiFHIwRVIW10/LzfpT1sIycyMWpCepx00Rtblom6Zrp9KuwYTHKeUqprlqoiWaMfd61jW05jcP/AHetVLuFF70mdgb+3zgE/lR9uh/yKqrtdRInQ08Bj2osha9Sb7dDR9thqPY3pS+U392gB/26Kj7bFTPJb+7R5Lf3aNBj/tsVJ9tipvkt6UeS392gB322Kk+2x03yW/u0hjYdqBD/ALanpSfbF9KiK46ikwtAXJvtg/u0n2v/AGag+WjKU7Bcm+1t/dpPtUnZahyvpSjBNAD/ALROei04PcNUyqoGRTyQBSuMjiErP854rRXpVZBgZPU1ZXpUj6DqKKKACiiloEzOg4u3HtWjWfHxet9K0KfUOiAda4fVLq5S8ZFcgeldwOtcJrS4vfqKqJnLczjNM33nNMLOepNNoqwF59TSUU7a3pQAlFPWOR87RnHWnJBLISEGcUh2IqM1IYZA2wjn0qx9imxnHPpRcLFOkq8LNwPn+Wpjp+1RIWyCaVwsZfNRuMVsmyhUgE5zyKzLtQkpVegoBorU006mmgQ6iiimAUtFFABRRRQIKKKKACiiigAooooAKKKKBhRRRQAU9I2kOEGaZV6xG5mwccUmVCN3YhW1mY4xyOaebKcdB1rRjWTzPmHHSrKKQ2OoqHJnQqMTCe1nTqtV+Rwa60LWHqKxJIAvU04yuZ1KXKrozqWkpasxCgdRRQOooHHc7KH/AFS1LUduMwrU4U0rja1GYpcVIENOEbelLmQcrIcUYqwITUgtyannRXIypijbWgLVj2qUWh70ucOQy9ppwQ1rC1Hc1ILdBRzMfKjHETelPEJNbAijHanhVHQUrsNDIFux7VKLVvStOilZjuUVtD3qUWqjqas0UWDmIRAgp4jQdqfRRYVwAA6ClpKKYC0lFFAgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiloASlpKWgApKWkoAKKKKACiiigAooooAKKKKACiiigAooooAinGYjXJXXUV18ozGfpXJXYrSJEtylRRRVDCiiigApKWkoAKKKKACiiigAooooAKKKSgBaKYZEHU1GZ0HTmgV0T0VUNwewqMzSHvTsLmR//Q6aikpaAFooooAWlpKWgY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ICjAqJtYYHhRiqV8iwkNH0NXVtYpLZWxzigQo1Zz1UUjanMVLoBgVGIYniCdxUc9uIIGK9DQBOuoXDIXx0qv/as5YehosFMls3qajj06VDk8igZO+oTtJsU4NJNdXYjDZwKJbbMizR/eHUVX1GdvljHGOtCQhkmoXGAqscmpo7uVgUdiGAzSadbB83En4VoSWtvJ82QGFDAzWkuUGGduahWaUglnPBqxqRYRKop9vp++EM7HJFMBkVxJhsksMcVmmRy24k10ENkkAIzuz61nyaeyyb1Py5oBlKZ96jb261azHJZlGxkd6uSWEchAQ7TTbq0iS2Kjgr3ouBzJYnjsKbzwBRTkIEgz0zVEm1KI0sipwTjisGuh1GOFbcFBg4rnqEJhRRRTJLFo+ydW969HjO6NW9RXmUZw4NejWbbrZT7VnLc3XwFmiiipJCiiigAooooAKKKKACiiigYUUUUAFFFFABRRRQAUUUUAFFFFAC0lFFAgooooAKKKKACiiigAooooAKKKKYBRRRQAVzniFf3SmujrB18ZtgfenHcipscXRRRW5kFFFFAgooooAKKKKACiiigAooooAKKKKACiiigAooooA//UyKKKK0AWikooAWlpKikmSPr1oA1LOQKdhrSyB3Fcc13IT8vFMNzcN1c0gudfJcwxDLsK5vUL37S21Puis8knknNJQIKKSloAKKKKAFopKWgAooooAKWkpaACiiigBaKSloAKWkooAWjAoooAMCnq7r91iKZRQBaS8u0+7IRVlNX1BP8AlqTWbRRYDej8Q3qfe+arieJ5R9+MVytLSsO52ieJoD99MVbTxDp7dSR+FcBRRYLnpceq2En3X/OrS3Nu/wB1x+deVU4Mw+6TS5QuesB0PRgfxp3415Wt1cp91yKsLquoJ0lNHKFz0ylrzxNe1BerE1bTxLcr95M/jSsO53FFcinij+/H+tXE8S2h++MUWC50VFY6a7pz/wAZH4VbTUrKT7slKwF6ioVngb7rj86kDKejD86BjqKKKACiiigQUtJRQAtFFFABRRRQAUUUUAFFFFAC0UlLQAUUUUAFFFFABRRRQMKKKKACiiikAlFFFABSUtJQMKSlooASilpKBhRRRQAUUUUAFFFFACUUtFAFK5ufK+VetZzXMrd6Lpt0pqtW8Yqxwzm2yQyOeppu402iqIuLk0lFFAgo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gNAOLxR7139ZG0tkFFFFBAUUUUAFFFFABRRRQMKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooEFFFFABRRRQAUUUUAFFFFABRRRQAUUUUDCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBaKKKBBSUtFACUUUUAFFFFABRRRQAUUUUAFFFFABS0lFAHHavZTy3e6JCQe+KrRaJeydcD613dFXzsjkRykXhw/8ALZ/yNaMWhWUf3sn61s0UuZlcqKyWVpH92MflVhVRfugD6UtFIYuTSUUUgCiiigAooooAKKKKACiiigAooooA/9HjqKKKsQUUUUAFFFFABRRRQAUtJS0AFFFFABRRRQAtFFFABS0lLQAUUUUALRRRQAUUUUAFLSUtABRRRQAUtJS0AFFFFABS0UUAFFFFABS0lLQAUUUUAFFA54FOKMMAgjNACUYz0q2bOVUErdM1rRW0Aj3IA2R3pNjSMGNHkfYo5q21i0Tr5p4Izxya1bdY2bKjDLwaiKk3By20A8GlcdgSK2eFggyVHfrUGnKhLJkgila4gtpmI+Zj1rOE7pIZI/lJ9KNQbNG63x7Qznae1RG6iEJjGST1qg8skpzI2ajp2Fcs/aZBH5afKPaq5JPU5oopiCiiigAooopgFFFFABRRRSAKKKKAClpKKAFooooAKKKKYE1uMzKPevUoBtgQe1eZWQ3XC/UV6ggwgHtWT3NfsIdRRRSICiiigAooopgFFFFABRRRQAUUUUAFVrnooqzVW4+8goQBS0lFMBaKKKACkoooAQgEYNQm3Q1PRQJpFb7MlPECCpqKdwshoRR0FOoopDCiiigAooooAKKKKACiiigAooooAKSlooASiiigAooooAz9SuvstsWHU8CuCLF2LtyTya3ddu/MmECn5V6/WsGrRLLNnbtc3Cxp25rrJLa+ZQikBR71Q0i0kgh+0uPvcj6VqyXqRRMzE8etS2WlZGLql5LGn2bgE9SKq6XbsT50h2J2qrG3267/AHpwCe9db5VoYgjFSFHrQxLuY2pGGOL9zM249q5yrd46SXLNHwvYVVqkSwpKKKYgooooAKKKKACiiigAopKlSKWU4RSfwoAjorRGlXuwuQAB6ms48HHpQAUUUUAFFFFABXa+HzmEiuKrsfDx/dmon0NaezOk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AdqWk+tMLEMBjigCTJpKRjhc0yNi6BjxQBJRUcqF1wDingYGKQxa4fxCuL7P+yK7iuM8RjF0D7CqjuTLY52iiitDMKKKKBi0UlLQAUUUUAT23+tFepQcwJ9BXlcH+tFepWxzbp9BWctzZfAieikpakgKKKKACiiigYUUUUAFFFFABRRRQIKKKKBhRRRQAUUUUAFFFFAgooooAKKKKBhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQIKKKKACiiigAooooAKKKKACiiigAooooGFFFFABRRRTAKKKKQBRRRQIKKKKACiiigAooooAin/wBX+NPj+4KVlDDBpQMDAoGFFFFAgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKYBRRRSAKKKKACiiigAooooA//W46iiirEFFFFABRRRQAUUUUALRSUtABRRRQAUtJS0AFLSUtABS0lLQAUUUUALRRRQAUtJS0AFFFA5OKAFoq49qse0M3zGnS+SFURjkdaLjsV4oHlyF7VJHbFgSxxirDt5aiRWwfQVFBvkDDIAPrSAbMLdUHlkk1VFSSIsThc5BPWr8ttF5Y8r72M072CxmU9I3k+4M1YitWbDPwM1fAMVwghQ9MHjik2CRkrFIzbMc1bisXdmRzjbVttq3jbyAKja4jjuPvEqQeRRdgM+yRfZ97E7gatSRIkagKM+pqi9yojaNec9Caiku5ZVCngCizC5ts2WXzGAAqu9xGrEM/4isUu7feOabRyhc0Z7xWTy4s475qijtGdyHBptFOwiZriZjksahPJyaKKACiiimAUUUUAFLmkooAKKKKQBS4NJSlietACVO3kiMYzuqCigCcNCIznO7PFTwWDzxmQEACqNben/AOpfcTyOKTGjFIwSPSkpW4Y0lMQUUUUAFFFFABRRRQAtA6iilXlh9aGOO6PR9GXbZj3rUqjpg22afSr1ZI0n8TCiiiggKKKKYBRRRQMKKKKQgooooAKKKKYCN901nW3RqvyHCE1QtfuGn0F1LNFFFAwooooAKKKKACiiigAooooAKKKKACiiigAooooASiqOoztBblk6nisxGuY7cuXLFlzzQB0GQehzRuUdSBXP6YZUcPI5bcMkHtVS+E7Tsyudue1FgOrz3qNp4FOGkUH0JqtaS7rMA8naea5JYfPuGWRjnNFgO1M8AG7zFx9alBDDI5FchcQRW1vsUksfXtXSae4e0Qg5oGXKKKSkA2UbonX1FeayjEzj0Y16bjPFeb3qbLuRf9o1cSZFaiiiqJCiiigAooooAKnto/MnRPU1BWhpi7rkH0pMqG51IGAB6Cs3UCWMcA/ibBrTrNUedqJ9EANIZebEMOf7orjJnMkrOe5rqNVl8u3I/vHFclTRItFJRTELRSUtABRSUtABRSUtABRRRQAVqaTbG4uh6DmssDJwK7zRbP7PB5jD5mqJdjWnp7xtABQFHalpKKRAE4BJrjdavGJ8qM/M9dHqN0ttblmOK4NGMjPeTd/u1SRLYx/3MYgj+8atQRhVAFVIFLuZm79K0lGBVmbHUUUUyQpaSigBaKKKYBRRRQAUdj9KKPWkBU08fNIarz/PdYq1Y8GQVXjG+9A96iWx0U/ibOkQYUD2pG+dtg/GlJ2rmnxLtGT1NDEgkQFNo7dKz8Y4rUqtLFn5lpNGkJdCpVmP7tQBSTirYGBiiIVHoMYgDJrOu7MXUW8cMORV0gyvjsKlPtVGSRy0MhyYpOGWp6tX9n5g86Lhx+tUIZfMGG4YdRVJmckSUUtJTJEIplPpDQA2o5BxmpaawypoY0NHIzRTV6U6mIiPWm05utNoEFFFFABRRRQAUUUUAFIQDS0UACsRw1TA56VDQDtoE0TUtNDA0FgKZNhGPFQn74p5Oaj/AIxSZSH0UUUwDOKMg0Uxl7rxSGPpaiDsOo/KnhgelFwaHA4Oavwybhg1QpVYqcinczlG5rUVFG4cVJVHO1YWiiigQVUuJP4RVh22rms1mLNmkzWnHqNp6OUORTKKRsaaOHGRUlZkchQ1oIwcZFUmYSjYfRRRQQFJSZpaAClpKKBC0UUUAFFFVJrjHyR8mhspRbJ5JUiGSeaos8s5/urSrEWO+U5NT4xwKnc00jsRbFiQmnWynaXPVqjmO5hGO9XFG1cULccthplMZ+YZFSRnd8/XNMJzwelII2Qbk/KmS0ixS1EkitweDUtMzasFFFFAgooooAKKKKACiiigAooooAYUzyvBpVbPDcGnUhANA7jqKaCRwadQIKKKQkKMscUDSFqGWVIx8xqtLdMx2QjJNWoNJeUb7liM1jOrbY7aOEctZCwTKlwkimu6XDhXHpXEtosq/PbtnHrWrZ6pc2iiC8jJA/iANc+75j0eW0FDsdPRVaG8tpxlHA9ieatVVzBprcKKKKYgrn9a1Dyk+zQ8u1a17dJZwNKx57VydlC95Obuf14pitf0Lem2Yt497/fatOg0wnPyigpj413tnsKtUxF2ripUGTSKRYUYFKTgZpazNTu/s8G1fvN0poyk7IxtQuDdXPlr91a3bKHyYQO5rC023MkoZvqa6f2pXuzTl5YqIvas6QYYitCqtwvO6miWVKKKa5wpqhMzrhuGPtRYjEOfWork/uz71aththUe1Etwp/CyyOtPzUY604nikMiY5NMpTSVRIVn3f+sFaFZ9398UAJqBP2ZABmsTJ9DXWIY1jBlC4/2qY01pn7sdJDkjlsn0NSQ5EqkgjmujFxZA/dSqN9PbOFMQAOe1DCOjKkwzcN/ums5/uVrqu+4IPeM1kuSVK4+7xTjsTV+O5PB/qxUNyOFNS2/KVHc8oKFqgekh8vzW4IpkYAXipITvhxUaAqTGfwq0YzWg41Sl/wBZirtUJD+9NORNMbWnax4XJrPjXc1bEIwKEKq9LE9FFFWcoUtJS0AFFFFABRRSMcKTQC3InuIo+Dz9KiN6n8Kms7PJPvVqG0vJxuhQkeuKwdRnpRwkbXJPtzdlpft0nZRUy6PqbfwYqQaHqf8AdFT7RlLDQK326X+6KX7fL/cFWv7D1T+6KX+xdUH8Ipe0Y/q0OxVF+/dP0pw1Be6N+VTHSdTH8AqM2GoL1jz+FHtWS8LDsKL+E9QRUq3cDd8fWqpt7pfvQn8jUTRn+KEj8KftWQ8JA1BLE3Rx+dP4PQ5rE2RehWnBSP8AVyn8TVqt3MpYPszZpKzRJdp02sPrUgvWX/WofwFWqkTGWFmi9RUCXUL98fWpwQehB+lWmjBwa3QUUUUyQpCcDNLUbHJ20hoRfX1p1FJQUFRtGrVJSUDTKbRlahZA1aRAPBqtJHt5HSk1c0jNp6D7PUbvT3yh3L6GuxtNTs9TQK+Ff0NcLTMMjb4zgjuKwlT6o76eIUtJncXVo6KcDcM55rNm+ZBIox6ik03X+lvfDI6bq1buBWj863O5T6VMXrqXOFldGJbP5d6pPRgaTUrU28v2iMfK3UVDN8u1l/hIroiFuIeeQwqtmZpKUbM5pWBGRTqimiazmMTfdP3TTwa3i7nBODi7Dqq3J4AqzWfM256bFBakVVp/vIPerNVj89wB6c1Ejpp73NMdBRUYNPpALRSUUALRSUUwCjkciiikBfgvWT5ZORWqjLINyHNc3UsU0kJyh/Ck12Hvubzoki7JBke9Ys+nSQMZrY7h6GtSC7jmGDwatYNLcV3E5pJw52uNrehqatO5sYrkZ+63YisVxPaNsnGV7MKNi009ieikBDDKnIpaYgp9onm3iL6Uw1qaHB5tw0vYVnUeljqwy95y7HWquFArmPEFxu2WiHlutdLcTJbxmRzgAVwIka8vXuW6DpSRle8n5FpV2qFHYUU6krQgbUbVLUbUxFaQ4Umuq0K28uDzGHLc1zUcZnuFiX15rv4IxDEsY7CsZu7OmmuWF+5LS0lFIApaSikAtFJRQAtFJRQBHLGZF46jkVHFCytvkOWqxRQISiiigYUUUUwCiiigBKKKKACkpaSgAoopCQBknH1oAWs88Xg9wavK6McKQaoTfLdIfahCLtFBpm9Om4UDHU12CIXPQU6q92rPAyr1NAEqMJF3VmXtxdwttgTcPWtCMbUAp9MDnkuZ7hDuJUjqBV2xt/LPmEkk+tW5rdChaMYYelNSZigVACR1zQMu0VTR5Q+JMc+lTzBgmR+NIQvnRbtu4ZqWqKiCUrtUAjnNXqBhRQTgU1CxGWpCOf8AEQzbofSuOrtfEAzaA+lcV2rVbGb3PRtOO6yQ1erM0hs2KfU1pVmzQWiiikBR1AgW+ec54xVKyjlmdXnXgDg1d1AqtsSahsrh0tlMnTHFMDUPFIRWS93M74TA9KiW7uWJBxwcGgDZyMYNAKkfLWQsrmZlY/hV2CTBIIGT6UAWstnAHFLUPmqOSfyqVSGGRQMdXI+JR+8Rq66uV8Sj5Yz7047ky2OUoooqzMKKKKYwooooAWikpaAJIf8AWCvUrTm2T6V5ZF/rBXqNlzap9KzlubL4CzS0lLUkBRRRQAUUUUAFFFFAwooooAKKKKBBRRRQMKKKKACiiigAooooEFFFFABRRRQMKKKKACiiigAooooAKKKKACiiigAooooAKKKKBBRRRQAUUUUAFFFFABRRRQAUUUUDCiiigAooooAKKKKACiiigQUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB//1+OoooqxBRRRQAUUUUAFFFFABS0lLQAUUUUAFLSUtABS0lLQAUUUUALRRRQAtFFFABS0lLQAUd6KWgDTjeGZwGyzepqnMQHKKMYqT7UqgCJAMd6rMxZtx70JDZZ8hvJ8zrVmKxaRSXOOMiqInlC7A3HpSedMeNxpWYXLkVrAVLSN0OKma6t14XsMVk0tFguX5b7egRVAx3qJry4bHzEYqrS07CFZmc7mOTTaKKYBRRRQAUUUUAFLRSgEnAoASin7GztPWkKEdaQDaKkj2A/vOlPPk/w+tAEODU8duZOSQBUrSRKQUHBqq7dgcigCd4oo0OWy3aqtJS0AFFFFABRRRQAUUUUAFSpPLGNqnAqKigAooooAKKKKACiiigAooooAWnxDMgHvTKltxmZR70nsXT+JHp1kNtrGParNRW4xboPapazHLcKKKKZIUUUUAFFFFABRRRQAUUUUAFFFFAEU5xETVK1+4at3RxCaqWv+rp9BdSzRRRQMKKKKACiiigAooooAKKKKACiiigAooooAKSlpKAKOoW7XMGxeoOazIbV/LKmU5UfhV7VWlWAeUcHPNZqQzLDln4b05oAns7UxsZ3bdnjaOajksDLIXEmwE8qKNPkZLoRDO3BPNVdREkd0SHI3c0AdAsQgt/LTnA4rA06S3imkNyvzZ4yKu2v2qKAz3DnGOBVKG0OpFpt5A9KALtxNp04Kt+laNgsSW4EJytYVzYCzi81BuA61tabNHNbho1CewoYIv0UUUhijrXDa5F5V7kdGGa7mua8Rwbo0nHY4qo7ks5OikpaskKKKKACiiigArV0gfvzWVWlpTbbjHrSZcDqO9Z+n/NNJJ+H61fboapaUMo5/2j/OkwM7W3+ZY/xrCrU1dt13j0FZdUSFFFFAgooooAWkoooAKWkpaACiinRo0jhF6mkNK+hp6TZG6uASPlXmvQAAqhR0FZ+mWa2duB/EeTWjUeZcn0QUE4GaKx9ZvhZ2xVfvvwPxpknO6tdG+u/s0Z+RDz+FZl0wZlt06d6sQp9nhMr/AHm5NUoAXZpm79Kuxne+pcjUDAFWqgjqeqIFopKWgQUUUUAFFFFABS0lFAC0UlLTArWfE0q0lgm+5Z/SkiOy6kHrV7TY9sRf+8T/ADrNnRHS7NALvYegqeowwU4p4YGnYSkthaKKKQxpVeuKglbAwOpqdjgVXQb2LntwKYh6rtXFI3Sn0hoAgArFv7Nkb7TB17itw0hGeDQI5yOQSrkdfSn0Xts1vIZ4fu9xTUdZF3LVJmbQ6kpaSmSNopaSgCBeCRT6aeJKdQhsjamVI3So6ZIUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUz+On0z+MUmND6KKKZIUUUUDEAxTSgPI4NOooC4wMQcNUlIQCMGmAlDg9KQbliNyhrQRgwyKy6mikKH2qkzKcbmjRTQwIyKZI+1c1RjboV7iTJ2iq1BOTmipOhKysFFFFAwqxA+Diq9OT7woFJXRqUhNIDxSVRz2FFOpop1AMKKKKBC0UlRzEiM4oBK7sVppmc+XH+JojjCD3pY1Cr7mn1Jq30QUvvSUyVtqE02CV2RxfvJi/YcVbY4FQWy7YwT1NSMeaS2CWrHgDhamquhywqxTJYxo1bnofWmB3j4fketTUuAeDQF+4isGGRTqrtGV+aOlWbs/FFyXHsT0Un0paZIUUUUAFFFFABRRRQAUUUUAFFB45NU5Lgk7IvxNJuxcIOWxPJOsfHU+lUT5tw+0ck9h0p0UTzPti5Pdq3rW1S2Xjlj1Nc06lz06GHS1Y2ysEgAZxuf8AlXQ29ru+aTpUVpDvbcegrYHHFYrU65ytohixRr91RQ0cbjDgEU+iqMTNk0q1f5ox5Z9RUQtr+25hfzB6Ma16KAu0ZQ1F4ztuoyD/ALIzVtL21dd2/GPXirJAYYYZFUZtNs5gcoAT3p+hN11RzVzJJq955aH90hrYSMQoEUYAqA6HJBlrKUg+lV2bU7b/AF0Yceuaq/cLLozQJ706JcncaoxXsEx2tlT6EVqLt2/KcimL1H1NGOKhqynSkMczBVLHtXHXc5vLon+EHitnV7vy4/JQ8msmwtzJIBTeisRTXNK/RG7YQ+VFuPU1epAABgUtItu7uFMcblIp1FAjLIwcVDKcLVyddrZqhMatESMy6PCr6mtFBhAPasyb5pUWtXpxQ9yl8KHChjxSCkY0CY2kpaSmIKz7z71aFULv71ADNR5tErCGK6O6jM1uqDrWUdPlHekDKJAoUfOp96t/YZe5p32KQDcCOKBI0Ix/pae6VnXqCKVsVoo3+kRE/wB3FF9pd3PKXTG0+9EXuiqsb2ZjWp+Q0swylXF0m9TgYH41DLDJECknUVUVoZzauV7Y4GKklUkbh1FQQ/dbHarSsHUNTXYJdyBG3jPeqDHLk1fljI+ZPxFUgMkDFNkKNrliFcCtGLpVEYFXImBFWjCZYpaSlpmAUUUUALRSUtABTJP9WafTJf8AVmhjjujE7H616doi7bBBXmQ6H616hpAxZx1xPc916QLT6jZRsUeQAj3pP7TsT/y1X8xXl2rAG/k+tUNorRQObmZ7B/aNl/z1X8xSjULQ/wDLVfzFeP7RTgtHIhc57ALy2PSVfzFO+0wHpIv5ivHvmHQmniSUfdc0uRD9oewb4m/iU/jR5Fu/VVNeRi7u1+7KamXVdRj+7M1HIPnPUH02xf70S1Ql0Gwk6Lt+lcda+IdRSQB3Lj3r0O3m8+FZemRUvR2Ls+XmOcl8Mr1glas6XRNSh+7hx9a7K6vbeyQSXBwDVePWdOl+5J+dKzEpHAy2sycTQn6gGq4Xb912T2r1MSW04++rD61Um0iwuBzGM+ooTsDSZ58s06ejip1uo24cEH9K6C48MjrbSEH0rEudM1C2/wBam5fatFUZjLDRkPBBGVINNxiswBkPykofSrC3LpxKuR61qqiZyzwso7FukpqSRyDKH86WtE7nM01owoopKAFpMjoaKrscmgpK42RNpyOlR1MH4weRUbLjkdKRqvMiZA1aFhqc9mfLJyh7HpVGkIB61EoJnRSquOj2N2VoplMkfGe1aemSeZahT1XrXKQ3LQHa43Ka3tNkCSEJyj81jfozpUVq47F6+tRcwkfxDoa5yNm5RuGFdgeK5/VLfypRcp0b71aRdmc1aF1cpSPtQms6rFw+SFFVq0uc8VZAx2qWPaobZScyHvTZ23ERDv1q0qhVCip3ZulZD6cDTKKBEtFMBp9AhaKSimAtFJRQAtJRRQAAkHIrRt75k+WXketZ1JSaGmdSjJIu5DmkkjSVSkgyDXPQ3EkDZU8elb1vdR3A44b0qfUTj1Ri3FjLaEyW/wAyf3fSoo5VkHHB9DXT4rJvNND5ntvlcdvWgIzvpIoOTjA6niupsWg0uyDzH5yOQOtcpDPhtjrtkWrDFpG3Sncahq7udKlyx5V1JNR1Ce+yT8sY7etNtYvKiA7moGPmzLGOg61oYxxTRPSwlJS0VRI2onO0EmpjUIjM8ywL3PNJuyuOMeZ2NjQ7QljcuPpXUVDbwrbwrEvYVNWKOiTuwooooJCiiigAooooAKKKKYBRRRSAKSlooASiiimAUU1nVfvHFIrq33TQK46iikJA6mkMKKQEHpS0wIpZViTc3XsKrxxSTfvJjwei1G37682nooz+NaFAitJCsY3xDaR6VXuDuaNx3q+5G0g96z7j5I19iKYFmZiziJePekeCLYcjn1qKCQyOzYxVpgGGDSAqWTOyEPzjoatk4GaoyPLbsIoVDbulS+Xcuvznb9KAJVdZBuXpTqzLcyRO8PXHSrqxuwyzEUwJiQBzVKNGgkJPKtzUpt3brIaPshIx5hpXGhAFkbdnpSTSyhcKMiq2029yELEqa0JXCxE4xxQMpqkjrlVC59KtxrKFw55plud8QINT0CAADrzT81GacvSgDH10Zsj7Vw/au71oZsXrhO1XHYze53WiNmxX6mtisLQDmzx7mtC8u/skYbGSeBUM0LtLWEmpzNyyADvzT5NRlEwjiUEbdx5osBoXqeZAU7mqxiH2Zbf+LFVLrUJfJDAYOcVCb2dQhYYJ707AWVtp0AJA+XgE1OLJwrMpyWOarLdytL5LdCM5qs17cB2iVuhoC5pNY+Y/m7ip74qURi3QbDvPes1LmYSJHuPzg5NWLKQiYxEkn1oAvwxKMv1DdjVn6UUVIBXM+JB+4jP+1XTVzviMf6Kh/wBqqW4nscZRRRVkBRRRTEFFFFAwooopCHx/fFeoaec2aV5cn3xXp2mHNklRLc3XwF6lpKKkgWikpaACiikoAWiiigAooooAKKKKACiiigAooooGFFFFABRRRQIKKKKACiiigYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAgooooAKKKKACiiigYUUUUAFFFFABRRRQAUUUUAFFFFAgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigD/9DjqKXBHUUlWIKKKKACiiigAoopaACiiigAooooAWiiigApaKKACiiigBaKKKACloooAKWkpaACiiloAKWkpaACiiimAtFJTlRnOFFIBKKurZsD+9O0Yqs4VZCoOQKLhYZRg07cuOnNKsjJjbwRQAgRmOBSBecGnNI7tuY80ymBOUiUfeyaCYRjaM+tQUUgJjKP4VxUYYjpTaKYDixY5NJSUoyTgc0gEoqwLaUnGOoyKmS1R0O44YUXHYqKhxvxkVcNvCyDyz8x7VNBFiHMZ5HWlZYFRZd2GHb1qbjsZTKyMVbqKSpZpTNIXxioqokKKKKACiiigAopQCegpDx1oAKKKKACiiigAooooAKKKKACiiigBas2YzcIPcVWq9pw3XSfUUpbGlL4j0yMYjUe1OoHAAorMTCiiimIKKKKACiiigAooooGFFFFAgooooAq3hxCagtf9XUl8f3YFR23+rqugluWPrWPc6gwbZD271dvZDHAcdTXOVcI31MK02tEW1v7lTktn2rYtbpbhfRu4rnKkhkMMgcfjVOKZlCq09TqqKYjB0DjvT6xO0KKKKACiiigAooooAKKKKACkpaSgDK1SUKiw4zvOKppE0SlYQAR71q3djFeEGTgr0qqNItwSc8mgCrYSHz2ExBf19qh1hP3kci4I71oppFsh3Dlql/s21b765oAR5bdrTaWGCKwtPvYbDdG44J4Nbv9l2f9ynf2ZZf886AMi71aGWExoMlqn0JsxuMcVpDTrIf8s6tJGka7YxgUDHUtFFAgqpfQC4tHTvjIq3RQB5cVKMUPVTikrW1m1+zXZYfdfmsmtCApaSigBaKKKACrFpJ5c6t71Xozjmhji7M7huUz6iquk/6pv980ljOLiAeoo0n7rr/tH+dQy2jA1M5uz9Kz60NTH+lms+rMwooooAKKKKACiiigAooooAK67Q9N2/6TMPoKoaPpZuXE8owg/Wu4UBVCr0FQ3c02QUUUtBIx3WNC7cBRmuBnmbUb1pm/1aHC1ta/fFVFlCfmbr9Kx0RbeHaOwqorqRN9ClqMuQIl70IoRAo7VTyZrjce9XqpdyXpoTJU9QJU1Mli0UUUCCiiigBaKSimAtFJS0AFLSUUAUj/AMfmPWt+FBGgUVgy8XUZ9a6FegqOps37qGn71FJ3oqzk6i5Ipwc96ZRRYak0LKxICjvTwNoCjtUcY3MXPbipqzOtCU006mNTAYaaeBTqhmbamB1NAiEHeCW71i3Nu1o/mxcoeoraHAxSEBhtbkGmIyEdXXctLUU8DWj+YnKHrUisHXcKaZm1YKKWkqhEMvY0tOcZU1GpyopAB6VFUxqE0xBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFACU3+MU6mn74pMaH0UlFMQtJRRQIKWkooAWkIBGDRS0ARg7TtNSUjAMMUik9D1FA33LMUhU4PSklfcahopkcutwooooKCiiigBaVetJSr1oEzQXpS01elOqjBjhTqYOtPoJYUUlGaBC0jAMMGk3AUoOelAFXBQ7T+FOqZlDDBqDlThqRoncWqk75dY6sscDNZTsWctUSZtSjfU2AQF+lV2nQE+tZ+9yME03FJyKVK25qQtubK9KvKcisy2JCmtCPoatGE9ySlpKQsFGTTIH0x41f60oIIyKWgCt88R9qmWRW68Gn9eDULw91oHo9yeiqokdDg1Orq3SnclxsPooopkhRRRSAKjklSIZY1FNOI/lXljWaokmY927VnKdjppUHLVlp5Hl5Y4X0qS3tXuDwNqD9at21izANcDGO1a4AUYHArCU7npU6KjuRxRJCuxBgVZjXcwFRitC0jy2ayZ0o04ECIBU1FFNHO3dhRRRTJCiiloAxZLm4sbjEx3RN0PpWujrIodDkGmzQxzxmOQZBrBDT6VLsPzQk9fShp7otWlo9zo6M1FFNHMgeM5FSUJ32IcbaMp3Fha3I/eIM+tZEunXdn89oxZf7tdJRT9RarY5u21JWbyrgbGrZ8xY4zITxiob7TobxMkYcdDXKzz3dt/oU/Tsapbik7xdtxJ5GuJy57niui0+Dy4956msG0i82UAV1qgKoUdqHuVFcsB1JRRQSFFFFAFe4xsrGkPBrUu2wMVkTHCVojJ6lJfnuV9hWnWbbDM5PoK0aRoOFNPWl7U2gQUUUUwCqN5xk1eqldjINAmSSxmaFQG28VR+xzDpMfypZrm4iISOPcMDnNQfa7r/AJ4/rSBk/wBkl7ymmPaSEY80j8Kj+13P/PH9aUXVx3h/WmImVPKeMFtxBHNdMelcqksssimSPZyO9dQvSoW5tLWCGHpXOagMTH3rqCuRXO6muJAfWtVscz3MGD7zCnwgo7IOlRxcTGiUlJNw61JZcBqvOFDKe5NPSdX4fg1Hc8MtUnclqxXkck4qxbuQKpHqTVuIYWqRlJaGgkgNTA1nA4qdJccGquc7h2LdFNVgRxTqZmFLSUtABUcv+rNSVHN/qmpMcfiRijpXqel8Wkf0ry0V6np3Fqg9q43ue7L4DzTUjm+k+pqjXpU3hqynlMrHluaj/wCEUsv736Vqpo5lHucBaw/abhYAcbjjNdWPCL/89j+VbEHhqyglWVTypyK6EDtU8xbiuhw//CIN/wA9z+VQ3HhcW0DTSTkAe1d9g1yviyYparCP4uaE9SGkjz+iiirIHRf60V6tpvNkhrymH/WivVtM/wCPGOspfEdX/Ln5mR4ox9iXPqa8/Ciu78VPi1jX1Jrha0WxykiSyRnMbEVpwa7qVv0kLD0rIoosO52Nv4ukHFxEPrmt+38R6bccFtpPbFeX0YqeUakesy2emagu5tpJ71h3Xhl1Ba0k3f7NcPFNLCcxMQa2bfxHqVvgM28elLlLUyG4tJ7V8ToUb1HNNWd04k5HrXQjxTDOmy6hGD71iXk2myndanYT/DTTaJlGE9JD1dWGVNLWYC8JyOlXo5VkGRW8Z3OCrQcHpsSHpVc1I7Z4qKqIihKASKKKChCO4plP6UuA31oKuR0sc0ts+6M8elIRjrSGplFPc0p1HB3R2VleJdxA/wAQ6ipbmEXELRHvXFW88lpKJI+ncV21vOtxEJV71irp2Z1u0lzROHJO4huoNNLBRuParV9GIr6RPXFZ75lkEK9O9aX0Obl1H20ZcmZvwqzUygKoUdqjYYNNIG7jaKKKBBSg0lFMCQHNFR04N2NADqKKSgQtFJRQAtFJRSAWlVmQ7lOCKbRQNaG5aX6yYjl4b1rS9xXIGtSzv3UiKTn0qdhuPNsXryxjuhu+646GsQSSQOYbkYI6H1rqEZZBlar3VpFdxmOTr2NDRMZW0ZjWak7pW6tV2qETyWb/AGW56fwtV+kbPuFFFFAiORgi5ra0WzwDdSDk9PpWRaW7X10FH3F5NduiLGoRegrKTuzoUeRa7jqKKKQgooooAKKKKACiiigAooopgFFFFIAooooAKqz3UcIIzk1Hc3aw/IRyRXPyHcxb1rSMTGpUtoiSWV5XJc59KjjkljbehxinQqQ2WHFOliMZ4ORWmmxz67mimplo87eRVKe4lf5snB7U2DaP9YOlWpFCDYD96pskW3KSvcitblozjORW6rBlDCuYUkFlNbNlLlPLPWpki6M9bMbMWt5/OAypGDTxfq/ESkmrUg4pEAA4FQbkKRyO3mTH6LTLvoPqKt1UuxmP8aYEQl8li+PlNW45o5RlDUVswKlanCKv3RikMzbqV47lGC5C1oq7OmcYqTNJQBUED5D9walczfwrU1FAFXdc/wBwfnRvuR0QfnVqigDNkjuJZFkKDj3qVvtDjBQY+tXaKLgVEE8ahQg/OpAZu6j86nooAj+fHIpRv9KfRQBmaqubGSuAr0XUhmykFecnqauOxEtzsvDx/wBGIq3qqO4jKLu+aqHh0/uWFdKDipe5a2OeSzlaMuE2k0v2O5Dh1QH5cda6DOaM0rhYwpLC4ktxHj5gc046dPIib+ChHH0rapaLgZY05hN52/r2pzaVEzbi1aVLQBWW0hXHHI6GpljjU5UYNPooAKKKKQBWB4iH+hr/AL1b9YXiAf6EPrTW4M4eikpa1MwooooAKKKKACiiigBy/eFemaV/x5JXmS/eFel6R/x4rWctzaPwGlRRx6ijK+o/OpIClpu9B1YfnTDPCOrCgCWiq5u7ZeriojqNkvWSnYC7RWcdX09eslRHW9NH/LT9KLAa1FYp1/Th/HULeI7EdOaLMDoKK5lvE1sPurmoj4ojHSP9aLMLnV0VyB8UntF+tRHxRN2i/WnysLo7SiuGPia6PRMfjUR8R3x6cUcrC531LXnh8QaiejYqM67qR/5aUcouY9GorzY61qR/5a10mgXlxclhO26k1YpanS0UUUhBRRRQAUUUUDCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigQUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAH/0c0qp6immCI9RUm1qMNWgiubOE9qjNgh6GrvPpS5oAzDYN2NRmymHTmtjNOosBgm1nH8NRmKQdRXSUoVT2pAcvgjqKK6nyY26gUhsbduq0DOXpa6NtKhbpxULaP/AHWouFjCorWbSJx905qFtNu1/hJoAoUVYa0uF6oahKOvUUCG0tJS0AFFFFAC0UUUALRRRQAUtJS0AFLSUtABRRRTAOO/StQnEO1OVAzn3rMHXmtwBxb7YsAY7ipY0Y7yvJjcc4qOlPU5pKoQtFFFABRRRg0gCipUglkPyirqaa7AszAAdqTaHYzaK3YbSGKPzMZNMviDCDtwaXMOxikGtC2BhHmBc5GKpxyNGcinNcSMpToCapoRfLqGDSSY9qri68lyY+c1RopWC5M08jMzA43VDS0UxBRRRQAUUUUAFWrWNXlHmDK1Vq7ZXEcDHzBkUmNGvEvlzYUZjIoawidizDPOatRyJLHvUYFScEVBrYyLywXYHgGCT0qD+y38reG59K38gdaQkFsdqd2LlRyMkTxNtkGDUdampzI7hF6jrWXVoze4UUUUCCiiigAooooAK1dIXddr9ayq29CXN0KmWxpS3PQz1pKU9aSpJCiiigAooooAKKKKACiiigYUUUlAhaSlooAz74/dFFt/q6ZfH5lFPtv9XVPYS3KmqHEaisKt7UxmEH0rArWnscdb4h5XAB9aCMU3JFOZt1WZM3tPk3w4PY1frH0sn5hWxWEtzvpu8UFFFFSWFFFFABRRRQAUUUUAJRRRQAUUUUAFJRRQAUUUUDCiiikAtFJRTELRSUtAGZqtmLy1IH3l5FcCQVJVuo4Neo1yOt6aUY3cI4P3hTixNHN0UA5oqyQpaSloAKSlooAuWNybaUH+E9a39MdTM4XoefzrlK09Jn8q5wf4uKlotPoN1YYuz9KzK2tcXFyD6gVi00SwooopiCiiigAooo9qACtrS9KkvHEkgwg/WrOl6I8xE1yML6V2SIkahEGAKhstKwRxpEgRBgCnUUUgCq93cJa27Tv2HH1qzXFa5eG7uRZRH5V5b8Ka1EzPhLXM73kvVjx9DSXjnaI16nmraqEUKOgrPJ8yV5D0UECtDFb3KsI/eMfQ8Vaqvb/c3etWKEN7kyVPVZDVgVRLHUUlLSEFFFFMAooooAKPeoZ32J706I5jU+1ICSlpKWmBTuuHjb0NdAn3B9K5+8H7sN6Gt6E5iU+1Q9zVfAA60UCirOUKRumPWloQbnz2FJmlNXZKo2qBS0tJUnQITgVGaX7x9hQaAG1TlO6THpVsnAJqgp3Ev600IfSUHjrUBmAbAGaAJmUMpVuQaxJYms5MjmM1tKwcZFJIiyKUcZBoAywQwyOhoqBle0k2Nyh6VP15FUmZNWA9KrrwSKsVA3yvn1oEhaiPWpajamIbRRRQAUUUUAFFFFABRRRQAUUUUAFJRRQAUw/eFPpjdRSY0PooopiCiiigAooooAKKKWgApi8kmpG+VM1GvC0Ath9FFFAgooooAKKKKYBTl602nL1oEy8vSnZpgPFFMxsO3Yo3mmUtAWF3GkoooGFODYptFArEu4GggMMGoaieQqcCi4KPYhum2fu1PWqBBBwac7lpC5p/yuPesW7s7YRsrEdLSEFetIKBl+AnZWghwDWVA5QGrQnOM1omcs4u5dLgHBqCdxgAVWVstk0hOTk0xKJPHLtODV0MCKyqnjlKnBpilHqjQopisCKdTMxGUMMGqzxsvK1aooBOxWWYrw3NWFdWGQahlVPxqvhl5U0rlcqZfqvLNtOxOtMF1gbT1pkMDXT4Xp3NROdkbUMO5PUbDC9w21PxNb1vbR264Uc+tPiiSJAiDFS1yt3PWjFRVkFLSUtIY5Rk1tWiYXNZMYy1bsIwgpLcJuyJqKKKo5woopaAEpaKYJI2YqDyO1AD6Y6JKpRxkGn0Uwavuc5NBcaY/mwfNGeorWtL2K7Tch57irhAYYYZFYF5p0kDfabI4I5Ioavqtx89tJ7dzfpawLLWUkPlXPysPWt0MGG5TkUk+jKlBrUdWfqFhHfQlSPmHQ1fpapOxlJXON06Q2Nyba7GDng11HuKr6hp8d4no46Gse0vpLST7He8Y4DUbarYtS59HudBRTQQRkdKWmSLSUUx22qTQJmbctues64PCj3q25yxNUJzmTHoM1oZobaD5nPvir1VbQYQn1Oas0i2KaSiigQUUUUwCqt0Pkq1Va5+5QKQRtmMU+oof9WKkoAWkzR1pNuKAIZzwv1FbQZVjUsccVjTgbM+hrdgjSW3QuO1S37xqlemVTdpnagzWbqa/Kre1dIkUafdArI1sfu1NXFo55rqcan+vNOuBwDTek9TyDKEUiiC3VHyr/hSXOFfYO1JAcSYpLnmc/Sq6EEFXFGFFVVGWAq5VIzkLRSUUyR6uVq2kobrVGlBI6UEyjc06Wqkc3ZqtA56VRg1YWop/wDUtUtQ3P8AqGpPYIfEjJQZwPevUoH8myV/RQa8whGXUe9elzfJpxPolci+I9qu2qehzb+K7lZGXZwDinDxbN3iP51yLHLsfem1pyo5+ZnZf8Ja3eI/nS/8Jcf+eJ/OuMzSZo5ULmZ2v/CXn/nifzrA1bVn1V1YrtCjGKyc0lCSDmbCilpKoQ6L/WCvVtL/AOPGOvKY/wDWCvV9M4skrGXxHWv4PzLE1tBcrtnXcKyZvDmnS/cXaa3KWgwOLm8KHrDIPpisqbw7qMX3V3CvSaM07sLHkctldw/62Miqp44Ir2QqjfeUH8KpzabYzg+ZGPw4p8wWPJ6M1va0ul27+RZqS46nPSufFUSOpQuTSVbtbeS4kEcQyxobsVGPM7FyxsZbxjHCPmFQyxS2kpVxtYdq73TbJdPh2j756ms7xA0HkAMv7w9DULuXJLY5hXDjIoqqmQ4A71dKmuiLujz6kVF2RHRS0lUQJRRSUDJBh/lbrUboUODRViNg42PQGxTIzWpo935E/kOflbpVKWIpz2qo5K4deorOcbo3o1LM0teyl7lOrVUtoTEu5vvGrV3Ot1JFL36GmGQUomk97DqRhkU3eKN4qzMjopXI+8KaCCMikFgooopiCg80UUgFVuzU+oiMihX52t1oCxJRRSUALRSUUwFopKKQC0h55HWiih6jTsa1jcmQ+WTiQdPetiOZXOx/lcVyGWBDpwRXR2sseoQ5PEi9azvZ2Zco8y5luWbq1S5jMcg57GsONpLWT7Ncf8BPrW2k7RHyrjp2NSXNrHcx7H69jVMzi7bmbVeRnkcW8XLNxUTSTW7/AGWQZc8KfWun0nTPs4+0Tcu1ZTl0R10oL45F/TrJbKAKPvHrV+iipQ27u7CiiigQUUUUALTSQOtLTSuTzQA6iiigAooooAjMqhtp60+m7FzuxzTqACse6u5FJA4wa2Kqz2qTD3qk+5M02tDm5Zmlfc1Lg5DR8EdaWeFopCpFVyz1v0OFuz1N2JogoJ6ntUVxbFUDrx7VDZMpcCX86s3LKVwrcVl1OhNOJSaZMj2q3sW5QPnB9aqJApf56mcEJ5aGqZEWV5Y9iFycnpipLdpI2BIxSKmxWB6kUhd5FyeCOKBeaNUyGRwoPvVhpI04JxWVaiXdhumOtQ3LFGKt+FRY153Y3FYOMr0qKdd0ZFQWJZoue1XWXKn6UjYoWnUir1UbUfvWHpV/FIYlFJkDqaaZIx1YUAPoqE3NuvVxURv7NesgoAt0VnnVLAf8tRUZ1nTx/wAtAaLBdGpRWK2u2I6HNRHxDaDopP407MLo36SubbxJD/DGfzqFvEn92M/nRZiujqqWuObxFOfurioW8QXp+7xRysOZHW3ozaOPavNW+8fqa1ZNZvpFKMwwfasdyetWlYlu51/hw/IwrpsgdSK8xjlkjHyMRTzcTnq5/OpcR8x6VvQdSKaZ4R1YV5oZZT1c/nTd792P50cocx6Sby1Xq4qI6lYr1kFedZb1P50nPqaOUOY9COr6eP8AloKjOt6eP481wNFPlDmO5bX7IdOahbxHbDomfxrjKKOVC5mdcfEkfaI/nUZ8S+kR/OuVoosguzpT4kl/hTFULzV572PynGBWTRTsK4UUUUwCiiigAooooAKKKKACr0epXsSbI3wBVKikBcbUb1uslRG6uW6uagoosA8yynqxpu5z/EfzpKKADJ9TR+JoooAKKKKYBRRRQAUtJRSAWiiimAUUUUAFFFFABXV+Gj+8IrlK6jw2f32KiRrT6na0UUVBIUUUUALRSUUAFFFFMYUUUUAFFFFABRRRQAUUUUgCiiimAUUUUAFFFFABRRRQAUUUUAFFFFIAooooEFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAf/Sp0UUtaCCiiimAUtJS0AKKkFMFSCkBIoqZRUaip1FSykOApwFOAqQClcqxHinAVMibmAq0bUdjSuJoobR3AppijPVR+VXjbOKjMLjtTEUGs7Zuq1A2lWjdjWoUYdRTcUAYraJAfunFV20I/wOK6OlouFjlG0S5H3WBqu2lXi/w5rtKWi4WOEaxu16xmoTDMv3lIr0HA7ikKRnqo/KncVjzw5HUUmRXoJt4G6oPyqFtPtH6rRcLHC5FFdm2j2TdiKgbQrY/dJ/Oi4WOUorpG0Bf4XqJtBmH3XFO4rGEOCD6VrLfx+XjHOMUraJdDoQahOkXi/w5paD1M4nJJ9aSrp068H/ACzNRGzuh1jNMRBRUhgmXqhFMKsOooAvRyWyAYHPfNMu5opiNgxiqX1oyKVh3L0V60SbMUv9ozAYWqGRRxTsguWmvJ24zxULSSSffOaZRRYQUUUUwCkpaKQBRRRQAUUUUAFFFFABSdaWigC/DfyRJ5ZGRVsasBwUNYtFKw02bJ1YdkNV5NSlddq8VnUUWC7Akk5PWiiimIKKKKACiiigAoopaAEro/Dq5uc+1c4a6vw0v74t7Gpka0+rOzpKKKkgKKKKACiiigAooooAKKSigAooooAWikpaAMq8P73FS23+qFV7o5mNWLf/AFQqnsJBcx+bCyd65cggkHtXXVlXVhvYyRdfSqhK2hjWhfVGNRVg204ONhq5baexYPLwPStXJI5405NlrToikW4960aQAAYHalrBs7krKwUUUUhhRRRQAUUUUAFFFFABVRryIT+QevrVuoJFgGXkAHvQND3dUXc3SqJ1GLkAdKqXbDZlM4PrWaRzUNnTTpJq7NldSXb8w5pp1RR/CayWCimgKSATgUXZTpxSNn+0cgYU1F/ap3BQtMSGAY3PTPJtAd2+tLHIWm1Bw+zbUb6nIDgLTG+y7xnJJ9DUXmWZbG1sigReS+drczdxUCajMyknsOKcHhW2Lqp2+lVPPDDagGKLAWRfz4BJHNL9tnxnI64pIYvMYKUP17VeSwjAIfnnPFAEMNzO0oQnIrSdVkUo4yDTEhjj+6KlpAcPqmlPauZoRmM/pWKDnmvUGVXUqwyDXMajoWSZrT8VqkyWjlqKfJHJC2yVSp96ZVCFopKWgApVJVgR1FJRQBPcXEs+PM7VWpzMW5NNpDCiijIHWmIKKmht57g4hQt9K6Ky8Os2Huzx6Urjsc9b2090+yFSa7DTtDjt8S3HzPWzDbQWyhIVAx371PUNlWAYAwOBRRRQAUUUEgDJ6DrQBm6reiytWb+JuF+tcdaxsAZZPvPzU9/cnUr7j/Vx/wAxT/YVpFGM5EU77IifXiqePLtCx6tUt0dzLEPXNJffLb7R6imxLdIqwcRL9KlpkfEYFPqkDHqasjpVQVYQ0CJaKSloELRSUUALRSVDM+1cDvQBVmbeT7VZtjmID0qnU9o3DLSH0LtFFFMRXuhmA1q2Lb7ZTWfKMxMKn0l8wbfTNTLc1h8LRfFFB4YiiqOZ7gTgZqRBhfrUYG5gPSp6lm9NWQlNY9hTqYOTmkaBjAxTTTqaaAK9w21MetVUI28VHcyl246CkTiKmhCEmRjnoKiZtrELTkOF5qPqxNAieHJJq1iq9uOCasj36UDK08KzIUb8Kx1LQyGGT8DW7nzM7RwO9Uru385Mj7y0CaK9QyjjNJDIWG1uoqRhkVW5lsyMHIprUL0xQ1MGMooooEFFFFABRRRQAUUUUAFFFJQAUUUUAFMftT6Y/SkxofRRRTEFFFJQAtFJS0AFOUZNNqaMYGaBMinPIUU2kY7pCfSlpDCnU2lFMBaKKKBBRRRQAU5etNpV60Ay8OlFA6UtUYjaWiigApcUUooENpKcRTaBgaz53wD78VckOBj1rNmbLbfSpkzWkrsiHSlBI6UlLWJ1kysGGDTGj7rTKlRs8GmIRWxwasdqiZf7wpMsvI5FWmZyhfYnFLTEcNT6q5k1bcWikopiJ45dpwelXQ2RWXU0cpXg9KCJRNCmO4UUm8Bd1VTJvNMhRFY7uTUTSFflHJpksgQVas7V5fnPQ96ylOx1UqLkQxQm4cIOvc10UMSwoESmwwJAuFHPrU9c7dz0YQUVZBS0lLSKCnCm08UDRZt1ywrcUYArJtV+atcUkRUFoooqjEKWkpaACse/tJA32u2J3DqPWtiij1C7WqMyx1BLkbH4cdRWlWLf6e2ftNr8rjnA71JYaiJ/3M3yyDjBpfD6GmkldbmvR9aSimQYGqaUsoM0Aww9KxLPU7myfZJyo6iu7rnNW00EfaIRz3FXpLczUpUttV2Na1vILtd0bc+lW686jea3bzITjFdRp+sR3GI5jtb1qHeO5ulGa5oG9WfqFhHexEYw3Y1ez3FOpp2MZRucjaXktjL9kvOnY10SsGG5eQap6vYrcwFlHzr0NYGlakYn+zTnjOATTtbVbFRlz+6/iR1lVbp8Lt9as5GMisqd971aRnJ9CGs1zku3sRWgxwpPpWc33Cf7xqhLYt24xEvuKmpqDCKPalpIoWikopiFoopKAFqC4/1dTVFP/qzQJkNvzF+NTVBbf6upGdRznpQA+lquLmA/xinfaIP74pALMMoa27E/6Mn0rEZ43RtrA8Vsaac2q1EtzaPwMvVkayM22a2KzdWXNm34VUNzCp8Jwb8TirXaqsv+tU1aqxFFfllFJMcyk+1PmGJM1Exy+aaJYsYy9WahiHU1LVIye4tLSUUxC0UlLQAVYilKnBqvSiglq5qA5Gaguv8AUNT4fu80y84gNOWxjBe+kZ9qMzoPevRdRO3TH/3K89sRm6jHvXfawdumv/uVyR+I9fFP3EjzXqSfekJ4oFFamJ2+k6FYXVqJZgSx961P+Ea0sfwn864ODU7y2Ty4mwKn/tzUf79Z8sjVuJ2v/CNaZ/dP50f8Izph/hP51xX9uaj/AH6cNe1EfxijlkK8TsD4W049AfzqJvCdkehP51zA8RakP4h+VTp4o1BfvYP4UWkHum0fCcIYMj4xXSW8P2eFYuuK5mw8SvcyiKReTXWZyAfWp66mkrqK7BRRRTMgpKWkoAK4/XddCA2lofm/iYUuu64IwbS0OW/iIriOSdzHJNUkS2JyTuY5J707FFPVS7BV5JqgSu7IWKNpXCKMk16FpOmrZRb3GXaqujaULdBPMPmPQGugqNzZtRVkBYKCzdBXn+o3Zurln7DgV0Wt3vlRfZ0PzP1rjcFmCDqaduhjfqya3XJLmrdKE8tQtJXQlZWPOnLmdxCAaiZcVNRTEmVqSpGXHIqOg0QUdKKKBluNw67WqrPDt+YdKVSVOatghl5oIvyu5jW5beQeg6VcqGSPyJt38LVNWaVtDqbvqFFFFMQVCysh3J07ipqWkMYkiuOPyp1RPFzuTg0iS87ZODRcLdiaiikqiRaayhh70tFIBqPzsbrUtROm8cdabHJk7G4IoHYmoopKYhaKSikAtJRSUALT4J3tZhMnTuKjo6jFKUbouEuV3Ox/dXcIdeQarpK9q3lTcoehrI0m88mX7PIflbpXRSxrIuxuhqIu+hVWFtVsQ3dslygP8Q5U1JpuqOj/AGO84YcKfWqUUz2snkTn5T901PdWq3K7l4Ycg0pRuFOpy6PY6vr0ornNL1Jg32O74cdCe9dHWaOiUbBRRTWkROXOKCbjqKrG8tl6uKiOo2w75quVk867l6is06pAOxqM6tEOimjlZPtI9zWorFOrr2U1GdXc/dWnyMXtYm9RWHHeXs/+rA/Kpf8AiYHqQKmxoma9JzWT5V8erisrVpLqziDB+SaaVxOVtzq6aWUdTXmbaheN1c/nUJurhusjfnT5Q5j0qcWsq4kYCsWWK0Rs+auB2rizLKert+dNLOerE1SVjOUVLdHZG5s0AAkHFSSajYsu1nFcRzRQCilodUmpWqqQTkmnxavZRDkEn61yVFDVxpW2Oiu9Xhlz5SkE0yHWliUAxkmsCigLI6ZvEbFdqx4qjNrVxMMED8qx6SiwGqus3yLtRgB9KT+2dQP8Y/Ksuiiw7l3+0LzJYPyajN7dt1c/nVaiiwExuJz1dvzphllP8bfnTKKBDtznqx/Om5PqaKKYBRRRQAUUUUAFFFFABRRRQAUx+lPpr9KAHDpRQOlFAC0lFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAwooooAKKKKACiiigBaKKKQBRRRTAKKKKACiiigAooooEFFFFABRRRQMKWiigAooooAKKKKACuk8OH/SQK5uug8PnF2tRLY0pbs76kpaSoEFFFFABRRRQAUUUUAFLSUUALRSUtACUUtFAxKKWkoAKKKKYBRRRQAUUUUAFFFFABRRRQAUUUUhBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB/9OpRRS1qISilooEFLSUtAxwqRajFSrSBEq1YWoVqdallolWpAKaKkFQy0TwDL5q9VW3HU1aoRLFooopkhgHtTTGh7U+igCE28Zphth2NWqKAKRtmHQ00wuO1aFFAGb5bjsabgjqK1KTap7UXAzKK0jGh7UwwoaAKNFXDbpTfs3oaAK1FTm3fsaTyXFAyKjJp/luO1Jtb0NADaMA9RS4PpSUANKRnqoppggPVF/KpKWgCsbO2PVB+VRnTbNuq1dooAzW0izbsahbQ7Q9M/nWzRRcLGA2gQn7rH86hPh8/wAL101FFwsco2gTjowqFtDux0INdlRmncVkcO2j3q9s1EdNvV/gNd7k0ZouFjz42V2OsbflUZt7gdY2r0TikKoeqii4cp5yY5R1U00gjqK9GMUJ6oKYba2PWNfyo5g5TzvNGRXoJsbRusY/Kojplk38FO4uU4PIpa7ZtGsj2IqFtCtD0LUXDlOPorrD4ft/4WP51EfDy/wv+tFwscxRXRHw9L/C4/OozoFyOjLRcLGDRWydDvB3Wojo96OwouFjLorQOl3o/hqM6feD/lmfyouKxToqybO6H/LNvypptrkdY2/KncLENFSGGcdY2/Km+XJ3Q0AMrsvDS8M1cdgg4Ndx4cXEDN71EjWGzOkooopEBRRRQAUUUUAFFFFACUUtJQAUUUUAFLSUdqAMW4OZmq3b/wCrFUpDmVjV2D/ViqYkTUUUUhhS0lFAC0UUUAFFFFABRRRQAUUUUAFFFFADJHEUZkboBmqNtdi7B+Xj3q+QGG1hkGkVEQYQAfSgdzFvoSo37h14FZJY7sGt3Uo3YhlXI9qyRC5PEbflStqae0drEBoClu1aCWdw/RQPrVlNNl/jYfhTIcmZ/QH2quATjFdCmnQrksSc1aW3gQYCincmxz6wTO6sqkgVai02XkkgZrbGBwOKWlcCjFZIibHORVhIIU+6oqaigBAAOlFFFABS0lLQAUUUUAVp7S3uRiVQffvWBc+HQctbNj2NdRRRcLHnsul30J5Qt9KptDOv3kIr07OetMMcTfeQGnzCseY4YdQaMMegNelm2tj1jX8qBa2w/wCWa/lT5gsebiKZvuoTU6WF7J92Jvyr0QQwr0QCpBgdOKXMFjiIfD95JzIQorbtvD9rFhpSWP6Vu5opXHYZHBDCMRKBUtJRQMKWkooELRSUUALWBr199ngFtGfnk4rbllWGNpX4CjNcAZGv7trqToOF/CqiiZOxJbxeTGB3PJqalprHCk+laGBTX95clv7vFM1E/uR9RU1sOGf1NQaj/qh9RSexUfiGL90UtIPuilqgFqRDg1FSg0CLgPFOqFGqWgQtFFFAATgZrPkbc2aszvgYFU6BhTrVsTFfWm0xDtnB9aTGjXooopkiNypqLS32ytGe9Snoaz7d/Ku1bsTUy2uaUvisdM4/iFN96kHI+tJsGaLkyp3YIMDPrT6KKRqNb0oo6migANZ885Y+XH+NaFU5bbJLR8GgRnyABKf/AMsqbIGHDjFQ72VdvUUxD0KhctUOeppoORg0vt60AXYeI6lCNJ97haWCPgE9Ks0DGqoUYWopE/iFTUHkUAc9eQlG8+P8aajB1yK1pUHKnoaw2U2suD91ulPYhq4dGNIac/XNNqjMSiiigAooooAKKKKACikooAWkoooAKKKKACmv92nU1vumhghw6UlA6UUAFFFFABS0lLQAVYb5Y6hUZNOnOFxQLqQJ0z606kHAxS0DCiiigBaWkpaACiiigQUUUUAWYpM8Gp6oA4qzHJu4NMzlEmooopki0lLSGgQ0tSbhSUxzgUDSIpHySfSs7O5iTVqZsJ9ap1lNnVSWlyQqQM02nI5Xr0qfYjjIqDYr0VK0TDpUZBHWgCaNwflapChHKVUqxHJ2amICFb/ZNG5k4cZFTFVamYZevIppiauODBulLUWwHlDg+lHmMvDiqUjJ0+xLSUgYN0NOqiLC+YwG3tTCRjKnBFRvIU+aqruZDnpUuRcYXAlnOWNaljqDW/7t+VrKorJq50wm47HcxyxzLujOakrioLmW3bKHj0rpLXUYrgbW+VqzaaOmMlLY0aWkpaQxRTlpop69aTKRqWgrSqjajir1ETGo9QoooqjMOnNAZWGVOaOvFZEwlsZfNTLRnqPSgqKvoa9LUUUqTIHQ5qShCatoxaxdQ07eftFt8rrzx3raop3Fre63MXT9SE37mb5XXjmtisDVbEg/a7fhh1xTtN1VZx5MxwwqWuX0NVaorrc3aDgjB6UlFUZ2OY1OwMDGeIZQ9RWE0efmTg16G6rIpRuQa4u8tjZ3BQ/cbla0TT0ZgouDvElsNYkgIhueV9a6yGaKdd8bAiuDeMMKLe7uLF8qePTtWcoNbHTGpGektGegkZBBrznU4PIvGC8dxXZ2OrW90oDHa3oa57XUUXIYfxVUNVYwqRcZqRc029M9ttY/MtOJya5+xkMU+3s1b5qo9iqmtpdyGdsRkevFVJBgRr7ippzllX3pjjMyr6c1RK6F3pxSUp60lIAooopgFFFFABUcv+rNSVHL/qzQJ7FaDiMnsKYzx4X51IZsY71lSPMZxDHnDcYFBtXjuI23Bvm6DtSBFS4TbMwHAzUPPqatXf8Arjmq9UiWS2wYyn5j0rudIbdbH2NcRbcTfUV2ein9y6+jVlL4jpp/w2bFUtSGbNqu1WvButXFOO5jPY86n6qas9qguOg+tTDoK06ma2IZxlc+lVD1q+4yhFZ9AMsx8LT6aOBS1ZiLRRRTAWikpaBC05Rk02po1y1AnsXohhagvj+5q0OlUr8/IBRLYypK80Qad/x+R/Wu3147dOPutcVpo/0yL612uvI0lgqJ1Nc0dz1cUrxR5wOlLW/D4a1CVQ2VANX08JXB++6/nV8yM+VnI0lduvhFf4n/AFqUeEbfu5/OjmQcjODzRmu/HhOz7u350HwnZ9nb86XOg5DgKK73/hErbs5/OmnwlD2c/nT50HIzlNLOLxPrXqy/dH0rl4PDCwTLKr9PeupA2gD0rNvU1fwJBS0UUGYVymva0IFNpbHLngn0qfXNaSyjNvD/AKxh19K89LNIxkc5J6mqiiWw5YlmOSetFLRVCAZJwK7HQ9IwBc3A+gNVdF0czMLicYUdBXcBQo2qMAVL1N0uVCY9KjkYIhdugqWuf1278mDyVPL04mFR9Dlby4NzctKTx2ptkm9zKenaqjZ+6vU1sxRiKMIKuCu7mOIlyx5SQ89ahZcVNSHmtjhuQUlPZcU2goQ1AwwanpGGRSKTsVqKUjFJQWFWIjxiq9SxnBoFLYkljEqFTVKNjja3UVo1SuU2MJl/GlJdSqUvssKKByM0VJsFFFFABTHjVxz19afRQMqh3hO1+V9asAhhlTmlIDDBqs0bxHdF09KQblmiokmV+DwfepadxNBUUke4ZXhhUtFMRFFLu+VuCKlqCWPPzpwRSxShxg8EUhtdUTUUUlAhaSiigYUUlFAhrA8MvUV1unXIurcZ+8vWuUq1p9wbW5AP3W61nNWd0dFP34uDOmuYFniKnr2NZtjetG/2a47HANbnHUd6xLm3QXWDwHGAfeq3RzrflZcvbXzlEsXDryCKv6TqgnX7PPxIvFULaV42+zz9ex9ahvrRgRc23DrzxUSjfVG1GpZ+zmdpWbqMZaPcvaoNK1NbyPy5OJF4IrWdQ6FT3qIs1qQ0aONNJVi5iMMhB6VXrpPNatowooopiFqSNSzAVGK1bK3JO41EnZG1KF2aVrEI0qzSDgYpawO0K5jxIf3K/Wunrl/Ev+pX61UTKr0OQoooqwCiiigAooooAKKSloAKSiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAprdKdSHkUAKOlFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAwooooAKKKKACiiigAoopaACiiigAooooAKKKKACiiigAooooAKKKKBBRRRQMKWkpaACiiigAooooEFbehHF4tYla+inF4tTLY1pbno9JS0lZiCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA//1KtFFFakhRRS0AFLSUtAxwqVajFSrSY0TLVhagWrC1DLRKKkFMWpBUstF2AYSp6jiGEFSUGbClpKWmIKWkpaQBRRRTAWiiikAUUUtMAooopAFFFFABS0lFAC0mBS0UAJtX0pNielOooAj8pD2ppgSpqKBkH2cdjTfs57GrVFAFPyHpPJertFAFHYw7U3a3pWjSUAZ+D6UVf2j0pNielAFGirvlJ6UnkpQFynRVryF9aT7OOxoArUVY8g9jTfIegCGlqTynpPLf0pgMop21vSkwfSgYlFLRQAUUUUAFLk0lFIBcmkzRRQAUm1e4FLRQA3ZGeqik8mAnlBT6UdaAOE1MKLxggAA9K6/QU22efU1x2oHdev9a7rSF22S+9DKXws06KKKDMKKKKACiiigAooooAKSlooASiiigAoPQ0U1jhT9KAMMnLE1fg/1QrOFaMH+qFUxImooopDCloooAKKKKACiiigAooooAKKKKACiikoAKKKKACjp0oooAM0UUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUtJS0AFFFFAC0UlLQAUUUUALRSUUALRSVWvLlLS3ad+w4oQM5/wAQXhYrYQnlvvY9Kzo0EaBB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gh6PxXVzoHhZfUVlF9C8RHVTRC0a3VurD7wHB96LeYt+6l+8P1qHTnzEU7qTViaHeN6cMKpaETSkZt5byWkwvbbj1ArqNPv0vYA4PzdxWVFIJkKOOehFZLCXSbrz4v8AVseRUTjbVG1Grze5Lc6y9thMuR1rnHRo2wwrqbe4juYRLGcgiobi1SYZ704zIq0b6o5mlAzWibCTNWobALy1aOaMI0ZPcpW1qzkEjit6OMRrgUqIqDAp9Yt3OuMVFWQUUUUigrmPEn+pX6109cz4j/1C/WqiZVOhx1FJS1YCUUUUAFFFFABRRRQMKKKKBBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQMKKKKACiiikAUUUUwCiiigBaKKKACikpaACikooAWikpaACiiigAooooAKKKKACiiigBaSiigBaKKKACiiigQVqaQcXi/WsutHSzi7X61MtjWl8R6bSUDoPpRWYBRRRQIKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP//Vq0tFFakhRRRQAU6kpaQDhUy1EKmWkykTLVhahWp1qWaIlFSDrUYqZBlhUFF9RgCnUlLTMgpaSloAWiiigApaSlpgFFFFIAopaSmAtFFFIAooooGFFFFAC0UUUAFFFFABRRRQAtFFFAgooooAWiiigYUUUUAFFFFAgooooAKKKKBi0UlLQIKTApaKAE2r6UmxPSnUUAM8pPSm+SlS0UAQ+QvrTfIHrViigZW8g9jSeQ9WqKAuU/Jek8t/SrtFAXKOxvSkww7VoUUBc83uLa4a8Y7Dgt6V31khjtUU8cVa4ooY+bSwUUUUEhRRRQAUUUUAFFFFABRRSUAFFFFABUcxxGTUlQXJxCaEDMUdK0oP9UKzRWlB/qxVsSJqWkpakYUUUUAFFFFABRRRQAUUUUAFFFFACUUtJQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lFAC0UUU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5g5TzSiu2uPD1rJzCdhrAutFvLb5gNy+tO4rGRRQQQcHiigQUUUoBY4HWgBKKe8bxnDjFMoAKKKKYBRRRQMKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAClpKWgAooooEFFFFAwooooAKKKKACloooAKKKKACiiigQVZszidfrVap7U4mX60pbGlP4kepx/6tfpT6jh5hU+1SVkN7hRRRQIKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/9evRRRWhIUUUtABSikpwoGPWp1qEVOtJlInWp1qFamWoZoiQVctxwTVQVdhGEqQlsT0UUVRmLRRRSAWiiigBaKSloAKKKKACiiloGFFFFAgooooGFLSUtAgooooGFFFFABRRRQIWiiigAooooAWikpaBhRRSUALRRRQAUUUUAFFFFABS0lFAhaKKKBhRSUtABRRRQIKKKKACiiigAooooAKKKKAClpKKAFooooAKKKKACikooAWkoooAWkoooAKKKKACs+/PCitCs2/PzKKcRMoVowf6sVnVowf6sVTBE1OptFSMdS03NGaAHUlJmlzQAoGaCpFJmpBIO9ICOip+DRtFAEFFS7BSeXQBHRT/LNJsNMBlFO2NRsagBlFO2tRsagBtJTtjUbGoGJminbGo2NQIbRTvLNGw0ANop+yjYKAGUZqTYKXaKBkWaOtS4FLRcCAL7U7aalopAR7TRsPrUlJQBH5S9cCl8tR2FPooAbtHoKMD0p1NYBhg0AN49qTI9qga1U9zVeSARjPP50wJbpVkjxnlTkU2O8gEYDthsc05YYioPNIbeE9RQA77ba/36Pttr/fFM+zQf3aX7NB/doAX7faDnf0rjLm4OpX5k6xp0rY1yWK3t/JiHzv0rJtohDEF7nrVxRlN9CekpaSrMwooooEFFFFABRRRQBigbLt19TU9R3Q2Xat61IaEWwooopkhSE4GaWo3PIFACDpTqKfbwSXkvlx9O5qW7Fxi5Mls7R72XA4QdTXXxxJCgjjGAKZbwJbRCNB061L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UUUAFTW/+uX61DUsP+tX60nsVT+JHqVsc26H2qaq1mc2qH2qzWRctwooooJCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigD//Qr0UUVoSLRRRQAtKKSnCgZItTpUC1YWkykTrUwqJamWs2aoeK0IxhBVBeorRHApImQ6iiiqIFoopaQBRRRQAUtJS0AFFFFAwpaSloEFFFFABRRRQMKKKKBC0UUUDCiiigAooooEFLSUUALRRRQAUtJRQMWiiigAooooAKKKKACiiigAooooAWikooAKKWigAooooAKKKKBBRRRQAUUUUAFFFFABRRRQAtJRRQAUtJRQAUUUUAFFFFABRRRQAUUUUAFZV6cygelatY92czGqiJlatG3/1dZ1aFv/q6GCJ6KKKQwooooAKKTIoyT0oAWigUtAACR0qUSetRUlAFnIPSlqtkinhyKVgJaKYJB3pQwNADqSj6U0sB1oAdRTc0ZoAWjNJRQMXNGabRQA7NJSUZoAWikzRkUALRSZFGRQAtFJkUZFAC0lJuHrRuFAC0U3cKN4oAdRTN4pN4oAkpKj3j1pu8etAElMcBhg03evrSb19aAEAwMUU0svrSbxTAfSFgqlj0AzTPMWsbWr3ybXy0+8/SmkJswppTf6g0p+4h4q3Ve1i8qIA9T1qetTBu7CiiigkKKKKACiiigAooooAzNRXG2QdqQHIzVm+Tfbn2qlC26IGhbldCWiiimIKiHJJqRjgVD8zERRjLGk3YcYtuyHpG9xIIYup6muxs7VLSIIo57moNOsFtIsty561pVle+p0OyXKhKSlpKZIUUUUALWVqOmx3iFlGHHQ1qUUAcRas0bG3l4ZavVY1e0KkXkI5HWqkbiRAwrRMxmrFadcNn1qCr0y7l+lUaZIUUUUwCoyhU7kODUlFJq402ti9bamV/d3H51sK6SLuQ5FcuyButJHLNbtmM8ehqOVrY1UlLc6g1ExxVW31GKb5ZPlb3qw4zyORQmNqwwmkzRSVRIyQZG8dRUdzLi3LDqRip6zrs4ZIx3NJhcWFdkY9TzUtJ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3zH3qYALwoxS0UyW2wooopiCiiigQUUtFABRRRQMKKKKBBRRRQAUUUUAFFFFABThSUtAC0ZpKQmgAJpKKKBhSUUUAFFFFABRRRQAUUUUAFFFFABRRRQCJ7ddz59K0Kr26bUz61YrzqkryufTYanyU0goopKg6BaSiigCvP1WrnYVVn/hq12FdlD4TwcxX7wWiiitzzgooooAKKKKAFooopAFBIUZbgCjIAyayLiZrpzDFwg6mk3Y0hDmGyyteSbV4jX9amwAAqikUKi7V6Cr1pBuPmv0HSoOnyRatYfKTcepqR27UO/YVDmixLYtKKbSigQy5bbD9Tip4htiQe1Z9226WOEeoNahGDgdqRdtEJRRRTJCkoooAKKKKACo5fu1JUcnShCZk30vlW59TxWDGMLz1NXdTl8yZYR0HNVaHuawVkFFFFIoQ11WmReVbA92rmoYzLKqD1rs0UIgQdqS3Co7QSKWpNstGNckOldHrT4hCf3q53tTZnR2Ciiig2CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKWkooAWiiikIKKKKYwooooAKKKKACiiigAooooAKKKKACilopAFFFFABRRQAT0BP0ouAUVaisbqb7ifnWpDoMrczNt+lZSrQjuy1TkzBqRIpZDhFJ/Cuvh0e0i5Ybj71pJFFEMRqBXNLGL7KNVQ7nHw6Pdy8sABWtDoUK8ysSfSt7NJXPLETl1No0oorxWdtD9xAKs9OBRRWDbe5olYKKKKQwooooAKKKSgBaSiigAooooAKWiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooopgFFFFIAooooAlh/wBYK0Kz4f8AWCtCvRw3wHLV3CiiiugxCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKYwooopCCkoooA/9eOlpKWtBBTqSlFAEi1aSqy1aSoZpEsLya0l6Cs6IZcVpVKHMWlpKWmQFLSUtABS0lLQAUUUUAFLSUUALRRRQAUtJRSAgcYas65cwTJKPoa1JBxms69TfDkdqY0aSkMAw6GlrP0+bfF5Z6rWhQAtFJS0CCiisq8nJk8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tkcnlT3rMroda1ATH7PHyB1Nc9Wkb21MaiSl7oUUUVRAUUUUAFFJRQAtFJRQAUUUUAFFFFABTHVuHThhT6KTV9CoScXdFu2u1mGx+GFXKwZYt3zpwwqe21AqfKn/ADrkqUbao93DYxTVpbmvS00MrDKnIp1c56AUUUUAQz/cqyOg+lV5v9XVgdBXXQ2PDzP40FLRRXQeWFFFFABR9aMgcmsi5unnYwQdP4jUt2LhDmYtzdNM32e3/E0RxpCm0fiaairCuxOTVyG3J+eX8qk6kklZDYoTJ8zcLV8YUYXpRRQIKKSloEFFFFABWbqkuyAIOrGtKuc1GTzbjYOiik9ioq7SNnSE2WpPvWnVayTZbKPXmrNJbDqO8mFFFFMgKKKKACiiigA6VyF7L9ouyey8V0l9MILZm7kcVyaA4yep5pFwXUdRRRQWFXLCDz7gDsOapmun0y38mDeerc0t9Cr8seY0unAqjqE3k2zN3NXq5vWZ98iwDt1qzlSuzHUYHNLRRUHUFFFFMAooooAKKKKACiiigAooooAKKKKACiiikAUUtFABRRRQAUUUUAFFFFABRRRQAUUVPHbXEv8Aq0JpOSW40myCitiLRbqTl/lrSi0KFeZW3VhLEwRoqUmct16VZis7mb/VoTXZxWFrD9xfzq2Ao6ACsJYz+VGqod2cnDodw/Mh21pw6JbJzJ81bVFc0q831NVSiiCO1t4RiNQKn+lFFZN3LSCiiikMKKKKACiiigAooooAKKKKAFopKKACiiigAooooAKKKKACiiigAoopaACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKYBRRRQAUUUUgHx/6wVpVmR/6wVp16GG+E5a24UUUV0mIUUUUAFFFJTAWikooAWikopALSUUUALSUUUAFFFFABRRRQAUUUUALRRRQAlFLRQB/9GF/umslutakhwhrKPWnI6KOwVNAMyioasWwzKKlGstjcTpVharpVhapnNE0LYcE1aqvbjCVYpEvcKWoJ5lgjLmsVtRuCcjAFUotmM6qjozoqKw4tUcHEoyPataKeOYZQ/hQ4tFRqRlsT0UlLUli0UlLQAU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GP1pN2NIQch9xcvct5UPCjqaIkP+qhGfU0+C1aQDPyp+prUREjXagqDp0WiIobdYuTy3rU9FFAgooopgFFFFAgooooAa7hI2c9hXLx5llLn+JsVsanN5cGwdW4qhZx/vI09wamWxtRWtzqIxtiVfQU+lPXFJTMm7sKKKKBBRRRQAUUUyWQRRtIewoAxNSc3FwtuvReTWUwwSvpWpZKZC9w/Vsis2UYkI96yjO7Z0uHLFEdFFIfQdTWhKVy3ZwG4uAvYcmuvACgKOgrP022+zwbj95ua0KIrqZ1ZX0QyRxGhc9hXFSSGaZ5T3PFb2sXOyMQJ1aueAwMU2KkuotJS0lI2CiiigAooooAKKKKACiiigAopaSkAtFFFABRRRQAUUUUAFFA5OBzViO0uZPuRmpckt2NJsr0VsRaLdSfeIX61oxaDEOZWz9KxliYLqaKlJnLVMlvcS/wCrQmu1i061h+6ufrVwIi/dUD8KwljOyNFh+7OOi0a7k+98v1rSi0CMcytn6V0NFYSxM31NVSiijFp1pD91c/WrgVF+6AKWisHJvc0SSFoopKQxaKSigBaKSloAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBaKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAcn3xWnWYn3hWnXfhfhOatuFFFFdZgFFFJSAWkoooAKKKKACiiigAooooAKKKKACiiigApaKKACiikoAWiikoAKKKKAP/SqzHCGsytG4PyVnUSOmlsFWrQZkqrV2yHzmkty57GwtWFqutWV61TOZGnEMIKkpq8AU6kSZOqMQFT8axq19VHKmsiuiGx59b4mFSxTPC4ZTUVFUZptao6y3mE8Ycde9T1g6XIRIY+xrermkrM9GnLmVwooopFi0UUUAFFFFABRRRQAtFJS0AQYw9Ej7ELAZxTpAcbl6io45FkGO/cUAJFcJL04PpVmq726NyvBHpUqbguH60ANeGOQYYVQksWXmM5FadLQBz7KynDDFNrfeNHGGFUZLLvGfzoHcrR3MsXfIrQiu45PvcG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RywpX1Y2SWW7fkcdlH9a0ILQJhpevp2qxFAkIwvJ9TUtSb+SCiiigQUUUUAFFFFMAooooEFLSVVupxDGfU0AY97J9ovAg6LV+wTddk9lFZdmu5nnatzSk+R5T3JFQzeOibNaikoqjAWikooAWikpaBBWVqchbbbL/H1rUYhVLHoKw4SbiZrlunQfhWdSVkbUYc0i1GoRAo7VjXa7ZjW3WTfDEgNc9F+8dVVaFGtDTLX7RN5rfdX+dUUjaaQRJ1PWuwt4Vt4hGvbrXVuczdkT0x3EaF26AU4Vgavd5ItYz9aowtd2MmeU3E7THpnio6MYGBRSOhKwUUUlAxaSiigAooopAFFFHFABS0g56c1KsMz/AHUJpOSW40mR0Vfj0y8k/hx9avR6DOf9YwFZSrwXUtU5PoYVJmutj0K3Xl2Jq9Hp1pH0XP1rGWLj0RaoPqcSsUr/AHEJq5Hpl5J/Dt+tdqscafdUCpM1jLFy6I0VBdTlY9BlPMjitCLRLVOWJJrZorGVeb3ZoqcUV47O2i+6g/EVYAVfugD6UUVk23uXYWkoopDCiiigAooooAWkoooAWikooAKKKKAClpKWgAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACloooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBV+8K1O1Za/eFanau/C7M5q24UUUV1mAUlLSUgCiiigAooooAKKKKACiiigAooooAKWkpaACiikoAWikooAKKKKYwooooA//06FyeKo1cujVOiW510/hCr9iOSaoVpWI4JpR3Cp8JqLVmIZYCqy1cgGXFUznNIUtJS0iTO1NN0IYdjWBXXOgkQo3euZuIGgcqenY1tTfQ468HfmRBRRRWhzIv6cCbgV0dZOmwFVMrd+la1c8ndno0o2iLRRRUmgUtJRQA12CDJpVYMMqaXqMGqsiNF88P5UAW6KgimDgBuGx0qegAooooAWqVxAxPmxcMKuUUAUoLoP8knDVdqpPaiT504aoIbloz5U350AaNLSAgjI5FLQAUUUUANZVcYYZqnJZ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bh2LlFMWRG6Gn0EWCiikpgLSUUtACUUUUAFFFFABRRRQAUUUUAFRyOFGB1odwoqsSScmky4xNWwPyNV+szTz94Vp1wT+Jn0mHd6aGSIsiFWrOa9lVPs8akv61qVWO2G4DY+/3qqcrOxhjKSlHmtsQW9ixPmTnrWqAqDagwKM0V0HlN3CiiigQUUUUwCiiigAooo6daACiq8lzHH7ms+W5kk6cCgLF2a6WMYTk1h3UjMvPVulTVWVfPuQvZeaGVFdS1t8q12jqRXQ2cflWyj1Gaw2Hm3KQjoDzXS42gKO3FT1LlpBIKKKKZgFFFFAwpaSkYhVLHoKAM/UZSEFun3npsaCNAoqvGTcTNcN0/hq3XFWnd2PQow5UFY+oSAyBV5NXru5W3j/ANo9Ki0yxaZ/tdyPoKujB/ETWmkrF7TLPyI/Nk+838q1aKY7rGhdzgCuo4m7le8ultYS56npXIgs5Mr/AHm5qe6uGvZy38C9KhJFJmkI2EopQrN90E/SrCWdzJ91D+NQ5pbs0UW9itRWtHo14/XA/Gr0egD/AJaufwrKWIgupapSZzWRSgFugJrso9GtE6jd9aupZ2sf3Y1rF4xdEaKg+rOGS2uH+6h/KrkekXknYD612oAX7oxS5NZSxcnsaKgupy8egOf9a2PpV+PRLVPvEmtilrGVeb6lqnFdColjaR9EB+oqyqIv3VAp1JWTk3uXZDsmkoopDCiikoAWikpaACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAWikooAWikooAWikooAWikooAWikooAWikpcH0p2YrhRS7W9Kd5bntVKnLsLmQyipRC9L5DVSoz7C9pEhoqwIPU07yB61aw8yfaxKtFW/JSneUnpVLCy6i9silRg1e2J6U7C+lWsL3ZPtvIo7W9KNjelX6Kr6rEXtWZ9FK/3jSVwtWZ0IKKKKQwooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAUdRWmOlZY61qDoK7sLszmrbi0lLSV2GAUUUUgCiiigAooooAKKKWgBKKKKAClpKKAFopKKACiiimMKKKKACiiigAooooA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KKACiiigAoopOB1oAWio2liX7zAVXa/sk+9KKBFyist9YsF6ODVV/EFqv3Rn8aLAb1Fcy3iSMfdjP51XbxJJ/DHinZhc66iuKbxDeH7uB+FQNrl+38Q/KjlC53lFeenVr49XrQ07UbqS4VHbINDQI7GiiipGFFFFABRRRQAUUUyQExsB6UwKk2o2cJKs/I7UtpfRXmfL7Vw8p2SuDzya6XQEAiZ/U0NAdDRSUtICpN9+oamm+9UNeXV+JnZDZBS0lLWRYUUUUwEopaSgBaKKKACiimsSFJFCV9AFzS1kNI5Oc1pQsWQE1pOlyq5coWJaKKKyICiiigAooooAKKKKACiiigAooooAKKKKACiiigAoopKAFopKKAFopKKAFopKKAFpKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigArTT7orMrTT7grtwvU563QdRRRXac4UUUUgCiiigAooopgFFFFABRRRQAUUUUAB5GKYd3Sn0UAFFFFABRRRSAKKKKYwooooA//XwqKSlqTvHxjLit5OgrDhGZBW6tVE5625Zh5cVqCs23+/WiKZiSUtNFOpALS0lLQAtFFFAC0UUUALSEZFLRQBECQcGpQc01lzyOtMBoAkoOCMGjOaKAMe60mNwXh4Y81NpkUsMTRyjHPFaVFFwKOpRCW0YdxzXGBwhz1rvJ2VImZhkYrgJWDSMy8AnpVITLY1G5SLyVPFU3keQ5ck0yiqJCiiigAqe3uZrZ98R/CoKKAO1sNUiuxtf5X9K1a82VmRtynBFdTpurCTENwcHsahopM6Gsq/DZBZuPStT3FMdEkG1hmkM5tJijHeAV9MVdgcSsNhwADx3qS4tDHgwruHcVJaWxJEki7SKYFKQNt8wZLexqjLLKSGJwwrcvbZSvmZwF7CsEq244GfegGREt97uad5hcYYDPrTljLkqfvdquxafcMQGG0d80CKYHyAg4XPX0rVtRJGwEeSOuatxadDGCrcg1fREjUKo4FIZm3UpA3g8jtU9lK8iEv+FY95MDKccYrS0xVKbwefSmwNSiikpALRRRQAUUUUAFFFFAC00oDS0tADRke9OoooAKKKKACiiigBaKSloAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiikAUUUUAFFFFABRRRQBla0+2wf3rga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", + "Persistence": "api", + "UpdatedAt": "2026-07-07T16:03:26.321816Z" + }, + "CreatedAt": "2026-07-07T15:30:22.861099Z", + "UpdatedAt": "2026-07-07T16:03:28.375093Z" + }, + { + "ID": "user-admin-npc0-com", + "DisplayName": "平台管理员", + "Email": "admin@npc0.com", + "Status": "pending", + "Roles": [ + "server-admin" + ], + "PasswordHash": "pbkdf2-sha256$120000$Dh9OBY2OONKFQKEkZGbVDA$10y+i3olXU7l6VrgMo7iOD+SBsOK3aqsOtwiN6fXQeo", + "Profile": { + "AvatarURL": "", + "Phone": "13148740782", + "QQ": "602269287", + "ContactNote": "" + }, + "Theme": { + "UserID": "", + "PaletteID": "", + "BackgroundPresetID": "", + "BackgroundImage": "", + "Persistence": "", + "UpdatedAt": "0001-01-01T00:00:00Z" + }, + "CreatedAt": "2026-07-07T15:32:30.444248Z", + "UpdatedAt": "2026-07-07T15:32:30.444248Z" + } + ], + "aiProviders": [], + "gamePlugins": [], + "serverInstances": [], + "runEndpoints": [], + "jobs": [], + "artifacts": [], + "logStreams": [], + "auditEvents": [] +} \ No newline at end of file diff --git a/platform/AGENTS.md b/platform/AGENTS.md new file mode 100644 index 0000000..37823fc --- /dev/null +++ b/platform/AGENTS.md @@ -0,0 +1,26 @@ +# AGENTS.md for platform + +This file applies to `platform/`. + +## Backend Structure + +Keep definitions out of business logic: + +- Request/response structs go in `dto/` or a dedicated contract package. +- Database tables go in `model/` with field comments and tags before migrations or repositories reference them. +- API route declarations and handlers go in `api/`. +- Business aggregates and value objects go in `domain/`. +- Protocol payloads go in `protocol/`. +- Shared helper functions go in `shared/` only when at least two packages need them. + +## API Rules + +Every HTTP/API handler must have OpenAPI-style comments when implemented. Request bodies, response bodies, and errors must reference named DTO structs. + +## Database Rules + +Prefer model-first table definitions. Do not define table schemas only inside migration SQL. Migrations may use raw DDL only when the model remains the source of truth. + +## Platform Boundaries + +Plugins and platform_web must never receive run credentials, raw host paths, or AI provider keys. All access must pass through platform authorization and bounded DTOs. diff --git a/platform/Dockerfile b/platform/Dockerfile new file mode 100644 index 0000000..9e9b163 --- /dev/null +++ b/platform/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.25.1-alpine AS build +WORKDIR /src +COPY platform/go.mod ./ +RUN go mod download +COPY platform/ ./ +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/platform ./cmd/platform + +FROM alpine:3.21 +RUN addgroup -S platform && adduser -S platform -G platform +WORKDIR /app +COPY --from=build /out/platform /app/platform +RUN mkdir -p /data/platform && chown -R platform:platform /data/platform +USER platform +EXPOSE 8080 +ENV PLATFORM_ADDR=:8080 \ + PLATFORM_STORAGE_BACKEND=file \ + PLATFORM_MYSQL_DSN="" \ + PLATFORM_DATA_DIR=/data/platform \ + PLATFORM_METADATA_PATH=/data/platform/metadata.json \ + PLATFORM_LOG_BODY_BACKEND=file \ + PLATFORM_LOG_DIR=/data/platform/logs +ENTRYPOINT ["/app/platform"] diff --git a/platform/README.md b/platform/README.md new file mode 100644 index 0000000..314d479 --- /dev/null +++ b/platform/README.md @@ -0,0 +1,76 @@ +# platform + +Backend control plane for the game server management platform. + +## Responsibilities + +- Users, roles, permissions, sessions, and audit. +- Game management plugin installation metadata and marketplace views. +- Server instance records and lifecycle orchestration. +- AI provider configuration and platform-mediated AI invocation. +- Run registration, capabilities, jobs, artifacts, log stream metadata, and storage adapters. + +## Required Directory Plan + +Implementation should use dedicated directories for: + +- `api/`: route wiring and HTTP/gRPC adapters. +- `dto/`: request and response structures. +- `domain/`: business types and aggregates. +- `model/`: database models only. +- `repo/`: repository interfaces and persistence implementations. +- `service/`: use cases and orchestration. +- `protocol/`: run, plugin, artifact, log, and AI contracts. +- `validator/`: validation rules. +- `config/`: configuration structures and loading. +- `shared/`: small shared helpers. + +Do not put DTOs, database models, or protocol structs inside handlers or service functions. + +## Development Baseline + +Tooling: + +- Go 1.25.1. +- Module: `browser.local/platform`. + +Commands: + +```bash +go test ./... +go run ./cmd/platform +``` + +Runtime configuration: + +- `PLATFORM_ADDR`: local listen address, default `:8080`. +- `PLATFORM_STORAGE_BACKEND`: storage backend, default `file`; use `memory` only for tests or disposable local runs. +- `PLATFORM_MYSQL_DSN`: MySQL DSN used when `PLATFORM_STORAGE_BACKEND=mysql`, for example `platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true`. +- `PLATFORM_DATA_DIR`: default platform data directory, default `.platform-data`. +- `PLATFORM_METADATA_PATH`: file-backed metadata snapshot path, default `.platform-data/metadata.json`. +- `PLATFORM_LOG_BODY_BACKEND`: log body backend, default follows metadata backend except MySQL uses `file`; supported values are `file` and `memory`. +- `PLATFORM_LOG_DIR`: segmented log body directory, default `.platform-data/logs`. + +MySQL configuration example: + +```bash +export PLATFORM_STORAGE_BACKEND=mysql +export PLATFORM_MYSQL_DSN='platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true' +export PLATFORM_LOG_BODY_BACKEND=file +export PLATFORM_LOG_DIR=.platform-data/logs +go run ./cmd/platform +``` + +MySQL is the platform metadata database here. It stores the platform metadata snapshot table and should later hold normalized users/plugins/servers/jobs/audit/log stream indexes. It is not the high-volume log body store; keep log bodies in segmented files locally, or add a future ClickHouse/Loki/OpenSearch/object-storage `LogBodyStore` adapter for production scale. + +For local direct debugging, copy `platform/.env.example` to `platform/.env`, edit the values, and run: + +```bash +go run ./cmd/platform +``` + +The platform process automatically reads root `.env` and `platform/.env` before loading configuration. Explicitly exported process environment values still take precedence over values in those files. + +For Docker, the root `docker-compose.yml` sets platform data under `/data/platform` and mounts it through the `platform-data` named volume. + +Current executable behavior includes the platform API, local auth/session support, durable file-backed metadata, segmented log bodies, run control/job/log/artifact routes, plugin bridge dispatch, and platform-mediated AI invocation. diff --git a/platform/api/artifact_download_handlers_test.go b/platform/api/artifact_download_handlers_test.go new file mode 100644 index 0000000..5d1cff2 --- /dev/null +++ b/platform/api/artifact_download_handlers_test.go @@ -0,0 +1,177 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "browser.local/platform/domain" + "browser.local/platform/dto" + "browser.local/platform/validator" +) + +func TestArtifactDownloadAPIWorkflowIsPlatformMediated(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest())) + postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download API"}, adminSession) + postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-api", ServerInstanceID: "server-download-api", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-api"}) + + payload := []byte("artifact payload for browser mediated download") + uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-api", "job-download-api", payload, 9) + + reference := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/artifacts/artifact-download-api/download", map[string]string{}, adminSession) + if reference.ArtifactID != "artifact-download-api" || reference.DownloadURL != "/api/v1/artifacts/artifact-download-api/content" || !reference.RangeSupported || reference.ChunkSizeBytes != validator.MaxArtifactDownloadBytes { + t.Fatalf("unexpected artifact reference: %+v", reference) + } + if reference.Checksum != validator.BytesChecksum(payload) || reference.SizeBytes != int64(len(payload)) || reference.StorageBehavior == "" { + t.Fatalf("expected integrity metadata in reference, got %+v", reference) + } + + contentRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-api/content?offset=9&limit=7", "", adminSession) + assertStatus(t, contentRecorder, http.StatusPartialContent) + if got, want := contentRecorder.Body.Bytes(), payload[9:16]; !bytes.Equal(got, want) { + t.Fatalf("expected range payload %q, got %q", want, got) + } + if contentRecorder.Header().Get("Content-Range") != "bytes 9-15/46" || contentRecorder.Header().Get("X-Artifact-Checksum") != validator.BytesChecksum(payload) || contentRecorder.Header().Get("X-Artifact-Content-Checksum") != validator.BytesChecksum(payload[9:16]) { + t.Fatalf("expected safe integrity headers, got %+v", contentRecorder.Header()) + } + + rangeRequest := httptest.NewRequest(http.MethodGet, "/api/v1/artifacts/artifact-download-api/content", nil) + rangeRequest.Header.Set("Authorization", "Bearer "+adminSession) + rangeRequest.Header.Set("Range", "bytes=0-7") + rangeRecorder := httptest.NewRecorder() + router.ServeHTTP(rangeRecorder, rangeRequest) + assertStatus(t, rangeRecorder, http.StatusPartialContent) + if !bytes.Equal(rangeRecorder.Body.Bytes(), payload[:8]) { + t.Fatalf("expected range header payload, got %q", rangeRecorder.Body.String()) + } + + for _, body := range []string{mustJSON(t, reference), contentRecorder.Header().Get("Content-Disposition"), contentRecorder.Header().Get("X-Artifact-Storage")} { + assertNoArtifactForbiddenFragments(t, body) + } +} + +func TestArtifactDownloadAPIDeniesUnavailableAndUnauthorizedArtifacts(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-download-other", + DisplayName: "Other Operator", + Email: "download-other@example.test", + Roles: []string{"server-admin"}, + Password: "secret-password", + }, adminSession) + otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "download-other@example.test", Password: "secret-password"}).SessionID + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest())) + postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-denied", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download Denied"}, adminSession) + postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-denied", ServerInstanceID: "server-download-denied", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-denied"}) + + payload := []byte("download denied payload") + uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-denied", "job-download-denied", payload, 8) + + unauthorized := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/artifacts/artifact-download-denied/download", map[string]string{}, otherSession) + assertErrorResponse(t, unauthorized, http.StatusForbidden, errorCodeForbidden) + unauthorizedContent := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-denied/content?limit=8", "", otherSession) + assertErrorResponse(t, unauthorizedContent, http.StatusForbidden, errorCodeForbidden) + + unavailable := postJSON[dto.ArtifactResponse](t, router, "/api/v1/artifacts", dto.ArtifactCreateRequest{ID: "artifact-uploading-denied", OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-download-denied", SizeBytes: 12, Checksum: validator.BytesChecksum([]byte("not-complete!"))}) + if unavailable.State != domain.ArtifactStateUploading { + t.Fatalf("expected uploading metadata, got %+v", unavailable) + } + unavailableDownload := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/artifacts/artifact-uploading-denied/download", map[string]string{}, adminSession) + assertErrorResponse(t, unavailableDownload, http.StatusBadRequest, errorCodeValidation) + + for _, body := range []string{unauthorized.Body.String(), unauthorizedContent.Body.String(), unavailableDownload.Body.String()} { + assertNoArtifactForbiddenFragments(t, body) + } +} + +func TestPluginBridgeArtifactOpenReturnsSafeReference(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest())) + registration := validGamePluginManifestRegistrationRequest() + registration.Manifest.Bridge.Actions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionArtifactsOpen)} + registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.artifacts.read"} + registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionArtifactsOpen)} + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) + instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-bridge-artifact", PluginID: "game.example", RunEndpointID: "run-local", Name: "Bridge Artifact"}, adminSession) + postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-bridge-artifact", ServerInstanceID: instance.ID, RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-bridge-artifact"}) + + payload := []byte("bridge artifact reference payload") + uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-bridge-open", "job-bridge-artifact", payload, 8) + + bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-artifact-open", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Action: string(domain.PluginBridgeActionArtifactsOpen), + Payload: map[string]string{"artifactId": "artifact-bridge-open"}, + }, adminSession) + if bridge.Status != "ok" || bridge.Result["downloadUrl"] != "/api/v1/artifacts/artifact-bridge-open/content" || bridge.Result["sizeBytes"] == "" || bridge.Result["checksum"] != validator.BytesChecksum(payload) { + t.Fatalf("expected safe artifact reference through bridge, got %+v", bridge) + } + assertNoArtifactForbiddenFragments(t, mustJSON(t, bridge)) +} + +func uploadCompletedArtifact(t *testing.T, router http.Handler, sessionToken string, artifactID string, jobID string, payload []byte, chunkSize int) dto.ArtifactTransferCompleteResponse { + t.Helper() + open := dto.ArtifactTransferOpenRequest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + ArtifactID: artifactID, + Direction: domain.ArtifactTransferDirectionUpload, + OwnerKind: domain.ArtifactOwnerKindJob, + OwnerID: jobID, + SizeBytes: int64(len(payload)), + ChunkSizeBytes: chunkSize, + Checksum: validator.BytesChecksum(payload), + IdempotencyKey: artifactID + "-upload", + } + opened := decodeBody[dto.ArtifactTransferOpenResponse](t, performArtifactTransferOpen(t, router, open)) + for index := 0; index < opened.TotalChunks; index++ { + offset := index * chunkSize + end := offset + chunkSize + if end > len(payload) { + end = len(payload) + } + part := payload[offset:end] + chunk := dto.ArtifactChunkUploadRequest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + TransferID: opened.TransferID, + ArtifactID: artifactID, + ChunkIndex: index, + Offset: int64(offset), + SizeBytes: len(part), + Checksum: validator.BytesChecksum(part), + Payload: part, + } + assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", chunk), http.StatusOK) + } + completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: artifactID, Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))}) + assertStatus(t, completeRecorder, http.StatusOK) + return decodeBody[dto.ArtifactTransferCompleteResponse](t, completeRecorder) +} + +func artifactDownloadHelloRequest() dto.RunControlHelloRequest { + request := validRunControlHelloRequest() + request.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"} + request.CapabilityReport.Fingerprint = "cap-artifact-download" + return request +} + +func assertNoArtifactForbiddenFragments(t *testing.T, body string) { + t.Helper() + for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "storage://", "file://"} { + if strings.Contains(body, forbidden) { + t.Fatalf("artifact response exposed forbidden fragment %q: %s", forbidden, body) + } + } +} diff --git a/platform/api/artifact_transfer_handlers_test.go b/platform/api/artifact_transfer_handlers_test.go new file mode 100644 index 0000000..34a432f --- /dev/null +++ b/platform/api/artifact_transfer_handlers_test.go @@ -0,0 +1,144 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "browser.local/platform/domain" + "browser.local/platform/dto" + "browser.local/platform/validator" +) + +func TestArtifactTransferAPIWorkflow(t *testing.T) { + router := newTestRouter() + hello := createArtifactTransferAPIFixtures(t, router) + payload := []byte("artifact payload for api upload") + openRequest := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8) + + openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", openRequest) + assertStatus(t, openRecorder, http.StatusOK) + opened := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder) + if !opened.Accepted || opened.TransferID == "" || opened.TotalChunks != 4 || opened.Artifact.State != domain.ArtifactStateUploading { + t.Fatalf("unexpected open response: %+v", opened) + } + + chunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8)) + assertStatus(t, chunkRecorder, http.StatusOK) + chunk := decodeBody[dto.ArtifactChunkUploadResponse](t, chunkRecorder) + if !chunk.Accepted || chunk.NextMissingChunkIndex != 1 || len(chunk.ReceivedChunkIndexes) != 1 { + t.Fatalf("unexpected chunk response: %+v", chunk) + } + + duplicateRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8)) + assertStatus(t, duplicateRecorder, http.StatusOK) + duplicate := decodeBody[dto.ArtifactChunkUploadResponse](t, duplicateRecorder) + if !duplicate.Duplicate { + t.Fatalf("expected duplicate chunk ack, got %+v", duplicate) + } + + statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/status", dto.ArtifactTransferStatusRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1"}) + assertStatus(t, statusRecorder, http.StatusOK) + status := decodeBody[dto.ArtifactTransferStatusResponse](t, statusRecorder) + if status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 { + t.Fatalf("unexpected status response: %+v", status) + } + + missingComplete := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))}) + assertErrorResponse(t, missingComplete, http.StatusBadRequest, errorCodeValidation) + + for index := 1; index < opened.TotalChunks; index++ { + partRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, index, 8)) + assertStatus(t, partRecorder, http.StatusOK) + } + completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))}) + assertStatus(t, completeRecorder, http.StatusOK) + complete := decodeBody[dto.ArtifactTransferCompleteResponse](t, completeRecorder) + if !complete.Accepted || !complete.Completed || complete.Artifact.State != domain.ArtifactStateAvailable { + t.Fatalf("unexpected complete response: %+v", complete) + } +} + +func TestArtifactTransferAPIErrors(t *testing.T) { + router := newTestRouter() + hello := createArtifactTransferAPIFixtures(t, router) + payload := []byte("artifact payload") + openRequest := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8) + opened := decodeBody[dto.ArtifactTransferOpenResponse](t, performArtifactTransferOpen(t, router, openRequest)) + + badChunk := validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8) + badChunk.Checksum = validator.BytesChecksum([]byte("different")) + badChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", badChunk) + assertErrorResponse(t, badChunkRecorder, http.StatusBadRequest, errorCodeValidation) + + invalidSession := validArtifactTransferOpenRequest("stale-token", payload, 8) + invalidSession.ArtifactID = "artifact-invalid-session" + invalidSession.IdempotencyKey = "artifact-invalid-session" + invalidSessionRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", invalidSession) + assertErrorResponse(t, invalidSessionRecorder, http.StatusBadRequest, errorCodeValidation) + + invalidOwner := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8) + invalidOwner.OwnerKind = domain.ArtifactOwnerKindPlatform + invalidOwner.ArtifactID = "artifact-invalid-owner" + invalidOwner.IdempotencyKey = "artifact-invalid-owner" + invalidOwnerRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", invalidOwner) + assertErrorResponse(t, invalidOwnerRecorder, http.StatusBadRequest, errorCodeValidation) + + methodFailure := performRaw(t, router, http.MethodGet, "/api/v1/run/artifacts/open", "") + assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed) +} + +func createArtifactTransferAPIFixtures(t *testing.T, router http.Handler) dto.RunControlHelloResponse { + t.Helper() + helloRequest := validRunControlHelloRequest() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.install", "process.start", "process.stop", "logs.read", "files.read") + helloRequest.CapabilityReport.Fingerprint = "cap-artifacts" + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest)) + adminSession := createAdminSession(t, router) + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1"}, adminSession) + postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-1", ServerInstanceID: "server-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-start"}) + return hello +} + +func performArtifactTransferOpen(t *testing.T, router http.Handler, request dto.ArtifactTransferOpenRequest) *httptest.ResponseRecorder { + t.Helper() + recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", request) + assertStatus(t, recorder, http.StatusOK) + return recorder +} + +func validArtifactTransferOpenRequest(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest { + return dto.ArtifactTransferOpenRequest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + ArtifactID: "artifact-1", + Direction: domain.ArtifactTransferDirectionUpload, + OwnerKind: domain.ArtifactOwnerKindJob, + OwnerID: "job-1", + SizeBytes: int64(len(payload)), + ChunkSizeBytes: chunkSize, + Checksum: validator.BytesChecksum(payload), + IdempotencyKey: "artifact-upload-1", + } +} + +func validArtifactChunkRequest(sessionToken string, transferID string, payload []byte, index int, chunkSize int) dto.ArtifactChunkUploadRequest { + offset := index * chunkSize + end := offset + chunkSize + if end > len(payload) { + end = len(payload) + } + part := payload[offset:end] + return dto.ArtifactChunkUploadRequest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + TransferID: transferID, + ArtifactID: "artifact-1", + ChunkIndex: index, + Offset: int64(offset), + SizeBytes: len(part), + Checksum: validator.BytesChecksum(part), + Payload: part, + } +} diff --git a/platform/api/channel_isolation_handlers_test.go b/platform/api/channel_isolation_handlers_test.go new file mode 100644 index 0000000..b1618d8 --- /dev/null +++ b/platform/api/channel_isolation_handlers_test.go @@ -0,0 +1,241 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "browser.local/platform/domain" + "browser.local/platform/dto" + "browser.local/platform/validator" +) + +func TestRunChannelAPIInterleavedRequestsMutateIndependentState(t *testing.T) { + router := newTestRouter() + hello := createArtifactTransferAPIFixtures(t, router) + createOnlyLogStreamForChannelIsolation(t, router) + + claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Capabilities: []string{"process.start"}, + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + }) + assertStatus(t, claimRecorder, http.StatusOK) + claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder) + if !claim.HasJob || claim.Job.JobID != "job-1" { + t.Fatalf("expected claimed job, got %+v", claim) + } + + payload := []byte("interleaved artifact payload for api isolation") + openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", artifactOpenForChannelIsolation(hello.SessionToken, payload, 8)) + assertStatus(t, openRecorder, http.StatusOK) + open := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder) + firstChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", artifactChunkForChannelIsolation(hello.SessionToken, open.TransferID, payload, 0, 8)) + assertStatus(t, firstChunkRecorder, http.StatusOK) + firstChunk := decodeBody[dto.ArtifactChunkUploadResponse](t, firstChunkRecorder) + if !firstChunk.Accepted || firstChunk.NextMissingChunkIndex != 1 { + t.Fatalf("expected first artifact chunk ack, got %+v", firstChunk) + } + + heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Version: "0.1.1", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-artifacts", + Capacity: dto.RunCapacityResponse{MaxJobs: 4, RunningJobs: 1}, + }) + assertStatus(t, heartbeatRecorder, http.StatusOK) + heartbeat := decodeBody[dto.RunControlHeartbeatResponse](t, heartbeatRecorder) + if !heartbeat.Accepted { + t.Fatalf("expected heartbeat accepted, got %+v", heartbeat) + } + + ack := postRunJobAck(t, router, dto.RunJobAckRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + Message: "job accepted while artifact transfer is active", + }) + if ack.Job.State != domain.JobStateRunning { + t.Fatalf("expected running job after ack, got %+v", ack) + } + + logBatch := validLogBatchRequest(t, hello.SessionToken, 1, 1) + logBatch.LogStreamID = "log-channel-isolation" + logRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", logBatch) + assertStatus(t, logRecorder, http.StatusOK) + logAck := decodeBody[dto.LogBatchIngestResponse](t, logRecorder) + if !logAck.Accepted || logAck.LatestSeq != 1 { + t.Fatalf("expected log ack independent from artifact transfer, got %+v", logAck) + } + + resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + State: domain.JobStateSucceeded, + Progress: dto.JobProgressBody{Percent: 100, Message: "done"}, + ResultRef: "artifact://jobs/job-1/result", + Message: "done", + }) + assertStatus(t, resultRecorder, http.StatusOK) + result := decodeBody[dto.RunJobResultResponse](t, resultRecorder) + if result.Job.State != domain.JobStateSucceeded || result.Job.ResultRef == "" { + t.Fatalf("expected terminal result independent from transfer, got %+v", result) + } + + statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/status", dto.ArtifactTransferStatusRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + TransferID: open.TransferID, + ArtifactID: "artifact-channel-isolation", + }) + assertStatus(t, statusRecorder, http.StatusOK) + status := decodeBody[dto.ArtifactTransferStatusResponse](t, statusRecorder) + if status.Completed || status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 { + t.Fatalf("artifact state should remain independent after heartbeat/job/log calls, got %+v", status) + } + stream := getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-channel-isolation") + if stream.LatestSeq != 1 { + t.Fatalf("expected log stream latest seq updated independently, got %+v", stream) + } + job := getJSON[dto.JobResponse](t, router, "/api/v1/jobs/job-1") + if job.State != domain.JobStateSucceeded { + t.Fatalf("expected job terminal state preserved, got %+v", job) + } +} + +func TestLightweightRunRoutesRejectHeavyChannelPayloads(t *testing.T) { + router := newTestRouter() + hello := createArtifactTransferAPIFixtures(t, router) + createOnlyLogStreamForChannelIsolation(t, router) + postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ + ID: "job-heavy-payload", + ServerInstanceID: "server-1", + RunEndpointID: "run-local", + Capability: "process.start", + IdempotencyKey: "heavy-payload-job", + }) + claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Capabilities: []string{"process.start"}, + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + }) + assertStatus(t, claimRecorder, http.StatusOK) + claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder) + + for _, tc := range []struct { + name string + path string + body string + }{ + { + name: "heartbeat rejects artifact chunk fields", + path: "/api/v1/run/control/heartbeat", + body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"version":"0.1.1","status":"online","capabilityFingerprint":"cap-jobs","capacity":{"maxJobs":4},"payload":"AAAA","transferId":"transfer-1","hostPath":"/Users/tasia/server"}`, hello.SessionToken), + }, + { + name: "job result rejects inline logs and sockets", + path: "/api/v1/run/jobs/result", + body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"jobId":%q,"leaseToken":%q,"attempt":%d,"state":"succeeded","progress":{"percent":100},"resultRef":"artifact://jobs/job-heavy-payload/result","entries":[{"seq":1,"line":"log"}],"directSocket":"unix:///tmp/run.sock"}`, hello.SessionToken, claim.Job.JobID, claim.Job.LeaseToken, claim.Job.Attempt), + }, + { + name: "log ingest rejects artifact transfer payload", + path: "/api/v1/run/logs/batches", + body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"logStreamId":"log-channel-isolation","serverInstanceId":"server-1","streamKey":"stdout","source":"process","firstSeq":1,"lastSeq":1,"compression":"none","checksum":"sha256:bad","entries":[{"seq":1,"timestamp":"2026-07-03T12:00:01Z","line":"line"}],"payload":"AAAA","transferId":"transfer-1"}`, hello.SessionToken), + }, + } { + t.Run(tc.name, func(t *testing.T) { + recorder := performRaw(t, router, http.MethodPost, tc.path, tc.body) + assertErrorResponse(t, recorder, http.StatusBadRequest, errorCodeBadRequest) + }) + } +} + +func createLogStreamForChannelIsolation(t *testing.T, router http.Handler) { + t.Helper() + adminSession := createAdminSession(t, router) + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-1", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "SCUM #1", + }, adminSession) + postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ + ID: "log-channel-isolation", + ServerInstanceID: "server-1", + Source: domain.LogStreamSourceProcess, + StreamKey: "stdout", + StorageBackend: domain.LogStorageBackendLocalSegments, + RetentionPolicy: "default", + }) +} + +func createOnlyLogStreamForChannelIsolation(t *testing.T, router http.Handler) { + t.Helper() + postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ + ID: "log-channel-isolation", + ServerInstanceID: "server-1", + Source: domain.LogStreamSourceProcess, + StreamKey: "stdout", + StorageBackend: domain.LogStorageBackendLocalSegments, + RetentionPolicy: "default", + }) +} + +func artifactOpenForChannelIsolation(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest { + return dto.ArtifactTransferOpenRequest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + ArtifactID: "artifact-channel-isolation", + Direction: domain.ArtifactTransferDirectionUpload, + OwnerKind: domain.ArtifactOwnerKindJob, + OwnerID: "job-1", + SizeBytes: int64(len(payload)), + ChunkSizeBytes: chunkSize, + Checksum: validator.BytesChecksum(payload), + IdempotencyKey: "artifact-channel-isolation", + } +} + +func artifactChunkForChannelIsolation(sessionToken string, transferID string, payload []byte, index int, chunkSize int) dto.ArtifactChunkUploadRequest { + offset := index * chunkSize + end := offset + chunkSize + if end > len(payload) { + end = len(payload) + } + part := payload[offset:end] + return dto.ArtifactChunkUploadRequest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + TransferID: transferID, + ArtifactID: "artifact-channel-isolation", + ChunkIndex: index, + Offset: int64(offset), + SizeBytes: len(part), + Checksum: validator.BytesChecksum(part), + Payload: part, + } +} + +func TestRunChannelAPIHeavyPayloadRejectionsDoNotMutateState(t *testing.T) { + router := newTestRouter() + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest())) + + recorder := performRaw(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"version":"0.1.1","status":"online","capabilityFingerprint":"cap-jobs","capacity":{"maxJobs":4,"runningJobs":1},"payload":"AAAA"}`, hello.SessionToken)) + assertErrorResponse(t, recorder, http.StatusBadRequest, errorCodeBadRequest) + + endpoint := getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local") + if endpoint.Capacity.RunningJobs != 0 || strings.Contains(endpoint.Capacity.Summary, "AAAA") { + t.Fatalf("rejected heartbeat must not mutate endpoint capacity or store heavy payload, got %+v", endpoint) + } +} diff --git a/platform/api/control_handlers_test.go b/platform/api/control_handlers_test.go new file mode 100644 index 0000000..1a2406b --- /dev/null +++ b/platform/api/control_handlers_test.go @@ -0,0 +1,113 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "browser.local/platform/domain" + "browser.local/platform/dto" +) + +func TestRunControlAPIHelloHeartbeatWorkflow(t *testing.T) { + router := newTestRouter() + + helloRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", validRunControlHelloRequest()) + assertStatus(t, helloRecorder, http.StatusOK) + hello := decodeBody[dto.RunControlHelloResponse](t, helloRecorder) + if !hello.Accepted || hello.SessionToken == "" || hello.RunEndpointID != "run-local" { + t.Fatalf("expected accepted hello response, got %+v", hello) + } + + endpoint := getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local") + if endpoint.Status != domain.RunEndpointStatusOnline || len(endpoint.Capabilities) != 2 { + t.Fatalf("expected registered endpoint metadata, got %+v", endpoint) + } + + heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Version: "0.1.1", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-v2", + Capacity: dto.RunCapacityResponse{ + MaxJobs: 4, + RunningJobs: 1, + }, + }) + assertStatus(t, heartbeatRecorder, http.StatusOK) + heartbeat := decodeBody[dto.RunControlHeartbeatResponse](t, heartbeatRecorder) + if !heartbeat.Accepted || !heartbeat.RefreshCapabilities || heartbeat.NextHeartbeatSeconds <= 0 { + t.Fatalf("expected accepted heartbeat with refresh, got %+v", heartbeat) + } +} + +func TestRunControlAPIReRegistrationRotatesToken(t *testing.T) { + router := newTestRouter() + first := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest())) + + request := validRunControlHelloRequest() + request.Version = "0.2.0" + second := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, request)) + if second.SessionToken == first.SessionToken { + t.Fatalf("expected new session token after re-registration, got %q", second.SessionToken) + } +} + +func TestRunControlAPIErrors(t *testing.T) { + router := newTestRouter() + + invalid := validRunControlHelloRequest() + invalid.RegistrationToken = "" + invalid.Capacity.RunningJobs = 8 + invalid.Capacity.MaxJobs = 4 + invalidHello := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", invalid) + assertErrorResponse(t, invalidHello, http.StatusBadRequest, errorCodeValidation) + + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest())) + invalidHeartbeat := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{ + RunEndpointID: "run-local", + SessionToken: "stale-token", + Version: "0.1.1", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-v1", + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + }) + assertErrorResponse(t, invalidHeartbeat, http.StatusBadRequest, errorCodeValidation) + + validHeartbeat := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Version: "0.1.1", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-v1", + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + }) + assertStatus(t, validHeartbeat, http.StatusOK) + + methodFailure := performRaw(t, router, http.MethodGet, "/api/v1/run/control/hello", "") + assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed) +} + +func performRunControlHello(t *testing.T, router http.Handler, request dto.RunControlHelloRequest) *httptest.ResponseRecorder { + t.Helper() + recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", request) + assertStatus(t, recorder, http.StatusOK) + return recorder +} + +func validRunControlHelloRequest() dto.RunControlHelloRequest { + return dto.RunControlHelloRequest{ + RegistrationToken: "registration-token", + RunEndpointID: "run-local", + DisplayName: "Local Run", + Version: "0.1.0", + Status: domain.RunEndpointStatusOnline, + Platform: "darwin/arm64", + CapabilityReport: dto.RunCapabilityReport{ + Capabilities: []string{"control.hello", "control.heartbeat"}, + Fingerprint: "cap-v1", + }, + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + } +} diff --git a/platform/api/health_handler.go b/platform/api/health_handler.go new file mode 100644 index 0000000..9c1a348 --- /dev/null +++ b/platform/api/health_handler.go @@ -0,0 +1,32 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "browser.local/platform/dto" +) + +const serviceVersion = "0.1.0-dev" + +// HealthHandler godoc +// @Summary Platform health +// @Description Returns process health for local development smoke tests. +// @Tags health +// @Success 200 {object} dto.HealthResponse +// @Router /healthz [get] +func HealthHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dto.HealthResponse{ + Service: "platform", + Status: "ok", + Version: serviceVersion, + Time: time.Now().UTC().Format(time.RFC3339), + }) +} diff --git a/platform/api/health_handler_test.go b/platform/api/health_handler_test.go new file mode 100644 index 0000000..3f54c75 --- /dev/null +++ b/platform/api/health_handler_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "browser.local/platform/dto" +) + +func TestHealthHandler(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + + HealthHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var body dto.HealthResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + + if body.Service != "platform" || body.Status != "ok" || body.Version == "" || body.Time == "" { + t.Fatalf("unexpected health body: %+v", body) + } +} + +func TestHealthHandlerRejectsUnsupportedMethods(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/healthz", nil) + rec := httptest.NewRecorder() + + HealthHandler(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} diff --git a/platform/api/job_channel_handlers_test.go b/platform/api/job_channel_handlers_test.go new file mode 100644 index 0000000..2cbb5b9 --- /dev/null +++ b/platform/api/job_channel_handlers_test.go @@ -0,0 +1,160 @@ +package api + +import ( + "net/http" + "testing" + + "browser.local/platform/domain" + "browser.local/platform/dto" +) + +func TestRunJobChannelAPIWorkflow(t *testing.T) { + router := newTestRouter() + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest())) + heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Version: "0.1.0", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-jobs", + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + }) + assertStatus(t, heartbeatRecorder, http.StatusOK) + + postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ + ID: "job-1", + RunEndpointID: "run-local", + Capability: "process.start", + IdempotencyKey: "idem-1", + }) + + claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Capabilities: []string{"process.start"}, + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + }) + assertStatus(t, claimRecorder, http.StatusOK) + claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder) + if !claim.HasJob || claim.Job.JobID != "job-1" || claim.Job.State != domain.JobStateAccepted { + t.Fatalf("expected claimed accepted job, got %+v", claim) + } + + ack := postRunJobAck(t, router, dto.RunJobAckRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + Message: "started", + }) + if ack.Job.State != domain.JobStateRunning { + t.Fatalf("expected running ack, got %+v", ack) + } + + progressRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/progress", dto.RunJobProgressRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + Progress: dto.JobProgressBody{Percent: 60, Message: "working"}, + }) + assertStatus(t, progressRecorder, http.StatusOK) + progress := decodeBody[dto.RunJobProgressResponse](t, progressRecorder) + if progress.Job.Progress.Percent != 60 { + t.Fatalf("expected progress update, got %+v", progress) + } + + cancelRecorder := performJSON(t, router, http.MethodPost, "/api/v1/jobs/job-1/cancel", dto.RunJobCancelRequestBody{Reason: "operator requested"}) + assertStatus(t, cancelRecorder, http.StatusOK) + cancel := decodeBody[dto.RunJobCancelRequestResponse](t, cancelRecorder) + if !cancel.Accepted || cancel.Reason != "operator requested" { + t.Fatalf("expected cancel request, got %+v", cancel) + } + + pollRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/cancel", dto.RunJobCancelPollRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + }) + assertStatus(t, pollRecorder, http.StatusOK) + poll := decodeBody[dto.RunJobCancelPollResponse](t, pollRecorder) + if !poll.HasCancel || poll.JobID != "job-1" { + t.Fatalf("expected cancel poll result, got %+v", poll) + } + + resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + State: domain.JobStateCancelled, + Progress: dto.JobProgressBody{Percent: 100, Message: "cancelled"}, + Message: "cancelled", + }) + assertStatus(t, resultRecorder, http.StatusOK) + result := decodeBody[dto.RunJobResultResponse](t, resultRecorder) + if result.Job.State != domain.JobStateCancelled { + t.Fatalf("expected cancelled result, got %+v", result) + } + + reconcileRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/reconcile", dto.RunJobReconcileRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + ActiveJobIDs: []string{"local-only"}, + }) + assertStatus(t, reconcileRecorder, http.StatusOK) + reconcile := decodeBody[dto.RunJobReconcileResponse](t, reconcileRecorder) + if len(reconcile.ActiveJobs) != 0 || len(reconcile.UnknownJobIDs) != 1 || reconcile.UnknownJobIDs[0] != "local-only" { + t.Fatalf("expected no active platform jobs and one unknown local job, got %+v", reconcile) + } +} + +func TestRunJobChannelAPIErrors(t *testing.T) { + router := newTestRouter() + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest())) + postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-1"}) + + invalidClaim := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{ + RunEndpointID: "run-local", + SessionToken: "stale", + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + }) + assertErrorResponse(t, invalidClaim, http.StatusBadRequest, errorCodeValidation) + + claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + })) + + invalidProgress := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/progress", dto.RunJobProgressRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + Progress: dto.JobProgressBody{Percent: 101}, + }) + assertErrorResponse(t, invalidProgress, http.StatusBadRequest, errorCodeValidation) + + badMethod := performRaw(t, router, http.MethodGet, "/api/v1/run/jobs/claim", "") + assertErrorResponse(t, badMethod, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed) +} + +func postRunJobAck(t *testing.T, router http.Handler, request dto.RunJobAckRequest) dto.RunJobAckResponse { + t.Helper() + recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/ack", request) + assertStatus(t, recorder, http.StatusOK) + return decodeBody[dto.RunJobAckResponse](t, recorder) +} + +func validRunJobControlHelloRequest() dto.RunControlHelloRequest { + request := validRunControlHelloRequest() + request.CapabilityReport.Capabilities = append(request.CapabilityReport.Capabilities, "process.start") + request.CapabilityReport.Fingerprint = "cap-jobs" + return request +} diff --git a/platform/api/json.go b/platform/api/json.go new file mode 100644 index 0000000..e39d7a5 --- /dev/null +++ b/platform/api/json.go @@ -0,0 +1,81 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "browser.local/platform/dto" + "browser.local/platform/repo" + "browser.local/platform/service" + "browser.local/platform/validator" +) + +const ( + errorCodeForbidden = "forbidden" + errorCodeBadRequest = "bad_request" + errorCodeDuplicate = "duplicate_resource" + errorCodeInternal = "internal_error" + errorCodeMethodNotAllowed = "method_not_allowed" + errorCodeNotFound = "not_found" + errorCodeUnauthorized = "unauthorized" + errorCodeValidation = "validation_failed" +) + +func decodeJSON[T any](r *http.Request) (T, error) { + var value T + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, fmt.Errorf("decode json: %w", err) + } + + var extra struct{} + if err := decoder.Decode(&extra); err != io.EOF { + return value, errors.New("decode json: multiple JSON values are not allowed") + } + return value, nil +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeAPIError(w http.ResponseWriter, status int, code string, message string, details []string) { + writeJSON(w, status, dto.ErrorResponse{ + Code: code, + Message: message, + Details: details, + }) +} + +func writeMethodNotAllowed(w http.ResponseWriter, allow string) { + w.Header().Set("Allow", allow) + writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil) +} + +func writeServiceError(w http.ResponseWriter, err error) { + var validationErr validator.ValidationError + switch { + case errors.As(err, &validationErr): + writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", validationErr.Violations) + case errors.Is(err, service.ErrUnauthorized): + writeAPIError(w, http.StatusUnauthorized, errorCodeUnauthorized, "authentication required", nil) + case errors.Is(err, service.ErrForbidden): + writeAPIError(w, http.StatusForbidden, errorCodeForbidden, "account is not allowed to access this resource", nil) + case errors.Is(err, repo.ErrDuplicate): + writeAPIError(w, http.StatusConflict, errorCodeDuplicate, "resource already exists", nil) + case errors.Is(err, repo.ErrNotFound): + writeAPIError(w, http.StatusNotFound, errorCodeNotFound, "resource not found", nil) + default: + writeAPIError(w, http.StatusInternalServerError, errorCodeInternal, "internal server error", nil) + } +} + +func writeDecodeError(w http.ResponseWriter, err error) { + writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid JSON request body", []string{err.Error()}) +} diff --git a/platform/api/log_ingest_handlers_test.go b/platform/api/log_ingest_handlers_test.go new file mode 100644 index 0000000..d63f825 --- /dev/null +++ b/platform/api/log_ingest_handlers_test.go @@ -0,0 +1,106 @@ +package api + +import ( + "net/http" + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/dto" + "browser.local/platform/validator" +) + +func TestLogIngestAPIWorkflow(t *testing.T) { + router := newTestRouter() + hello := createLogIngestAPIFixtures(t, router) + batch := validLogBatchRequest(t, hello.SessionToken, 1, 2) + + ackRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch) + assertStatus(t, ackRecorder, http.StatusOK) + ack := decodeBody[dto.LogBatchIngestResponse](t, ackRecorder) + if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 2 || ack.LatestSeq != 2 { + t.Fatalf("unexpected ack: %+v", ack) + } + + stream := getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1") + if stream.LatestSeq != 2 { + t.Fatalf("expected latest seq update, got %+v", stream) + } + + queryRecorder := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: "log-1", AfterSeq: 0, Limit: 1}) + assertStatus(t, queryRecorder, http.StatusOK) + query := decodeBody[dto.LogStreamCursorResponse](t, queryRecorder) + if len(query.Entries) != 1 || query.Entries[0].Seq != 1 || query.NextSeq != 1 || query.LatestSeq != 2 { + t.Fatalf("unexpected query: %+v", query) + } +} + +func TestLogIngestAPIDuplicateAndErrors(t *testing.T) { + router := newTestRouter() + hello := createLogIngestAPIFixtures(t, router) + batch := validLogBatchRequest(t, hello.SessionToken, 1, 1) + + first := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch) + assertStatus(t, first, http.StatusOK) + duplicate := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch) + assertStatus(t, duplicate, http.StatusOK) + duplicateAck := decodeBody[dto.LogBatchIngestResponse](t, duplicate) + if !duplicateAck.Duplicate { + t.Fatalf("expected duplicate ack, got %+v", duplicateAck) + } + + gap := validLogBatchRequest(t, hello.SessionToken, 3, 3) + gapRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", gap) + assertErrorResponse(t, gapRecorder, http.StatusBadRequest, errorCodeValidation) + + missingQuery := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: "missing", Limit: 1}) + assertErrorResponse(t, missingQuery, http.StatusNotFound, errorCodeNotFound) +} + +func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunControlHelloResponse { + t.Helper() + helloRequest := validRunControlHelloRequest() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.install", "process.start", "process.stop", "logs.read") + helloRequest.CapabilityReport.Fingerprint = "cap-logs" + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest)) + adminSession := createAdminSession(t, router) + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1"}, adminSession) + postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ + ID: "log-1", + ServerInstanceID: "server-1", + Source: domain.LogStreamSourceProcess, + StreamKey: "stdout", + StorageBackend: domain.LogStorageBackendLocalSegments, + RetentionPolicy: "default", + }) + return hello +} + +func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest { + t.Helper() + entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1) + domainEntries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1) + for seq := firstSeq; seq <= lastSeq; seq++ { + entry := dto.LogEntryBody{Seq: seq, Timestamp: time.Date(2026, 7, 3, 12, 0, int(seq), 0, time.UTC), Level: "info", Line: "line"} + entries = append(entries, entry) + domainEntries = append(domainEntries, domain.LogEntry{Seq: entry.Seq, Timestamp: entry.Timestamp, Level: entry.Level, Line: entry.Line}) + } + checksum, err := validator.LogEntriesChecksum(domainEntries) + if err != nil { + t.Fatalf("checksum entries: %v", err) + } + return dto.LogBatchIngestRequest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + LogStreamID: "log-1", + ServerInstanceID: "server-1", + StreamKey: "stdout", + Source: domain.LogStreamSourceProcess, + FirstSeq: firstSeq, + LastSeq: lastSeq, + Compression: "none", + Checksum: checksum, + Entries: entries, + } +} diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go new file mode 100644 index 0000000..0ec5c4c --- /dev/null +++ b/platform/api/resource_handlers.go @@ -0,0 +1,2068 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + + "browser.local/platform/domain" + "browser.local/platform/dto" + "browser.local/platform/service" + "browser.local/platform/validator" +) + +type coreHandlers struct { + core service.Core +} + +func newCoreHandlers(core service.Core) *coreHandlers { + return &coreHandlers{core: core} +} + +func (h *coreHandlers) register(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/auth/register", h.authRegister) + mux.HandleFunc("/api/v1/auth/login", h.authLogin) + mux.HandleFunc("/api/v1/auth/logout", h.authLogout) + mux.HandleFunc("/api/v1/users/current", h.currentUser) + mux.HandleFunc("/api/v1/users/current/profile", h.currentUserProfile) + mux.HandleFunc("/api/v1/users/current/theme", h.currentUserTheme) + mux.HandleFunc("/api/v1/users", h.users) + mux.HandleFunc("/api/v1/users/{id}", h.userDetail) + mux.HandleFunc("/api/v1/ai-providers", h.aiProviders) + mux.HandleFunc("/api/v1/ai-providers/{id}", h.aiProviderDetail) + mux.HandleFunc("/api/v1/ai-providers/{id}/status", h.aiProviderStatus) + mux.HandleFunc("/api/v1/ai-providers/{id}/test", h.aiProviderTest) + mux.HandleFunc("/api/v1/ai-providers/{id}/models", h.aiProviderModels) + mux.HandleFunc("/api/v1/ai/invocations", h.aiInvocation) + mux.HandleFunc("/api/v1/ai/config-suggestions", h.aiConfigSuggestion) + mux.HandleFunc("/api/v1/game-plugins", h.gamePlugins) + mux.HandleFunc("/api/v1/game-plugins/register-manifest", h.gamePluginManifestRegistration) + mux.HandleFunc("/api/v1/plugin-marketplace/plugins", h.marketplacePlugins) + mux.HandleFunc("/api/v1/plugin-marketplace/plugins/{id}/state", h.marketplacePluginState) + mux.HandleFunc("/api/v1/plugin-marketplace/plugins/{id}", h.marketplacePluginDetail) + mux.HandleFunc("/api/v1/plugin-bridge/authorize", h.pluginBridgeAuthorize) + mux.HandleFunc("/api/v1/plugin-bridge/execute", h.pluginBridgeExecute) + mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail) + mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics) + mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics) + mux.HandleFunc("/api/v1/server-instances", h.serverInstances) + mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow) + mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart) + mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop) + mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff) + mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove) + mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig) + mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates) + mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators) + mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail) + mux.HandleFunc("/api/v1/server-instances/{id}", h.serverInstanceDetail) + mux.HandleFunc("/api/v1/run/control/hello", h.runControlHello) + mux.HandleFunc("/api/v1/run/control/heartbeat", h.runControlHeartbeat) + mux.HandleFunc("/api/v1/run/jobs/claim", h.runJobClaim) + mux.HandleFunc("/api/v1/run/jobs/ack", h.runJobAck) + mux.HandleFunc("/api/v1/run/jobs/progress", h.runJobProgress) + mux.HandleFunc("/api/v1/run/jobs/result", h.runJobResult) + mux.HandleFunc("/api/v1/run/jobs/cancel", h.runJobCancelPoll) + mux.HandleFunc("/api/v1/run/jobs/reconcile", h.runJobReconcile) + mux.HandleFunc("/api/v1/run/logs/batches", h.runLogBatchIngest) + mux.HandleFunc("/api/v1/run/artifacts/open", h.runArtifactOpen) + mux.HandleFunc("/api/v1/run/artifacts/chunks", h.runArtifactChunkUpload) + mux.HandleFunc("/api/v1/run/artifacts/status", h.runArtifactStatus) + mux.HandleFunc("/api/v1/run/artifacts/complete", h.runArtifactComplete) + mux.HandleFunc("/api/v1/run/endpoints", h.runEndpoints) + mux.HandleFunc("/api/v1/run/endpoints/{id}", h.runEndpointDetail) + mux.HandleFunc("/api/v1/jobs", h.jobs) + mux.HandleFunc("/api/v1/jobs/{id}/cancel", h.jobCancel) + mux.HandleFunc("/api/v1/jobs/{id}", h.jobDetail) + mux.HandleFunc("/api/v1/file-operations/dispatch", h.fileOperationDispatch) + mux.HandleFunc("/api/v1/artifacts", h.artifacts) + mux.HandleFunc("/api/v1/artifacts/{id}/download", h.artifactDownload) + mux.HandleFunc("/api/v1/artifacts/{id}/content", h.artifactContent) + mux.HandleFunc("/api/v1/artifacts/{id}", h.artifactDetail) + mux.HandleFunc("/api/v1/log-streams", h.logStreams) + mux.HandleFunc("/api/v1/log-streams/query", h.logStreamQuery) + mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail) + mux.HandleFunc("/api/v1/audit-events", h.auditEvents) + mux.HandleFunc("/api/v1/audit-events/{id}", h.auditEventDetail) +} + +// authRegister godoc +// @Summary Register a platform account +// @Description Creates a pending low-privilege platform account without granting platform administrator rights. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body dto.RegisterRequest true "Registration request" +// @Success 200 {object} dto.AuthSessionResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 409 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/auth/register [post] +func (h *coreHandlers) authRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RegisterRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + session, err := h.core.RegisterUser(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AuthSessionFromDomain(session)) +} + +// authLogin godoc +// @Summary Login to the platform +// @Description Authenticates an active platform user and returns a bearer session token. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body dto.LoginRequest true "Login request" +// @Success 200 {object} dto.AuthSessionResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/auth/login [post] +func (h *coreHandlers) authLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.LoginRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + session, err := h.core.LoginUser(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AuthSessionFromDomain(session)) +} + +// authLogout godoc +// @Summary Logout of the platform +// @Description Invalidates the active bearer session token. +// @Tags auth +// @Produce json +// @Success 204 +// @Failure 401 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/auth/logout [post] +func (h *coreHandlers) authLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + if err := h.core.LogoutUser(bearerToken(r)); err != nil { + writeServiceError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// currentUser godoc +// @Summary Get current platform user +// @Description Returns the authenticated current user's bounded identity, roles, profile, and theme preference. +// @Tags users +// @Produce json +// @Success 200 {object} dto.CurrentUserResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/users/current [get] +func (h *coreHandlers) currentUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + user, err := h.core.GetCurrentUser(bearerToken(r)) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.CurrentUserFromDomain(user)) +} + +// currentUserProfile godoc +// @Summary Update current platform user profile +// @Description Updates bounded profile fields for the authenticated current user. +// @Tags users +// @Accept json +// @Produce json +// @Param body body dto.UserProfileBody true "Profile update request" +// @Success 200 {object} dto.CurrentUserResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/users/current/profile [put] +func (h *coreHandlers) currentUserProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + writeMethodNotAllowed(w, http.MethodPut) + return + } + request, err := decodeJSON[dto.UserProfileBody](r) + if err != nil { + writeDecodeError(w, err) + return + } + user, err := h.core.UpdateCurrentUserProfile(bearerToken(r), request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + if request.DisplayName != "" { + user.DisplayName = request.DisplayName + user, err = h.core.UpdateUser(user.ID, user) + if err != nil { + writeServiceError(w, err) + return + } + } + writeJSON(w, http.StatusOK, dto.CurrentUserFromDomain(user)) +} + +// currentUserTheme godoc +// @Summary Update current platform user theme +// @Description Persists the authenticated user's console theme preference. +// @Tags users +// @Accept json +// @Produce json +// @Param body body dto.UserThemePreferenceRequest true "Theme preference request" +// @Success 200 {object} dto.UserThemePreferenceResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/users/current/theme [put] +func (h *coreHandlers) currentUserTheme(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + writeMethodNotAllowed(w, http.MethodPut) + return + } + request, err := decodeJSON[dto.UserThemePreferenceRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + preference, err := h.core.UpdateCurrentUserTheme(bearerToken(r), request.ToDomain("")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.UserThemePreferenceFromDomain(preference)) +} + +func bearerToken(r *http.Request) string { + const prefix = "Bearer " + header := r.Header.Get("Authorization") + if len(header) < len(prefix) || header[:len(prefix)] != prefix { + return "" + } + return header[len(prefix):] +} + +// pluginBridgeAuthorize godoc +// @Summary Authorize plugin page bridge action +// @Description Evaluates one plugin bridge action against installed plugin manifest permissions without executing privileged work. +// @Tags plugin-bridge +// @Accept json +// @Produce json +// @Param body body dto.PluginBridgeAuthorizeRequest true "Plugin bridge authorization request" +// @Success 200 {object} dto.PluginBridgeAuthorizeResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/plugin-bridge/authorize [post] +func (h *coreHandlers) pluginBridgeAuthorize(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.PluginBridgeAuthorizeRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.AuthorizePluginBridgeAction(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.PluginBridgeAuthorizeFromDomain(result)) +} + +// pluginBridgeExecute godoc +// @Summary Execute plugin page bridge action +// @Description Authorizes and executes one platform-mediated plugin page bridge action without exposing platform auth, run sockets, host paths, or provider credentials. +// @Tags plugin-bridge +// @Accept json +// @Produce json +// @Param body body dto.PluginBridgeExecuteRequest true "Plugin bridge execution request" +// @Success 200 {object} dto.PluginBridgeExecuteResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/plugin-bridge/execute [post] +func (h *coreHandlers) pluginBridgeExecute(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.PluginBridgeExecuteRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.ExecutePluginBridgeAction(bearerToken(r), request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.PluginBridgeExecuteFromDomain(result)) +} + +// users godoc +// @Summary Create or list users +// @Description Creates a platform user or lists platform users. +// @Tags users +// @Accept json +// @Produce json +// @Param body body dto.UserCreateRequest false "User create request" +// @Success 200 {object} dto.UserListResponse +// @Success 201 {object} dto.UserResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/users [get] +// @Router /api/v1/users [post] +func (h *coreHandlers) users(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requirePlatformAdmin(w, r); !ok { + return + } + switch r.Method { + case http.MethodGet: + users, err := h.core.ListUsers(domain.UserFilter{Status: domain.UserStatus(r.URL.Query().Get("status"))}) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.UserListFromDomain(users)) + case http.MethodPost: + request, err := decodeJSON[dto.UserCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + user, err := h.core.CreateUser(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.UserFromDomain(user)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// userDetail godoc +// @Summary Get or update user +// @Description Returns or updates one platform user by ID. +// @Tags users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Param body body dto.UserUpdateRequest false "User update request" +// @Success 200 {object} dto.UserResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/users/{id} [get] +// @Router /api/v1/users/{id} [put] +func (h *coreHandlers) userDetail(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requirePlatformAdmin(w, r); !ok { + return + } + switch r.Method { + case http.MethodGet: + user, err := h.core.GetUser(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.UserFromDomain(user)) + case http.MethodPut: + existing, err := h.core.GetUser(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + request, err := decodeJSON[dto.UserUpdateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + user, err := h.core.UpdateUser(r.PathValue("id"), request.ApplyTo(existing)) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.UserFromDomain(user)) + default: + writeMethodNotAllowed(w, "GET, PUT") + return + } +} + +func (h *coreHandlers) requirePlatformAdmin(w http.ResponseWriter, r *http.Request) (domain.User, bool) { + user, err := h.core.GetCurrentUser(bearerToken(r)) + if err != nil { + writeServiceError(w, err) + return domain.User{}, false + } + for _, role := range user.Roles { + switch role { + case "platform-admin", "admin": + return user, true + } + } + writeServiceError(w, service.ErrForbidden) + return domain.User{}, false +} + +// aiProviders godoc +// @Summary Create or list AI providers +// @Description Creates or lists platform-managed AI provider metadata without raw keys. +// @Tags ai-providers +// @Accept json +// @Produce json +// @Param body body dto.AIProviderCreateRequest false "AI provider create request" +// @Success 200 {object} dto.AIProviderListResponse +// @Success 201 {object} dto.AIProviderResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/ai-providers [get] +// @Router /api/v1/ai-providers [post] +func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + providers, err := h.core.ListAIProviders(domain.AIProviderFilter{ + Kind: domain.AIProviderKind(r.URL.Query().Get("kind")), + Status: domain.AIProviderStatus(r.URL.Query().Get("status")), + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AIProviderListFromDomain(providers)) + case http.MethodPost: + request, err := decodeJSON[dto.AIProviderCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + provider, err := h.core.CreateAIProvider(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.AIProviderFromDomain(provider)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// aiProviderDetail godoc +// @Summary Get AI provider +// @Description Returns one platform-managed AI provider by ID without raw key material. +// @Tags ai-providers +// @Produce json +// @Param id path string true "AI provider ID" +// @Success 200 {object} dto.AIProviderResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/ai-providers/{id} [get] +// @Router /api/v1/ai-providers/{id} [put] +func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + provider, err := h.core.GetAIProvider(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AIProviderFromDomain(provider)) + case http.MethodPut: + existing, err := h.core.GetAIProvider(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + request, err := decodeJSON[dto.AIProviderUpdateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + provider, err := h.core.UpdateAIProvider(r.PathValue("id"), request.ToDomain(r.PathValue("id"), existing.Status)) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AIProviderFromDomain(provider)) + default: + writeMethodNotAllowed(w, "GET, PUT") + } +} + +// aiProviderStatus godoc +// @Summary Set AI provider status +// @Description Enables or disables one AI provider without exposing raw key material. +// @Tags ai-providers +// @Accept json +// @Produce json +// @Param id path string true "AI provider ID" +// @Param body body dto.AIProviderStatusRequest true "AI provider status request" +// @Success 200 {object} dto.AIProviderResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/ai-providers/{id}/status [post] +func (h *coreHandlers) aiProviderStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.AIProviderStatusRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + provider, err := h.core.SetAIProviderStatus(r.PathValue("id"), request.Status) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AIProviderFromDomain(provider)) +} + +// aiProviderTest godoc +// @Summary Test AI provider metadata +// @Description Performs local metadata validation for one AI provider without external network calls. +// @Tags ai-providers +// @Produce json +// @Param id path string true "AI provider ID" +// @Success 200 {object} dto.AIProviderTestResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/ai-providers/{id}/test [post] +func (h *coreHandlers) aiProviderTest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + result, err := h.core.TestAIProvider(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AIProviderTestFromDomain(result)) +} + +// aiProviderModels godoc +// @Summary List AI provider configured models +// @Description Returns configured model names for one AI provider without exposing credentials. +// @Tags ai-providers +// @Produce json +// @Param id path string true "AI provider ID" +// @Success 200 {object} dto.AIProviderModelsResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/ai-providers/{id}/models [get] +func (h *coreHandlers) aiProviderModels(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + models, err := h.core.ListAIProviderModels(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AIProviderModelsFromDomain(models)) +} + +// aiInvocation godoc +// @Summary Invoke platform-mediated AI +// @Description Invokes AI through platform-owned provider metadata and returns redacted recommendations without exposing provider credentials. +// @Tags ai +// @Accept json +// @Produce json +// @Param body body dto.AIInvocationRequest true "AI invocation request" +// @Success 200 {object} dto.AIInvocationResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/ai/invocations [post] +func (h *coreHandlers) aiInvocation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.AIInvocationRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + response, err := h.core.InvokeAIForSession(bearerToken(r), request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AIInvocationFromDomain(response)) +} + +func (h *coreHandlers) aiConfigSuggestion(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.LlmConfigSuggestionRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + response, err := h.core.InvokeAIForSession(bearerToken(r), domain.AIInvocationRequest{ + RequestID: "config-suggestion:" + request.ServerInstanceID, + ServerInstanceID: request.ServerInstanceID, + Purpose: "config.suggest", + Prompt: request.Prompt, + CurrentConfig: request.CurrentConfig, + ContextRefs: map[string]string{"server": "server://" + request.ServerInstanceID}, + }) + if err != nil { + writeServiceError(w, err) + return + } + if response.Status != "ok" { + recommendation := "AI suggestion unavailable" + if response.Error != nil { + recommendation = response.Error.Message + } + writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: recommendation}) + return + } + suggested := "" + if response.ConfigRecommendation != nil { + suggested = response.ConfigRecommendation.SuggestedConfig + } + writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested}) +} + +// gamePlugins godoc +// @Summary Create or list game management plugins +// @Description Creates or lists installed game management plugin metadata. +// @Tags game-plugins +// @Accept json +// @Produce json +// @Param body body dto.GamePluginCreateRequest false "Game plugin create request" +// @Success 200 {object} dto.GamePluginListResponse +// @Success 201 {object} dto.GamePluginResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/game-plugins [get] +// @Router /api/v1/game-plugins [post] +func (h *coreHandlers) gamePlugins(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + plugins, err := h.core.ListGamePlugins(domain.GamePluginFilter{ + ServerType: r.URL.Query().Get("serverType"), + Status: domain.GamePluginStatus(r.URL.Query().Get("status")), + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.GamePluginListFromDomain(plugins)) + case http.MethodPost: + request, err := decodeJSON[dto.GamePluginCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + plugin, err := h.core.CreateGamePlugin(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.GamePluginFromDomain(plugin)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// gamePluginManifestRegistration godoc +// @Summary Register game management plugin manifest +// @Description Validates and registers one game management plugin manifest as installed registry metadata. +// @Tags game-plugins +// @Accept json +// @Produce json +// @Param body body dto.GamePluginManifestRegistrationRequest true "Game plugin manifest registration request" +// @Success 201 {object} dto.GamePluginResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 409 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/game-plugins/register-manifest [post] +func (h *coreHandlers) gamePluginManifestRegistration(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.GamePluginManifestRegistrationRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + plugin, err := h.core.RegisterGamePluginManifest(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.GamePluginFromDomain(plugin)) +} + +// gamePluginDetail godoc +// @Summary Get game management plugin +// @Description Returns one game management plugin by ID. +// @Tags game-plugins +// @Produce json +// @Param id path string true "Game plugin ID" +// @Success 200 {object} dto.GamePluginResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/game-plugins/{id} [get] +func (h *coreHandlers) gamePluginDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + plugin, err := h.core.GetGamePlugin(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.GamePluginFromDomain(plugin)) +} + +// marketplacePlugins godoc +// @Summary List plugin marketplace entries +// @Description Lists marketplace plugin metadata projected from the platform registry without commerce data, host paths, run sockets, or raw credentials. +// @Tags plugin-marketplace +// @Produce json +// @Param status query string false "Plugin status" +// @Param serverType query string false "Server type" +// @Param capability query string false "Run or bridge capability" +// @Param keyword query string false "Keyword search" +// @Success 200 {object} dto.MarketplacePluginListResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/plugin-marketplace/plugins [get] +func (h *coreHandlers) marketplacePlugins(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + plugins, err := h.core.ListMarketplacePlugins(domain.PluginMarketplaceFilter{ + ServerType: r.URL.Query().Get("serverType"), + Status: domain.GamePluginStatus(r.URL.Query().Get("status")), + Capability: r.URL.Query().Get("capability"), + Keyword: r.URL.Query().Get("keyword"), + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.MarketplacePluginListFromDomain(plugins)) +} + +// marketplacePluginDetail godoc +// @Summary Get plugin marketplace detail +// @Description Returns one plugin marketplace entry with manifest-backed metadata and redacted platform-safe fields. +// @Tags plugin-marketplace +// @Produce json +// @Param id path string true "Plugin ID" +// @Success 200 {object} dto.MarketplacePluginResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/plugin-marketplace/plugins/{id} [get] +func (h *coreHandlers) marketplacePluginDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + plugin, err := h.core.GetMarketplacePlugin(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.MarketplacePluginFromDomain(plugin)) +} + +// marketplacePluginState godoc +// @Summary Change plugin marketplace state +// @Description Applies metadata-only install, enable, or disable state changes without package download or run execution. +// @Tags plugin-marketplace +// @Accept json +// @Produce json +// @Param id path string true "Plugin ID" +// @Param body body dto.MarketplacePluginStateRequest true "Marketplace state action" +// @Success 200 {object} dto.MarketplacePluginResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/plugin-marketplace/plugins/{id}/state [post] +func (h *coreHandlers) marketplacePluginState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.MarketplacePluginStateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + plugin, err := h.core.SetMarketplacePluginState(r.PathValue("id"), request.Action) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.MarketplacePluginFromDomain(plugin)) +} + +// serverInstances godoc +// @Summary Create or list server instances +// @Description Creates or lists server instances linked to installed game plugins and run endpoints. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param body body dto.ServerInstanceCreateRequest false "Server instance create request" +// @Success 200 {object} dto.ServerInstanceListResponse +// @Success 201 {object} dto.ServerInstanceResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances [get] +// @Router /api/v1/server-instances [post] +func (h *coreHandlers) serverInstances(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + instances, err := h.core.ListServerInstancesForSession(bearerToken(r), domain.ServerInstanceFilter{ + PluginID: r.URL.Query().Get("pluginId"), + RunEndpointID: r.URL.Query().Get("runEndpointId"), + State: domain.ServerInstanceState(r.URL.Query().Get("state")), + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerInstanceListFromDomain(instances)) + case http.MethodPost: + request, err := decodeJSON[dto.ServerInstanceCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + instance, err := h.core.CreateServerInstanceForSession(bearerToken(r), request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.ServerInstanceFromDomain(instance)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// serverInstanceDetail godoc +// @Summary Get server instance +// @Description Returns one server instance by ID. +// @Tags server-instances +// @Produce json +// @Param id path string true "Server instance ID" +// @Success 200 {object} dto.ServerInstanceResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id} [get] +func (h *coreHandlers) serverInstanceDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance)) +} + +// platformMetrics godoc +// @Summary Get platform resource usage metrics +// @Description Returns bounded platform resource usage derived from platform metadata without host paths or secrets. +// @Tags metrics +// @Produce json +// @Success 200 {object} dto.PlatformResourceUsageResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/metrics/platform [get] +func (h *coreHandlers) platformMetrics(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + if _, ok := h.requirePlatformAdmin(w, r); !ok { + return + } + usage, err := h.core.GetPlatformResourceUsage() + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.PlatformResourceUsageFromDomain(usage)) +} + +// serverInstanceMetrics godoc +// @Summary List visible server metrics +// @Description Returns bounded metrics for server instances visible to the authenticated user. +// @Tags metrics +// @Produce json +// @Success 200 {object} dto.ServerMetricsListResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/metrics/server-instances [get] +func (h *coreHandlers) serverInstanceMetrics(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + metrics, err := h.core.ListServerMetricsForSession(bearerToken(r)) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerMetricsListFromDomain(metrics)) +} + +// serverInstanceConfig godoc +// @Summary Read server configuration +// @Description Returns logical server configuration content for an authorized server instance without exposing run internals. +// @Tags server-instances +// @Produce json +// @Param id path string true "Server instance ID" +// @Success 200 {object} dto.ServerConfigResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/config [get] +func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + config, err := h.core.GetServerConfigForSession(bearerToken(r), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config)) +} + +// serverInstanceConfigDiff godoc +// @Summary Preview server config diff +// @Description Compares current logical server config with proposed content without dispatching a write job. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param id path string true "Server instance ID" +// @Param body body dto.ServerConfigDiffPreviewRequest true "Config diff preview request" +// @Success 200 {object} dto.ServerConfigDiffPreviewResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/config/diff [post] +func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerConfigDiffPreviewRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + preview, err := h.core.PreviewServerConfigWriteForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerConfigDiffPreviewFromDomain(preview)) +} + +// serverInstanceConfigApprove godoc +// @Summary Approve server config write +// @Description Validates a reviewed config diff and queues a scoped config.write run job without exposing host paths or raw credentials. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param id path string true "Server instance ID" +// @Param body body dto.ServerConfigWriteApprovalRequest true "Config write approval request" +// @Success 202 {object} dto.ServerConfigWriteDispatchResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/config/approve [post] +func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerConfigWriteApprovalRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + dispatch, err := h.core.ApproveServerConfigWriteForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.ServerConfigWriteDispatchFromDomain(dispatch)) +} + +// fileOperationDispatch godoc +// @Summary Dispatch scoped file operation +// @Description Queues a scoped files.read or files.write job using logical file keys or refs, never raw host paths. +// @Tags file-operations +// @Accept json +// @Produce json +// @Param body body dto.FileOperationDispatchRequest true "File operation dispatch request" +// @Success 202 {object} dto.FileOperationDispatchResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/file-operations/dispatch [post] +func (h *coreHandlers) fileOperationDispatch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.FileOperationDispatchRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.DispatchFileOperationForSession(bearerToken(r), request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result)) +} + +// serverAdministratorCandidates godoc +// @Summary List server administrator invite candidates +// @Description Returns active non-platform-admin users that the authenticated server owner can invite for one server. +// @Tags server-instances +// @Produce json +// @Param id path string true "Server instance ID" +// @Success 200 {object} dto.ServerMemberListResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/administrators/candidates [get] +func (h *coreHandlers) serverAdministratorCandidates(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + users, err := h.core.ListServerAdministratorCandidates(bearerToken(r), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerMemberListFromDomain(users)) +} + +// serverAdministrators godoc +// @Summary Add server administrator +// @Description Adds an active non-platform-admin user as a server-scoped administrator when requested by the server owner. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param id path string true "Server instance ID" +// @Param body body dto.ServerMemberRequest true "Server administrator request" +// @Success 200 {object} dto.ServerInstanceResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/administrators [post] +func (h *coreHandlers) serverAdministrators(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerMemberRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + instance, err := h.core.AddServerAdministrator(bearerToken(r), r.PathValue("id"), request.UserID) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance)) +} + +// serverAdministratorDetail godoc +// @Summary Remove server administrator +// @Description Removes a server-scoped administrator from a server when requested by the server owner. +// @Tags server-instances +// @Produce json +// @Param id path string true "Server instance ID" +// @Param userId path string true "User ID" +// @Success 200 {object} dto.ServerInstanceResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/administrators/{userId} [delete] +func (h *coreHandlers) serverAdministratorDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + writeMethodNotAllowed(w, http.MethodDelete) + return + } + instance, err := h.core.RemoveServerAdministrator(bearerToken(r), r.PathValue("id"), r.PathValue("userId")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance)) +} + +// runControlHello godoc +// @Summary Register run control session +// @Description Accepts lightweight run hello metadata, creates or updates run endpoint metadata, and returns a platform-issued session token. +// @Tags run +// @Accept json +// @Produce json +// @Param body body dto.RunControlHelloRequest true "Run control hello request" +// @Success 200 {object} dto.RunControlHelloResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/control/hello [post] +func (h *coreHandlers) runControlHello(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunControlHelloRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.RegisterRunHello(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunControlHelloFromDomain(result)) +} + +// runControlHeartbeat godoc +// @Summary Accept run control heartbeat +// @Description Accepts lightweight run heartbeat metadata when the active platform-issued session token matches. +// @Tags run +// @Accept json +// @Produce json +// @Param body body dto.RunControlHeartbeatRequest true "Run control heartbeat request" +// @Success 200 {object} dto.RunControlHeartbeatResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/control/heartbeat [post] +func (h *coreHandlers) runControlHeartbeat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunControlHeartbeatRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.AcceptRunHeartbeat(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result)) +} + +// runJobClaim godoc +// @Summary Claim one run job +// @Description Lets a registered run endpoint claim one queued job assigned to it using the active session token. +// @Tags run-jobs +// @Accept json +// @Produce json +// @Param body body dto.RunJobClaimRequest true "Run job claim request" +// @Success 200 {object} dto.RunJobClaimResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/jobs/claim [post] +func (h *coreHandlers) runJobClaim(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunJobClaimRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.ClaimRunJob(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunJobClaimFromDomain(result)) +} + +// runJobAck godoc +// @Summary Acknowledge one run job +// @Description Lets a registered run endpoint acknowledge an active job lease before execution. +// @Tags run-jobs +// @Accept json +// @Produce json +// @Param body body dto.RunJobAckRequest true "Run job ack request" +// @Success 200 {object} dto.RunJobAckResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/jobs/ack [post] +func (h *coreHandlers) runJobAck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunJobAckRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.AckRunJob(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunJobAckFromDomain(result)) +} + +// runJobProgress godoc +// @Summary Update run job progress +// @Description Lets a registered run endpoint report bounded progress for an active job lease. +// @Tags run-jobs +// @Accept json +// @Produce json +// @Param body body dto.RunJobProgressRequest true "Run job progress request" +// @Success 200 {object} dto.RunJobProgressResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/jobs/progress [post] +func (h *coreHandlers) runJobProgress(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunJobProgressRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.UpdateRunJobProgress(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunJobProgressFromDomain(result)) +} + +// runJobResult godoc +// @Summary Complete run job +// @Description Lets a registered run endpoint submit a bounded terminal result for an active job lease. +// @Tags run-jobs +// @Accept json +// @Produce json +// @Param body body dto.RunJobResultRequest true "Run job result request" +// @Success 200 {object} dto.RunJobResultResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/jobs/result [post] +func (h *coreHandlers) runJobResult(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunJobResultRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.CompleteRunJob(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunJobResultFromDomain(result)) +} + +// runJobCancelPoll godoc +// @Summary Poll run job cancellation +// @Description Lets a registered run endpoint poll for cancellation requests on active leased jobs. +// @Tags run-jobs +// @Accept json +// @Produce json +// @Param body body dto.RunJobCancelPollRequest true "Run job cancel poll request" +// @Success 200 {object} dto.RunJobCancelPollResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/jobs/cancel [post] +func (h *coreHandlers) runJobCancelPoll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunJobCancelPollRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.PollRunJobCancel(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunJobCancelPollFromDomain(result)) +} + +// runJobReconcile godoc +// @Summary Reconcile run jobs +// @Description Lets a registered run endpoint reconcile active platform jobs after restart or reconnect. +// @Tags run-jobs +// @Accept json +// @Produce json +// @Param body body dto.RunJobReconcileRequest true "Run job reconcile request" +// @Success 200 {object} dto.RunJobReconcileResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/jobs/reconcile [post] +func (h *coreHandlers) runJobReconcile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunJobReconcileRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.ReconcileRunJobs(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunJobReconcileFromDomain(result)) +} + +// runLogBatchIngest godoc +// @Summary Ingest run log batch +// @Description Accepts one bounded durable log batch from a registered run endpoint and returns an acknowledgement range. +// @Tags run-logs +// @Accept json +// @Produce json +// @Param body body dto.LogBatchIngestRequest true "Log batch ingest request" +// @Success 200 {object} dto.LogBatchIngestResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/logs/batches [post] +func (h *coreHandlers) runLogBatchIngest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.LogBatchIngestRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.IngestLogBatch(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.LogBatchIngestFromDomain(result)) +} + +// runArtifactOpen godoc +// @Summary Open run artifact upload transfer +// @Description Lets a registered run endpoint open a resumable upload transfer for a scoped artifact owner. +// @Tags run-artifacts +// @Accept json +// @Produce json +// @Param body body dto.ArtifactTransferOpenRequest true "Artifact transfer open request" +// @Success 200 {object} dto.ArtifactTransferOpenResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/artifacts/open [post] +func (h *coreHandlers) runArtifactOpen(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ArtifactTransferOpenRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.OpenArtifactTransfer(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ArtifactTransferOpenFromDomain(result)) +} + +// runArtifactChunkUpload godoc +// @Summary Upload run artifact chunk +// @Description Accepts one bounded artifact chunk from a registered run endpoint and returns resumable acknowledgement state. +// @Tags run-artifacts +// @Accept json +// @Produce json +// @Param body body dto.ArtifactChunkUploadRequest true "Artifact chunk upload request" +// @Success 200 {object} dto.ArtifactChunkUploadResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/artifacts/chunks [post] +func (h *coreHandlers) runArtifactChunkUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ArtifactChunkUploadRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.UploadArtifactChunk(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ArtifactChunkUploadFromDomain(result)) +} + +// runArtifactStatus godoc +// @Summary Query run artifact upload status +// @Description Returns resumable chunk acknowledgement state for one artifact transfer. +// @Tags run-artifacts +// @Accept json +// @Produce json +// @Param body body dto.ArtifactTransferStatusRequest true "Artifact transfer status request" +// @Success 200 {object} dto.ArtifactTransferStatusResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/artifacts/status [post] +func (h *coreHandlers) runArtifactStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ArtifactTransferStatusRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.QueryArtifactTransferStatus(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ArtifactTransferStatusFromDomain(result)) +} + +// runArtifactComplete godoc +// @Summary Complete run artifact upload transfer +// @Description Marks an artifact available only after every chunk is present and final checksum validation passes. +// @Tags run-artifacts +// @Accept json +// @Produce json +// @Param body body dto.ArtifactTransferCompleteRequest true "Artifact transfer complete request" +// @Success 200 {object} dto.ArtifactTransferCompleteResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/artifacts/complete [post] +func (h *coreHandlers) runArtifactComplete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ArtifactTransferCompleteRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.CompleteArtifactTransfer(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ArtifactTransferCompleteFromDomain(result)) +} + +// runEndpoints godoc +// @Summary Create or list run endpoints +// @Description Creates or lists run endpoint metadata used by platform-mediated jobs. +// @Tags run +// @Accept json +// @Produce json +// @Param body body dto.RunEndpointCreateRequest false "Run endpoint create request" +// @Success 200 {object} dto.RunEndpointListResponse +// @Success 201 {object} dto.RunEndpointResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/endpoints [get] +// @Router /api/v1/run/endpoints [post] +func (h *coreHandlers) runEndpoints(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + endpoints, err := h.core.ListRunEndpoints(domain.RunEndpointFilter{Status: domain.RunEndpointStatus(r.URL.Query().Get("status"))}) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunEndpointListFromDomain(endpoints)) + case http.MethodPost: + request, err := decodeJSON[dto.RunEndpointCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + endpoint, err := h.core.CreateRunEndpoint(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.RunEndpointFromDomain(endpoint)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// runEndpointDetail godoc +// @Summary Get run endpoint +// @Description Returns one run endpoint by ID. +// @Tags run +// @Produce json +// @Param id path string true "Run endpoint ID" +// @Success 200 {object} dto.RunEndpointResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/endpoints/{id} [get] +func (h *coreHandlers) runEndpointDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + endpoint, err := h.core.GetRunEndpoint(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunEndpointFromDomain(endpoint)) +} + +// jobs godoc +// @Summary Create or list jobs +// @Description Creates or lists platform job metadata. +// @Tags jobs +// @Accept json +// @Produce json +// @Param body body dto.JobCreateRequest false "Job create request" +// @Success 200 {object} dto.JobListResponse +// @Success 201 {object} dto.JobResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/jobs [get] +// @Router /api/v1/jobs [post] +func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + jobs, err := h.core.ListJobs(domain.JobFilter{ + ServerInstanceID: r.URL.Query().Get("serverInstanceId"), + RunEndpointID: r.URL.Query().Get("runEndpointId"), + State: domain.JobState(r.URL.Query().Get("state")), + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.JobListFromDomain(jobs)) + case http.MethodPost: + request, err := decodeJSON[dto.JobCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + job, err := h.core.CreateJob(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.JobFromDomain(job)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// jobCancel godoc +// @Summary Request job cancellation +// @Description Records a cancellation request for an accepted or running job; run observes it through the job cancel poll route. +// @Tags jobs +// @Accept json +// @Produce json +// @Param id path string true "Job ID" +// @Param body body dto.RunJobCancelRequestBody true "Job cancel request" +// @Success 200 {object} dto.RunJobCancelRequestResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/jobs/{id}/cancel [post] +func (h *coreHandlers) jobCancel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunJobCancelRequestBody](r) + if err != nil { + writeDecodeError(w, err) + return + } + request.JobID = r.PathValue("id") + result, err := h.core.RequestRunJobCancel(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunJobCancelRequestFromDomain(result)) +} + +// jobDetail godoc +// @Summary Get job +// @Description Returns one platform job by ID. +// @Tags jobs +// @Produce json +// @Param id path string true "Job ID" +// @Success 200 {object} dto.JobResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/jobs/{id} [get] +func (h *coreHandlers) jobDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + job, err := h.core.GetJob(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.JobFromDomain(job)) +} + +// artifacts godoc +// @Summary Create or list artifact metadata +// @Description Creates or lists artifact metadata; run chunk upload and browser content download use dedicated artifact transfer routes. +// @Tags artifacts +// @Accept json +// @Produce json +// @Param body body dto.ArtifactCreateRequest false "Artifact create request" +// @Success 200 {object} dto.ArtifactListResponse +// @Success 201 {object} dto.ArtifactResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/artifacts [get] +// @Router /api/v1/artifacts [post] +func (h *coreHandlers) artifacts(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + artifacts, err := h.core.ListArtifacts(domain.ArtifactFilter{ + OwnerKind: domain.ArtifactOwnerKind(r.URL.Query().Get("ownerKind")), + OwnerID: r.URL.Query().Get("ownerId"), + State: domain.ArtifactState(r.URL.Query().Get("state")), + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ArtifactListFromDomain(artifacts)) + case http.MethodPost: + request, err := decodeJSON[dto.ArtifactCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + artifact, err := h.core.CreateArtifact(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.ArtifactFromDomain(artifact)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// artifactDetail godoc +// @Summary Get artifact metadata +// @Description Returns one artifact metadata record by ID. +// @Tags artifacts +// @Produce json +// @Param id path string true "Artifact ID" +// @Success 200 {object} dto.ArtifactResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/artifacts/{id} [get] +func (h *coreHandlers) artifactDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + artifact, err := h.core.GetArtifactForSession(bearerToken(r), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ArtifactFromDomain(artifact)) +} + +// artifactDownload godoc +// @Summary Open a browser-safe artifact download reference +// @Description Returns a platform-owned artifact download reference without exposing storage paths, direct run sockets, or credentials. +// @Tags artifacts +// @Produce json +// @Param id path string true "Artifact ID" +// @Success 200 {object} dto.ArtifactDownloadReferenceResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/artifacts/{id}/download [post] +func (h *coreHandlers) artifactDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + reference, err := h.core.OpenArtifactDownloadForSession(bearerToken(r), domain.ArtifactDownloadReferenceRequest{ArtifactID: r.PathValue("id")}) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference)) +} + +// artifactContent godoc +// @Summary Read bounded artifact content +// @Description Streams a bounded artifact byte range through platform authorization with safe integrity headers. +// @Tags artifacts +// @Produce octet-stream +// @Param id path string true "Artifact ID" +// @Param offset query int false "Byte offset" +// @Param limit query int false "Maximum bytes" +// @Success 200 {file} binary +// @Success 206 {file} binary +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/artifacts/{id}/content [get] +func (h *coreHandlers) artifactContent(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + offset, limit, err := artifactRangeRequest(r) + if err != nil { + writeServiceError(w, err) + return + } + content, err := h.core.ReadArtifactContentForSession(bearerToken(r), domain.ArtifactContentRequest{ArtifactID: r.PathValue("id"), Offset: offset, Limit: limit}) + if err != nil { + writeServiceError(w, err) + return + } + w.Header().Set("Content-Type", content.ContentType) + w.Header().Set("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(content.Filename, "\"", "")+"\"") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Length", strconv.FormatInt(content.SizeBytes, 10)) + w.Header().Set("X-Artifact-Id", content.ArtifactID) + w.Header().Set("X-Artifact-Checksum", content.Checksum) + w.Header().Set("X-Artifact-Content-Checksum", content.ContentChecksum) + w.Header().Set("X-Artifact-Storage", content.StorageBehavior) + if content.Partial { + end := content.Offset + content.SizeBytes - 1 + w.Header().Set("Content-Range", "bytes "+strconv.FormatInt(content.Offset, 10)+"-"+strconv.FormatInt(end, 10)+"/"+strconv.FormatInt(content.TotalSizeBytes, 10)) + w.WriteHeader(http.StatusPartialContent) + } else { + w.WriteHeader(http.StatusOK) + } + _, _ = w.Write(content.Payload) +} + +func artifactRangeRequest(r *http.Request) (int64, int, error) { + query := r.URL.Query() + offset, err := parseOptionalInt64(query.Get("offset")) + if err != nil { + return 0, 0, validator.ValidationError{Violations: []string{"offset must be a number"}} + } + limit64, err := parseOptionalInt64(query.Get("limit")) + if err != nil { + return 0, 0, validator.ValidationError{Violations: []string{"limit must be a number"}} + } + limit := int(limit64) + if rangeHeader := strings.TrimSpace(r.Header.Get("Range")); rangeHeader != "" { + rangeOffset, rangeLimit, ok := parseByteRange(rangeHeader) + if !ok { + return 0, 0, validator.ValidationError{Violations: []string{"range header is invalid"}} + } + offset = rangeOffset + limit = rangeLimit + } + return offset, limit, nil +} + +func parseOptionalInt64(value string) (int64, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + return strconv.ParseInt(value, 10, 64) +} + +func parseByteRange(header string) (int64, int, bool) { + if !strings.HasPrefix(header, "bytes=") { + return 0, 0, false + } + parts := strings.Split(strings.TrimPrefix(header, "bytes="), "-") + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return 0, 0, false + } + start, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, 0, false + } + end, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || end < start { + return 0, 0, false + } + length := end - start + 1 + if length > int64(validator.MaxArtifactDownloadBytes) { + return 0, 0, false + } + return start, int(length), true +} + +// logStreams godoc +// @Summary Create or list log stream metadata +// @Description Creates or lists log stream metadata; durable ingest and cursor query use dedicated log routes while browser tail transport remains future work. +// @Tags logs +// @Accept json +// @Produce json +// @Param body body dto.LogStreamCreateRequest false "Log stream create request" +// @Success 200 {object} dto.LogStreamListResponse +// @Success 201 {object} dto.LogStreamResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/log-streams [get] +// @Router /api/v1/log-streams [post] +func (h *coreHandlers) logStreams(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ + ServerInstanceID: r.URL.Query().Get("serverInstanceId"), + StreamKey: r.URL.Query().Get("streamKey"), + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.LogStreamListFromDomain(streams)) + case http.MethodPost: + request, err := decodeJSON[dto.LogStreamCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + stream, err := h.core.CreateLogStream(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.LogStreamFromDomain(stream)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// logStreamQuery godoc +// @Summary Query log stream entries +// @Description Returns bounded stored log entries after a stream sequence cursor. +// @Tags logs +// @Accept json +// @Produce json +// @Param body body dto.LogStreamCursorRequest true "Log stream cursor request" +// @Success 200 {object} dto.LogStreamCursorResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/log-streams/query [post] +func (h *coreHandlers) logStreamQuery(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.LogStreamCursorRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.QueryLogStream(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.LogStreamCursorFromDomain(result)) +} + +// logStreamDetail godoc +// @Summary Get log stream metadata +// @Description Returns one log stream metadata record by ID. +// @Tags logs +// @Produce json +// @Param id path string true "Log stream ID" +// @Success 200 {object} dto.LogStreamResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/log-streams/{id} [get] +func (h *coreHandlers) logStreamDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + stream, err := h.core.GetLogStream(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.LogStreamFromDomain(stream)) +} + +// auditEvents godoc +// @Summary Create or list audit events +// @Description Creates or lists audit event metadata. +// @Tags audit-events +// @Accept json +// @Produce json +// @Param body body dto.AuditEventCreateRequest false "Audit event create request" +// @Success 200 {object} dto.AuditEventListResponse +// @Success 201 {object} dto.AuditEventResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/audit-events [get] +// @Router /api/v1/audit-events [post] +func (h *coreHandlers) auditEvents(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + events, err := h.core.ListAuditEvents(domain.AuditEventFilter{ + ActorID: r.URL.Query().Get("actorId"), + ResourceKind: r.URL.Query().Get("resourceKind"), + ResourceID: r.URL.Query().Get("resourceId"), + Result: domain.AuditResult(r.URL.Query().Get("result")), + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AuditEventListFromDomain(events)) + case http.MethodPost: + request, err := decodeJSON[dto.AuditEventCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + event, err := h.core.CreateAuditEvent(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, dto.AuditEventFromDomain(event)) + default: + writeMethodNotAllowed(w, "GET, POST") + } +} + +// auditEventDetail godoc +// @Summary Get audit event +// @Description Returns one audit event by ID. +// @Tags audit-events +// @Produce json +// @Param id path string true "Audit event ID" +// @Success 200 {object} dto.AuditEventResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/audit-events/{id} [get] +func (h *coreHandlers) auditEventDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + event, err := h.core.GetAuditEvent(r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.AuditEventFromDomain(event)) +} diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go new file mode 100644 index 0000000..e87b20d --- /dev/null +++ b/platform/api/resource_handlers_test.go @@ -0,0 +1,1422 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + + "browser.local/platform/config" + "browser.local/platform/domain" + "browser.local/platform/dto" + "browser.local/platform/repo" + "browser.local/platform/service" +) + +func TestCoreAPICreateListDetailWorkflows(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + + userResponse := postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-1", + DisplayName: "Operator", + Email: "operator@example.test", + Roles: []string{"admin"}, + }, adminSession) + if userResponse.Status != domain.UserStatusActive { + t.Fatalf("expected active user, got %+v", userResponse) + } + getJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users/user-1", adminSession) + users := getJSONWithAuth[dto.UserListResponse](t, router, "/api/v1/users?status=active", adminSession) + assertListCount(t, users.Count, 2) + + providerResponse := postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) + if providerResponse.APIKeyRef != "secret://providers/openai" { + t.Fatalf("expected AI provider key reference, got %+v", providerResponse) + } + getJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai") + providers := getJSON[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active") + assertListCount(t, providers.Count, 1) + + pluginResponse := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + if pluginResponse.Status != domain.GamePluginStatusInstalled { + t.Fatalf("expected installed plugin, got %+v", pluginResponse) + } + getJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/server.scum") + plugins := getJSON[dto.GamePluginListResponse](t, router, "/api/v1/game-plugins?serverType=scum&status=installed") + assertListCount(t, plugins.Count, 1) + + endpointResponse := postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + if endpointResponse.Status != domain.RunEndpointStatusOnline { + t.Fatalf("expected online run endpoint, got %+v", endpointResponse) + } + getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local") + endpoints := getJSON[dto.RunEndpointListResponse](t, router, "/api/v1/run/endpoints?status=online") + assertListCount(t, endpoints.Count, 1) + + instanceResponse := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-1", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "SCUM #1", + }, adminSession) + if instanceResponse.State != domain.ServerInstanceStateDraft || instanceResponse.PluginVersion != "1.0.0" { + t.Fatalf("expected server defaults, got %+v", instanceResponse) + } + getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-1", adminSession) + instances := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?pluginId=server.scum&runEndpointId=run-local&state=draft", adminSession) + assertListCount(t, instances.Count, 1) + + jobResponse := postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ + ID: "job-1", + ServerInstanceID: "server-1", + RunEndpointID: "run-local", + Capability: "process.start", + IdempotencyKey: "idem-start", + }) + if jobResponse.State != domain.JobStateQueued { + t.Fatalf("expected queued job, got %+v", jobResponse) + } + getJSON[dto.JobResponse](t, router, "/api/v1/jobs/job-1") + jobs := getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued") + assertListCount(t, jobs.Count, 1) + + artifactResponse := postJSON[dto.ArtifactResponse](t, router, "/api/v1/artifacts", dto.ArtifactCreateRequest{ + ID: "artifact-1", + OwnerKind: domain.ArtifactOwnerKindJob, + OwnerID: "job-1", + SizeBytes: 128, + Checksum: "sha256:abc", + }) + if artifactResponse.State != domain.ArtifactStateUploading { + t.Fatalf("expected uploading artifact, got %+v", artifactResponse) + } + getJSONWithAuth[dto.ArtifactResponse](t, router, "/api/v1/artifacts/artifact-1", adminSession) + artifacts := getJSON[dto.ArtifactListResponse](t, router, "/api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading") + assertListCount(t, artifacts.Count, 1) + + streamResponse := postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ + ID: "log-1", + ServerInstanceID: "server-1", + Source: domain.LogStreamSourceProcess, + StreamKey: "stdout", + StorageBackend: domain.LogStorageBackendLocalSegments, + RetentionPolicy: "default", + }) + if streamResponse.StreamKey != "stdout" { + t.Fatalf("expected stdout stream, got %+v", streamResponse) + } + getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1") + streams := getJSON[dto.LogStreamListResponse](t, router, "/api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout") + assertListCount(t, streams.Count, 1) + + auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{ + ID: "audit-1", + ActorID: "user-1", + Action: "server.create", + ResourceKind: "server-instance", + ResourceID: "server-1", + Result: domain.AuditResultSuccess, + Summary: "created server instance", + }) + if auditResponse.Result != domain.AuditResultSuccess { + t.Fatalf("expected successful audit event, got %+v", auditResponse) + } + getJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events/audit-1") + auditEvents := getJSON[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success") + assertListCount(t, auditEvents.Count, 1) +} + +func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-owner-metrics", + DisplayName: "Metrics Owner", + Email: "owner-metrics@example.test", + Roles: []string{"server-owner"}, + Password: "secret-password", + }, adminSession) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-other-metrics", + DisplayName: "Metrics Other", + Email: "other-metrics@example.test", + Roles: []string{"server-admin"}, + Password: "secret-password", + }, adminSession) + ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-metrics@example.test", Password: "secret-password"}).SessionID + otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-metrics@example.test", Password: "secret-password"}).SessionID + + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-metrics-api", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "Metrics API Server", + State: domain.ServerInstanceStateRunning, + }, ownerSession) + + usage := getJSONWithAuth[dto.PlatformResourceUsageResponse](t, router, "/api/v1/metrics/platform", adminSession) + if usage.Source != "platform-derived" || usage.CPUPercent < 0 || usage.CPUPercent > 100 || usage.CollectedAt.IsZero() { + t.Fatalf("unexpected platform metrics: %+v", usage) + } + assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/metrics/platform", "", ownerSession), http.StatusForbidden, errorCodeForbidden) + + ownerMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", ownerSession) + if ownerMetrics.Count != 1 || ownerMetrics.Items[0].ServerInstanceID != instance.ID || !ownerMetrics.Items[0].Online { + t.Fatalf("unexpected owner metrics: %+v", ownerMetrics) + } + otherMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", otherSession) + if otherMetrics.Count != 0 { + t.Fatalf("expected no metrics for other user, got %+v", otherMetrics) + } + + configRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession) + assertStatus(t, configRecorder, http.StatusOK) + config := decodeBody[dto.ServerConfigResponse](t, configRecorder) + if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics API Server") { + t.Fatalf("unexpected config response: %+v", config) + } + for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} { + if strings.Contains(configRecorder.Body.String(), forbidden) { + t.Fatalf("config response exposed forbidden fragment %q: %s", forbidden, configRecorder.Body.String()) + } + } + assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusForbidden, errorCodeForbidden) +} + +func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-owner-config-api", + DisplayName: "Config API Owner", + Email: "owner-config-api@example.test", + Roles: []string{"server-owner"}, + Password: "secret-password", + }, adminSession) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-other-config-api", + DisplayName: "Config API Other", + Email: "other-config-api@example.test", + Roles: []string{"server-admin"}, + Password: "secret-password", + }, adminSession) + ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-config-api@example.test", Password: "secret-password"}).SessionID + otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-config-api@example.test", Password: "secret-password"}).SessionID + + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-config-api", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "Config API Server", + State: domain.ServerInstanceStateRunning, + }, ownerSession) + config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession) + proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1) + + previewRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ + ExpectedConfigVersion: config.ConfigVersion, + Key: config.Key, + ProposedContent: proposed, + }, ownerSession) + assertStatus(t, previewRecorder, http.StatusOK) + preview := decodeBody[dto.ServerConfigDiffPreviewResponse](t, previewRecorder) + if !preview.HasChanges || preview.Source != "platform-review" || preview.ServerInstanceID != instance.ID { + t.Fatalf("unexpected preview: %+v", preview) + } + jobsAfterPreview := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-config-api", ownerSession) + if jobsAfterPreview.Count != 0 { + t.Fatalf("preview must not create jobs: %+v", jobsAfterPreview) + } + + approveRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ + ExpectedConfigVersion: config.ConfigVersion, + Key: config.Key, + ProposedContent: proposed, + IdempotencyKey: "idem-config-api", + }, ownerSession) + assertStatus(t, approveRecorder, http.StatusAccepted) + dispatch := decodeBody[dto.ServerConfigWriteDispatchResponse](t, approveRecorder) + if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != config.Key || dispatch.Job.InputRef == "" { + t.Fatalf("unexpected approval dispatch: %+v", dispatch) + } + for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} { + if strings.Contains(approveRecorder.Body.String(), forbidden) { + t.Fatalf("approval response exposed forbidden fragment %q: %s", forbidden, approveRecorder.Body.String()) + } + } + + assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ + ExpectedConfigVersion: config.ConfigVersion + 1, + Key: config.Key, + ProposedContent: proposed, + }, ownerSession), http.StatusBadRequest, errorCodeValidation) + assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ + ExpectedConfigVersion: config.ConfigVersion, + Key: config.Key, + ProposedContent: proposed, + IdempotencyKey: "idem-forbidden-api", + }, otherSession), http.StatusForbidden, errorCodeForbidden) + assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{ + ServerInstanceID: "server-config-api", + Operation: domain.FileOperationRead, + Key: "/Users/tasia/.ssh/id_rsa", + IdempotencyKey: "idem-file-unsafe-api", + }, ownerSession), http.StatusBadRequest, errorCodeValidation) + + fileRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{ + ServerInstanceID: "server-config-api", + Operation: domain.FileOperationRead, + Key: "logs/latest.log", + IdempotencyKey: "idem-file-api", + }, ownerSession) + assertStatus(t, fileRecorder, http.StatusAccepted) + fileDispatch := decodeBody[dto.FileOperationDispatchResponse](t, fileRecorder) + if fileDispatch.Job.Capability != domain.JobCapabilityFilesRead || fileDispatch.Job.TargetKey != "logs/latest.log" { + t.Fatalf("unexpected file dispatch: %+v", fileDispatch) + } +} + +func TestCoreAPIErrorResponses(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + + malformed := requestWithAuth(t, router, http.MethodPost, "/api/v1/users", "{", adminSession) + assertErrorResponse(t, malformed, http.StatusBadRequest, errorCodeBadRequest) + + invalid := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/users", dto.UserCreateRequest{}, adminSession) + assertErrorResponse(t, invalid, http.StatusBadRequest, errorCodeValidation) + + created := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/users", dto.UserCreateRequest{ID: "user-1", DisplayName: "Operator"}, adminSession) + assertStatus(t, created, http.StatusCreated) + duplicate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/users", dto.UserCreateRequest{ID: "user-1", DisplayName: "Operator"}, adminSession) + assertErrorResponse(t, duplicate, http.StatusConflict, errorCodeDuplicate) + + missing := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/missing", "", adminSession) + assertErrorResponse(t, missing, http.StatusNotFound, errorCodeNotFound) + + dependencyFailure := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-missing", + PluginID: "server.missing", + RunEndpointID: "run-missing", + Name: "Missing Dependencies", + }, adminSession) + assertErrorResponse(t, dependencyFailure, http.StatusNotFound, errorCodeNotFound) + + rawKey := validAIProviderRequest() + rawKey.ID = "ai.raw" + rawKey.APIKeyRef = "sk-raw-secret" + providerFailure := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey) + assertErrorResponse(t, providerFailure, http.StatusBadRequest, errorCodeValidation) + missingProvider := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/ai.raw", "") + assertErrorResponse(t, missingProvider, http.StatusNotFound, errorCodeNotFound) + + methodFailure := requestWithAuth(t, router, http.MethodDelete, "/api/v1/users", "", adminSession) + assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed) + if allow := methodFailure.Header().Get("Allow"); allow != "GET, POST" { + t.Fatalf("expected Allow header %q, got %q", "GET, POST", allow) + } +} + +func TestAuthSessionAPI(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + created := postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-auth", + DisplayName: "Auth User", + Email: "auth@example.test", + Roles: []string{"platform-admin"}, + Password: "secret-password", + }, adminSession) + if _, exists := anyJSON(t, created)["passwordHash"]; exists { + t.Fatalf("user response must not expose passwordHash") + } + + login := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "auth@example.test", Password: "secret-password"}) + if login.SessionID == "" || login.Status != "authenticated" || login.User.ID != "user-auth" { + t.Fatalf("unexpected login response: %+v", login) + } + + currentRecorder := performRaw(t, router, http.MethodGet, "/api/v1/users/current", "") + currentRecorder.Result().Header.Set("unused", "unused") + assertErrorResponse(t, currentRecorder, http.StatusUnauthorized, errorCodeUnauthorized) + + current := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", login.SessionID) + assertStatus(t, current, http.StatusOK) + currentUser := decodeBody[dto.CurrentUserResponse](t, current) + if currentUser.ID != "user-auth" || currentUser.Roles[0] != "platform-admin" { + t.Fatalf("unexpected current user: %+v", currentUser) + } + + profile := requestWithAuth(t, router, http.MethodPut, "/api/v1/users/current/profile", `{"displayName":"ignored","phone":"13900000000","contactNote":"primary contact"}`, login.SessionID) + assertStatus(t, profile, http.StatusOK) + updatedProfile := decodeBody[dto.CurrentUserResponse](t, profile) + if updatedProfile.Profile.Phone != "13900000000" || updatedProfile.Profile.ContactNote != "primary contact" { + t.Fatalf("unexpected profile update response: %+v", updatedProfile) + } + + theme := requestWithAuth(t, router, http.MethodPut, "/api/v1/users/current/theme", `{"paletteId":"crystal-moonlight","backgroundPresetId":"moon"}`, login.SessionID) + assertStatus(t, theme, http.StatusOK) + themeResponse := decodeBody[dto.UserThemePreferenceResponse](t, theme) + if themeResponse.UserID != "user-auth" || themeResponse.Persistence != "api" { + t.Fatalf("unexpected theme response: %+v", themeResponse) + } + + logout := requestWithAuth(t, router, http.MethodPost, "/api/v1/auth/logout", "", login.SessionID) + assertStatus(t, logout, http.StatusNoContent) + currentAfterLogout := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", login.SessionID) + assertErrorResponse(t, currentAfterLogout, http.StatusUnauthorized, errorCodeUnauthorized) +} + +func TestDefaultRouterSeedsLocalPlatformAdmin(t *testing.T) { + router, err := NewRouterFromConfig(config.Config{ + StorageBackend: "file", + MetadataPath: filepath.Join(t.TempDir(), "metadata.json"), + LogDir: filepath.Join(t.TempDir(), "logs"), + }) + if err != nil { + t.Fatalf("create default router: %v", err) + } + login := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{ + Account: "operator.local@example.test", + Password: "operator-local", + }) + if login.SessionID == "" || login.Status != "authenticated" || login.User.ID != "user-admin" { + t.Fatalf("unexpected default operator login response: %+v", login) + } + + current := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", login.SessionID) + assertStatus(t, current, http.StatusOK) + currentUser := decodeBody[dto.CurrentUserResponse](t, current) + if currentUser.ID != "user-admin" || len(currentUser.Roles) == 0 || currentUser.Roles[0] != "platform-admin" { + t.Fatalf("unexpected default current user: %+v", currentUser) + } +} + +func TestRouterConfigRejectsMySQLWithoutDSN(t *testing.T) { + _, err := NewRouterFromConfig(config.Config{ + StorageBackend: "mysql", + LogDir: t.TempDir(), + }) + if err == nil || !strings.Contains(err.Error(), "PLATFORM_MYSQL_DSN") { + t.Fatalf("expected missing MySQL DSN error, got %v", err) + } +} + +func TestMySQLMetadataDefaultsToFileLogBodyStore(t *testing.T) { + logStore, err := logStoreFromConfig(config.Config{ + StorageBackend: "mysql", + LogDir: t.TempDir(), + }) + if err != nil { + t.Fatalf("create log store: %v", err) + } + if _, ok := logStore.(*service.FileLogBodyStore); !ok { + t.Fatalf("expected MySQL metadata to default to file log body store, got %T", logStore) + } +} + +func TestRegisterAPICreatesPendingLowPrivilegeUser(t *testing.T) { + router := newTestRouter() + registration := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/register", dto.RegisterRequest{ + DisplayName: "Pending Player", + Email: "pending@example.test", + Password: "secret-password", + Phone: "13800000000", + QQ: "10001", + }) + if registration.Status != "pending" || registration.SessionID != "" { + t.Fatalf("expected pending registration without session, got %+v", registration) + } + if registration.User.Status != domain.UserStatusPending || registration.User.Roles[0] != "server-admin" { + t.Fatalf("expected pending server-admin registration, got %+v", registration.User) + } + if registration.User.Roles[0] == "platform-admin" || registration.User.Roles[0] == "admin" { + t.Fatalf("registration must not grant platform admin: %+v", registration.User) + } + + login := performJSON(t, router, http.MethodPost, "/api/v1/auth/login", dto.LoginRequest{Account: "pending@example.test", Password: "secret-password"}) + assertErrorResponse(t, login, http.StatusForbidden, errorCodeForbidden) +} + +func TestRegisterAPIBootstrapsFirstPlatformAdmin(t *testing.T) { + router := apiRouterWithoutSeededAdmin() + registration := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/register", dto.RegisterRequest{ + DisplayName: "Bootstrap Admin", + Email: "bootstrap@example.test", + Password: "secret-password", + }) + if registration.Status != "authenticated" || registration.SessionID == "" { + t.Fatalf("expected first registration to authenticate, got %+v", registration) + } + if registration.User.Status != domain.UserStatusActive || len(registration.User.Roles) != 1 || registration.User.Roles[0] != "platform-admin" { + t.Fatalf("expected first registration to create platform admin, got %+v", registration.User) + } + + current := getJSONWithAuth[dto.CurrentUserResponse](t, router, "/api/v1/users/current", registration.SessionID) + if current.ID != registration.User.ID || current.Roles[0] != "platform-admin" { + t.Fatalf("unexpected current bootstrap user: %+v", current) + } +} + +func TestUserUpdateAPI(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + created := postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + DisplayName: "Generated API User", + Email: "generated-api-user@example.test", + Roles: []string{"server-admin"}, + }, adminSession) + if created.ID != "user-generated-api-user-example-test" { + t.Fatalf("expected generated user id, got %+v", created) + } + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-update", + DisplayName: "User Update", + Email: "update@example.test", + Roles: []string{"server-admin"}, + }, adminSession) + + status := domain.UserStatusDisabled + displayName := "User Updated" + updated := putJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users/user-update", dto.UserUpdateRequest{ + DisplayName: &displayName, + Status: &status, + Roles: []string{"server-owner"}, + Profile: &dto.UserProfileBody{Phone: "13700000000"}, + }, adminSession) + if updated.DisplayName != displayName || updated.Status != status || updated.Roles[0] != "server-owner" || updated.Profile.Phone != "13700000000" { + t.Fatalf("unexpected user update: %+v", updated) + } + + unauthorized := performJSON(t, router, http.MethodPut, "/api/v1/users/user-update", dto.UserUpdateRequest{Status: &status}) + assertErrorResponse(t, unauthorized, http.StatusUnauthorized, errorCodeUnauthorized) +} + +func TestServerLifecycleWorkflowAPI(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + + created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ + ID: "server-create", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "SCUM Create", + IdempotencyKey: "idem-create", + }, adminSession) + if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall { + t.Fatalf("expected create workflow response, got %+v", created) + } + + ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-ready", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "SCUM Ready", + State: domain.ServerInstanceStateReady, + }, adminSession) + started := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{ + ExpectedConfigVersion: ready.ConfigVersion, + IdempotencyKey: "idem-start", + }, adminSession) + if started.Action != domain.ServerLifecycleActionStart || started.Job.Capability != domain.LifecycleCapabilityStart { + t.Fatalf("expected start workflow response, got %+v", started) + } + + running := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-running", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "SCUM Running", + State: domain.ServerInstanceStateRunning, + }, adminSession) + stopped := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-running/stop", dto.ServerLifecycleCommandRequest{ + ExpectedConfigVersion: running.ConfigVersion, + IdempotencyKey: "idem-stop", + }, adminSession) + if stopped.Action != domain.ServerLifecycleActionStop || stopped.Job.Capability != domain.LifecycleCapabilityStop { + t.Fatalf("expected stop workflow response, got %+v", stopped) + } + + stale := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{ + ExpectedConfigVersion: ready.ConfigVersion + 1, + IdempotencyKey: "idem-stale", + }, adminSession) + assertErrorResponse(t, stale, http.StatusBadRequest, errorCodeValidation) + + invalidStop := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-ready/stop", dto.ServerLifecycleCommandRequest{ + ExpectedConfigVersion: ready.ConfigVersion, + IdempotencyKey: "idem-invalid-stop", + }, adminSession) + assertErrorResponse(t, invalidStop, http.StatusBadRequest, errorCodeValidation) +} + +func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-owner", + DisplayName: "Server Owner", + Email: "owner@example.test", + Roles: []string{"server-owner"}, + Status: domain.UserStatusActive, + Password: "secret-password", + }, adminSession) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-helper", + DisplayName: "Server Helper", + Email: "helper@example.test", + Roles: []string{"server-admin"}, + Status: domain.UserStatusActive, + Password: "secret-password", + }, adminSession) + + ownerLogin := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner@example.test", Password: "secret-password"}) + helperLogin := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "helper@example.test", Password: "secret-password"}) + + created := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-owned", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "Owned Server", + State: domain.ServerInstanceStateReady, + }, ownerLogin.SessionID) + if created.OwnerUserID != "user-owner" || len(created.AdminUserIDs) != 0 { + t.Fatalf("expected owner-bound server, got %+v", created) + } + createdBody := anyJSON(t, created) + adminUserIDs, ok := createdBody["adminUserIds"].([]any) + if !ok || len(adminUserIDs) != 0 { + t.Fatalf("expected adminUserIds to serialize as an empty array, got %+v", createdBody["adminUserIds"]) + } + + adminList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession) + assertListCount(t, adminList.Count, 1) + ownerList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", ownerLogin.SessionID) + assertListCount(t, ownerList.Count, 1) + helperListBefore := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", helperLogin.SessionID) + assertListCount(t, helperListBefore.Count, 0) + + candidates := getJSONWithAuth[dto.ServerMemberListResponse](t, router, "/api/v1/server-instances/server-owned/administrators/candidates", ownerLogin.SessionID) + if candidates.Count != 1 || candidates.Items[0].ID != "user-helper" { + t.Fatalf("expected only non-platform helper candidate, got %+v", candidates) + } + helperCandidateBody := anyJSON(t, candidates.Items[0]) + if _, exists := helperCandidateBody["passwordHash"]; exists { + t.Fatalf("server member response must not expose passwordHash: %+v", helperCandidateBody) + } + + added := postOKJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-helper"}, ownerLogin.SessionID) + if len(added.AdminUserIDs) != 1 || added.AdminUserIDs[0] != "user-helper" { + t.Fatalf("expected helper admin membership, got %+v", added) + } + helperDetail := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-owned", helperLogin.SessionID) + if helperDetail.ID != "server-owned" { + t.Fatalf("expected helper to access assigned server, got %+v", helperDetail) + } + + nonOwnerAdd := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-owner"}, helperLogin.SessionID) + assertErrorResponse(t, nonOwnerAdd, http.StatusForbidden, errorCodeForbidden) + platformAdd := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-admin"}, ownerLogin.SessionID) + assertErrorResponse(t, platformAdd, http.StatusForbidden, errorCodeForbidden) + + removed := requestJSONWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-owned/administrators/user-helper", nil, ownerLogin.SessionID) + assertStatus(t, removed, http.StatusOK) + removedBody := decodeBody[dto.ServerInstanceResponse](t, removed) + if len(removedBody.AdminUserIDs) != 0 { + t.Fatalf("expected helper membership removed, got %+v", removedBody) + } + forbiddenDetail := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-owned", "", helperLogin.SessionID) + assertErrorResponse(t, forbiddenDetail, http.StatusForbidden, errorCodeForbidden) +} + +func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) { + router := newTestRouter() + recorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest()) + assertStatus(t, recorder, http.StatusCreated) + + var body map[string]any + if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { + t.Fatalf("decode provider response: %v", err) + } + if _, exists := body["apiKey"]; exists { + t.Fatalf("AI provider response must not expose apiKey: %+v", body) + } + if _, exists := body["rawApiKey"]; exists { + t.Fatalf("AI provider response must not expose rawApiKey: %+v", body) + } + if body["apiKeyRef"] != "secret://providers/openai" { + t.Fatalf("expected apiKeyRef only, got %+v", body) + } +} + +func TestAIProviderManagementAPI(t *testing.T) { + router := newTestRouter() + postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) + + update := validAIProviderUpdateRequest() + updatedRecorder := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update) + assertStatus(t, updatedRecorder, http.StatusOK) + updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder) + if updated.Name != "OpenAI Relay" || updated.APIKeyRef != "vault://providers/openai" || updated.Status != domain.AIProviderStatusActive { + t.Fatalf("unexpected updated provider: %+v", updated) + } + + statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}) + assertStatus(t, statusRecorder, http.StatusOK) + disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder) + if disabled.Status != domain.AIProviderStatusDisabled { + t.Fatalf("expected disabled provider, got %+v", disabled) + } + + testRecorder := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "") + assertStatus(t, testRecorder, http.StatusOK) + testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder) + if testResult.Success || testResult.Mode != "metadata" { + t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult) + } + + models := getJSON[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models") + if models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 2 { + t.Fatalf("unexpected models response: %+v", models) + } + + statusRecorder = performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive}) + assertStatus(t, statusRecorder, http.StatusOK) + testRecorder = performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "") + assertStatus(t, testRecorder, http.StatusOK) + testResult = decodeBody[dto.AIProviderTestResponse](t, testRecorder) + if !testResult.Success { + t.Fatalf("expected metadata test success, got %+v", testResult) + } +} + +func TestAIProviderManagementAPIErrors(t *testing.T) { + router := newTestRouter() + postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) + + rawUpdate := validAIProviderUpdateRequest() + rawUpdate.APIKeyRef = "sk-raw-secret" + rawFailure := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate) + assertErrorResponse(t, rawFailure, http.StatusBadRequest, errorCodeValidation) + + invalidStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError}) + assertErrorResponse(t, invalidStatus, http.StatusBadRequest, errorCodeValidation) + + missingUpdate := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest()) + assertErrorResponse(t, missingUpdate, http.StatusNotFound, errorCodeNotFound) + missingStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}) + assertErrorResponse(t, missingStatus, http.StatusNotFound, errorCodeNotFound) + missingTest := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "") + assertErrorResponse(t, missingTest, http.StatusNotFound, errorCodeNotFound) + missingModels := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "") + assertErrorResponse(t, missingModels, http.StatusNotFound, errorCodeNotFound) +} + +func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) + registration := validGamePluginManifestRegistrationRequest() + registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"} + registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)} + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-ai-api", + PluginID: "game.example", + RunEndpointID: "run-local", + Name: "AI API Server", + }, adminSession) + + allowedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/invocations", dto.AIInvocationRequest{ + RequestID: "ai-1", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Purpose: "logs.diagnose", + Prompt: "Summarize recent warnings safely", + ContextRefs: map[string]string{"server": "server://server-ai-api"}, + }, adminSession) + assertStatus(t, allowedRecorder, http.StatusOK) + allowed := decodeBody[dto.AIInvocationResponse](t, allowedRecorder) + if allowed.Status != "ok" || !allowed.Usage.Mocked || allowed.Recommendation == "" { + t.Fatalf("expected mocked safe AI response, got %+v", allowed) + } + + denied := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{ + RequestID: "ai-denied", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Purpose: "config.suggest", + Prompt: "Suggest config", + }, adminSession) + if denied.Status != "denied" || denied.Error == nil || denied.Error.Code != "permission_denied" { + t.Fatalf("expected purpose denial, got %+v", denied) + } + + unsafe := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/invocations", dto.AIInvocationRequest{ + RequestID: "ai-unsafe", + Purpose: "logs.diagnose", + Prompt: "use sk-live-raw-secret", + }, adminSession) + assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation) + + configRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-suggestions", dto.LlmConfigSuggestionRequest{ + ServerInstanceID: instance.ID, + Prompt: "Turn off pvp and keep this reviewable", + CurrentConfig: "server.name=AI API Server\n", + }, adminSession) + assertStatus(t, configRecorder, http.StatusOK) + config := decodeBody[dto.LlmConfigSuggestionResponse](t, configRecorder) + if config.SuggestedConfig == "" || !strings.Contains(config.SuggestedConfig, "ai.recommendation=review-required") { + t.Fatalf("expected reviewable config suggestion, got %+v", config) + } + jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-ai-api", adminSession) + if jobs.Count != 0 { + t.Fatalf("AI suggestion must not dispatch config writes, got %+v", jobs) + } + + bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-ai-api", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Action: string(domain.PluginBridgeActionAIInvoke), + AIPurpose: "logs.diagnose", + Payload: map[string]string{"prompt": "Summarize the logs"}, + }, adminSession) + if bridge.Status != "ok" || bridge.Result["recommendation"] == "" || bridge.Result["mocked"] != "true" { + t.Fatalf("expected bridge AI response, got %+v", bridge) + } + + for _, body := range []string{allowedRecorder.Body.String(), configRecorder.Body.String(), mustJSON(t, bridge), mustJSON(t, denied)} { + for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} { + if strings.Contains(body, forbidden) { + t.Fatalf("AI response exposed forbidden fragment %q: %s", forbidden, body) + } + } + } +} + +func TestGamePluginManifestRegistryAPI(t *testing.T) { + router := newTestRouter() + registration := validGamePluginManifestRegistrationRequest() + + created := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) + if created.ID != "game.example" || created.ServerType != "example" || created.ServerDisplayName != "Example Server" { + t.Fatalf("unexpected plugin registry response: %+v", created) + } + if created.ManifestRef != "artifact://manifests/game.example/0.1.0" || created.CreateFormSchemaRef != "schemas/create-form.schema.json" { + t.Fatalf("expected manifest and schema refs, got %+v", created) + } + if len(created.DeclaredPermissions) != 6 || !created.Permissions.AI || !created.Permissions.Artifacts || !created.Permissions.Jobs { + t.Fatalf("expected declared and aggregate permissions, got %+v", created) + } + if len(created.Pages) != 1 || created.Pages[0].Permissions[0] != "server.logs.read" { + t.Fatalf("expected page metadata, got %+v", created.Pages) + } + if len(created.AIPurposes) != 1 || created.AIPurposes[0] != "logs.diagnose" { + t.Fatalf("expected AI purposes, got %+v", created.AIPurposes) + } + if len(created.BridgeActions) != 4 || created.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) { + t.Fatalf("expected bridge actions, got %+v", created.BridgeActions) + } + + listed := getJSON[dto.GamePluginListResponse](t, router, "/api/v1/game-plugins?serverType=example&status=installed") + assertListCount(t, listed.Count, 1) + detail := getJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/game.example") + if detail.ID != created.ID || len(detail.RequiredRunCapabilities) != len(created.RequiredRunCapabilities) { + t.Fatalf("unexpected plugin detail: %+v", detail) + } + + duplicate := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration) + assertErrorResponse(t, duplicate, http.StatusConflict, errorCodeDuplicate) +} + +func TestPluginMarketplaceAPIListsDetailsAndChangesStateSafely(t *testing.T) { + router := newTestRouter() + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest()) + + listed := getJSON[dto.MarketplacePluginListResponse](t, router, "/api/v1/plugin-marketplace/plugins?serverType=example&status=installed&capability=logs.read&keyword=development") + if listed.Count != 1 || listed.Items[0].ID != "game.example" || listed.Items[0].Source != "platform-registry" { + t.Fatalf("unexpected marketplace list: %+v", listed) + } + if len(listed.Items[0].Capabilities) == 0 || listed.Items[0].Capabilities[0] != "process.install" || len(listed.Items[0].Pages) != 1 { + t.Fatalf("expected manifest-backed marketplace projection, got %+v", listed.Items[0]) + } + + detailRecorder := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins/game.example", "") + assertStatus(t, detailRecorder, http.StatusOK) + detail := decodeBody[dto.MarketplacePluginResponse](t, detailRecorder) + if detail.ID != "game.example" || detail.ManifestRef != "artifact://manifests/game.example/0.1.0" || detail.AIPurposes[0] != "logs.diagnose" { + t.Fatalf("unexpected marketplace detail: %+v", detail) + } + for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} { + if strings.Contains(detailRecorder.Body.String(), forbidden) { + t.Fatalf("marketplace detail exposed forbidden fragment %q: %s", forbidden, detailRecorder.Body.String()) + } + } + + disabled := postOKJSON[dto.MarketplacePluginResponse](t, router, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionDisable}) + if disabled.Status != domain.GamePluginStatusDisabled { + t.Fatalf("expected disabled marketplace plugin, got %+v", disabled) + } + installed := postOKJSON[dto.MarketplacePluginResponse](t, router, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionInstall}) + if installed.Status != domain.GamePluginStatusInstalled { + t.Fatalf("expected installed marketplace plugin, got %+v", installed) + } + + empty := getJSON[dto.MarketplacePluginListResponse](t, router, "/api/v1/plugin-marketplace/plugins?keyword=missing") + if empty.Count != 0 { + t.Fatalf("expected empty marketplace keyword result, got %+v", empty) + } + missing := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins/missing", "") + assertErrorResponse(t, missing, http.StatusNotFound, errorCodeNotFound) + unsupported := performJSON(t, router, http.MethodPost, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateAction("download")}) + assertErrorResponse(t, unsupported, http.StatusBadRequest, errorCodeValidation) + unsafeFilter := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins?keyword=sk-raw-secret", "") + assertErrorResponse(t, unsafeFilter, http.StatusBadRequest, errorCodeValidation) +} + +func TestPluginBridgeAuthorizeAPI(t *testing.T) { + router := newTestRouter() + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest()) + + allowed := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: string(domain.PluginBridgeActionLogsQuery), + }) + if !allowed.Allowed || allowed.RequiredPermissions[0] != "server.logs.read" { + t.Fatalf("expected allowed logs bridge action, got %+v", allowed) + } + + denied := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: string(domain.PluginBridgeActionFilesRequest), + }) + if denied.Allowed || denied.Reason == "" { + t.Fatalf("expected denied files bridge action, got %+v", denied) + } + + aiAllowed := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: string(domain.PluginBridgeActionAIInvoke), + AIPurpose: "logs.diagnose", + }) + if !aiAllowed.Allowed { + t.Fatalf("expected allowed AI bridge action, got %+v", aiAllowed) + } + + unsupported := performJSON(t, router, http.MethodPost, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: "direct.run.socket", + }) + assertErrorResponse(t, unsupported, http.StatusBadRequest, errorCodeValidation) +} + +func TestPluginBridgeExecuteAPI(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-bridge-owner", + DisplayName: "Bridge Owner", + Email: "bridge-owner@example.test", + Roles: []string{"server-owner"}, + Password: "secret-password", + }, adminSession) + ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "bridge-owner@example.test", Password: "secret-password"}).SessionID + postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) + + registration := validGamePluginManifestRegistrationRequest() + registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionJobsDispatch)) + registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "ai.invoke"} + registration.Manifest.Pages[0].BridgeActions = []string{ + string(domain.PluginBridgeActionServerInstancesRead), + string(domain.PluginBridgeActionJobsDispatch), + string(domain.PluginBridgeActionLogsQuery), + string(domain.PluginBridgeActionFilesRequest), + string(domain.PluginBridgeActionAIInvoke), + } + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-bridge-api", + PluginID: "game.example", + RunEndpointID: "run-local", + Name: "Bridge API Server", + State: domain.ServerInstanceStateRunning, + }, ownerSession) + lifecycleInstance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-bridge-lifecycle-api", + PluginID: "game.example", + RunEndpointID: "run-local", + Name: "Bridge Lifecycle API Server", + State: domain.ServerInstanceStateReady, + }, ownerSession) + stream := postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ + ID: "log-bridge-api", + ServerInstanceID: instance.ID, + Source: domain.LogStreamSourceProcess, + StreamKey: "stdout", + StorageBackend: domain.LogStorageBackendLocalSegments, + RetentionPolicy: "default", + }) + + serverRead := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-read-1", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Action: string(domain.PluginBridgeActionServerInstancesRead), + }, ownerSession) + if serverRead.Status != "ok" || serverRead.Result["serverInstanceId"] != instance.ID || serverRead.Result["state"] != string(domain.ServerInstanceStateRunning) { + t.Fatalf("expected safe server context response, got %+v", serverRead) + } + + logs := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-logs-1", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Action: string(domain.PluginBridgeActionLogsQuery), + Payload: map[string]string{"logStreamId": stream.ID, "limit": "10"}, + }, ownerSession) + if logs.Status != "ok" || logs.Result["logStreamId"] != stream.ID || logs.Result["entryCount"] != "0" { + t.Fatalf("expected safe log query response, got %+v", logs) + } + + lifecycle := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-lifecycle-start-1", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: lifecycleInstance.ID, + Action: string(domain.PluginBridgeActionJobsDispatch), + Payload: map[string]string{ + "lifecycleAction": "start", + "capability": domain.LifecycleCapabilityStart, + "expectedConfigVersion": strconv.Itoa(lifecycleInstance.ConfigVersion), + "idempotencyKey": "idem-bridge-lifecycle-start", + }, + }, ownerSession) + if lifecycle.Status != "queued" || lifecycle.Result["capability"] != domain.LifecycleCapabilityStart || lifecycle.Result["lifecycleAction"] != string(domain.ServerLifecycleActionStart) || lifecycle.Result["serverInstanceId"] != lifecycleInstance.ID { + t.Fatalf("expected platform-mediated lifecycle dispatch response, got %+v", lifecycle) + } + + lifecycleMismatch := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-lifecycle-mismatch-1", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: lifecycleInstance.ID, + Action: string(domain.PluginBridgeActionJobsDispatch), + Payload: map[string]string{ + "lifecycleAction": "start", + "capability": domain.LifecycleCapabilityStop, + "expectedConfigVersion": strconv.Itoa(lifecycleInstance.ConfigVersion), + "idempotencyKey": "idem-bridge-lifecycle-mismatch", + }, + }, ownerSession) + if lifecycleMismatch.Status != "denied" || lifecycleMismatch.Error == nil || lifecycleMismatch.Error.Code != "capability_denied" { + t.Fatalf("expected mismatched lifecycle capability denial, got %+v", lifecycleMismatch) + } + + fileDispatch := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-file-1", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Action: string(domain.PluginBridgeActionFilesRequest), + Payload: map[string]string{"operation": "read", "key": "logs/latest.log", "idempotencyKey": "idem-bridge-file"}, + }, ownerSession) + if fileDispatch.Status != "queued" || fileDispatch.Result["capability"] != domain.JobCapabilityFilesRead || fileDispatch.Result["targetKey"] != "logs/latest.log" { + t.Fatalf("expected safe file dispatch reference, got %+v", fileDispatch) + } + + aiResponse := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-ai-1", + PluginID: "game.example", + RouteKey: "logs", + Action: string(domain.PluginBridgeActionAIInvoke), + AIPurpose: "logs.diagnose", + }, ownerSession) + if aiResponse.Status != "ok" || aiResponse.Result["recommendation"] == "" || aiResponse.Result["mocked"] != "true" { + t.Fatalf("expected mediated AI safe response, got %+v", aiResponse) + } + + denied := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-denied-1", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Action: string(domain.PluginBridgeActionArtifactsOpen), + }, ownerSession) + if denied.Status != "denied" || denied.Error == nil || denied.Error.Code != "permission_denied" { + t.Fatalf("expected permission denied safe envelope, got %+v", denied) + } + + unsafe := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ + RequestID: "bridge-unsafe-1", + PluginID: "game.example", + RouteKey: "logs", + ServerInstanceID: instance.ID, + Action: string(domain.PluginBridgeActionFilesRequest), + Payload: map[string]string{"key": "/Users/tasia/.ssh/id_rsa", "idempotencyKey": "idem-unsafe"}, + }, ownerSession) + assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation) + + for _, body := range []string{mustJSON(t, serverRead), mustJSON(t, logs), mustJSON(t, lifecycle), mustJSON(t, lifecycleMismatch), mustJSON(t, fileDispatch), mustJSON(t, aiResponse), mustJSON(t, denied)} { + for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} { + if strings.Contains(body, forbidden) { + t.Fatalf("bridge response exposed forbidden fragment %q: %s", forbidden, body) + } + } + } +} + +func TestGamePluginManifestRegistryAPIRejectsUnsafeManifest(t *testing.T) { + router := newTestRouter() + registration := validGamePluginManifestRegistrationRequest() + registration.Manifest.Description = "requires direct run socket and raw AI key" + + response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration) + assertStatus(t, response, http.StatusBadRequest) + errorBody := decodeBody[dto.ErrorResponse](t, response) + if errorBody.Code != errorCodeValidation { + t.Fatalf("expected validation error, got %+v", errorBody) + } + joinedDetails := strings.Join(errorBody.Details, ",") + if !strings.Contains(joinedDetails, "direct run access") || !strings.Contains(joinedDetails, "raw credential") { + t.Fatalf("expected unsafe manifest details, got %+v", errorBody) + } +} + +func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) { + router := newTestRouter() + recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest()) + assertStatus(t, recorder, http.StatusCreated) + + var body map[string]any + if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { + t.Fatalf("decode plugin response: %v", err) + } + for _, forbidden := range []string{"apiKey", "rawApiKey", "hostPath", "runSocket", "runCredential"} { + if _, exists := body[forbidden]; exists { + t.Fatalf("game plugin response must not expose %s: %+v", forbidden, body) + } + } +} + +func newTestRouter() http.Handler { + core := service.NewCoreService(repo.NewMemoryStore()) + if err := core.SeedLocalPlatformAdmin(); err != nil { + panic(err) + } + return NewRouterWithCore(core) +} + +func apiRouterWithoutSeededAdmin() http.Handler { + return NewRouterWithCore(service.NewCoreService(repo.NewMemoryStore())) +} + +func postJSON[T any](t *testing.T, router http.Handler, path string, body any) T { + t.Helper() + recorder := performJSON(t, router, http.MethodPost, path, body) + assertStatus(t, recorder, http.StatusCreated) + return decodeBody[T](t, recorder) +} + +func postOKJSON[T any](t *testing.T, router http.Handler, path string, body any) T { + t.Helper() + recorder := performJSON(t, router, http.MethodPost, path, body) + assertStatus(t, recorder, http.StatusOK) + return decodeBody[T](t, recorder) +} + +func postOKJSONWithAuth[T any](t *testing.T, router http.Handler, path string, body any, sessionID string) T { + t.Helper() + recorder := requestJSONWithAuth(t, router, http.MethodPost, path, body, sessionID) + assertStatus(t, recorder, http.StatusOK) + return decodeBody[T](t, recorder) +} + +func postJSONWithAuth[T any](t *testing.T, router http.Handler, path string, body any, sessionID string) T { + t.Helper() + recorder := requestJSONWithAuth(t, router, http.MethodPost, path, body, sessionID) + assertStatus(t, recorder, http.StatusCreated) + return decodeBody[T](t, recorder) +} + +func putJSON[T any](t *testing.T, router http.Handler, path string, body any) T { + t.Helper() + recorder := performJSON(t, router, http.MethodPut, path, body) + assertStatus(t, recorder, http.StatusOK) + return decodeBody[T](t, recorder) +} + +func putJSONWithAuth[T any](t *testing.T, router http.Handler, path string, body any, sessionID string) T { + t.Helper() + recorder := requestJSONWithAuth(t, router, http.MethodPut, path, body, sessionID) + assertStatus(t, recorder, http.StatusOK) + return decodeBody[T](t, recorder) +} + +func getJSON[T any](t *testing.T, router http.Handler, path string) T { + t.Helper() + recorder := performRaw(t, router, http.MethodGet, path, "") + assertStatus(t, recorder, http.StatusOK) + return decodeBody[T](t, recorder) +} + +func getJSONWithAuth[T any](t *testing.T, router http.Handler, path string, sessionID string) T { + t.Helper() + recorder := requestWithAuth(t, router, http.MethodGet, path, "", sessionID) + assertStatus(t, recorder, http.StatusOK) + return decodeBody[T](t, recorder) +} + +func performJSON(t *testing.T, router http.Handler, method string, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode request body: %v", err) + } + return performRequest(t, router, method, path, &buf) +} + +func requestJSONWithAuth(t *testing.T, router http.Handler, method string, path string, body any, sessionID string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode request body: %v", err) + } + return requestWithReaderAndAuth(t, router, method, path, &buf, sessionID) +} + +func performRaw(t *testing.T, router http.Handler, method string, path string, body string) *httptest.ResponseRecorder { + t.Helper() + if body == "" { + return performRequest(t, router, method, path, nil) + } + return performRequest(t, router, method, path, bytes.NewBufferString(body)) +} + +func performRequest(t *testing.T, router http.Handler, method string, path string, body io.Reader) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func requestWithAuth(t *testing.T, router http.Handler, method string, path string, body string, sessionID string) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != "" { + reader = bytes.NewBufferString(body) + } + return requestWithReaderAndAuth(t, router, method, path, reader, sessionID) +} + +func requestWithReaderAndAuth(t *testing.T, router http.Handler, method string, path string, body io.Reader, sessionID string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sessionID) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func createAdminSession(t *testing.T, router http.Handler) string { + t.Helper() + session := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{ + Account: "operator.local@example.test", + Password: "operator-local", + }) + if session.SessionID == "" { + t.Fatalf("expected admin session token") + } + return session.SessionID +} + +func decodeBody[T any](t *testing.T, recorder *httptest.ResponseRecorder) T { + t.Helper() + var body T + if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { + t.Fatalf("decode response body: %v", err) + } + return body +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal response body: %v", err) + } + return string(encoded) +} + +func assertStatus(t *testing.T, recorder *httptest.ResponseRecorder, want int) { + t.Helper() + if recorder.Code != want { + t.Fatalf("expected status %d, got %d body=%s", want, recorder.Code, recorder.Body.String()) + } +} + +func assertErrorResponse(t *testing.T, recorder *httptest.ResponseRecorder, status int, code string) { + t.Helper() + assertStatus(t, recorder, status) + response := decodeBody[dto.ErrorResponse](t, recorder) + if response.Code != code || response.Message == "" { + t.Fatalf("expected error code %q with message, got %+v", code, response) + } +} + +func assertListCount(t *testing.T, got int, want int) { + t.Helper() + if got != want { + t.Fatalf("expected list count %d, got %d", want, got) + } +} + +func anyJSON(t *testing.T, value any) map[string]any { + t.Helper() + payload, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal json: %v", err) + } + var body map[string]any + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("unmarshal json: %v", err) + } + return body +} + +func validAIProviderRequest() dto.AIProviderCreateRequest { + return dto.AIProviderCreateRequest{ + ID: "ai.openai", + Name: "OpenAI", + Kind: domain.AIProviderKindOpenAI, + BaseURL: "https://api.openai.com/v1", + APIKeyRef: "secret://providers/openai", + Models: []string{"gpt-4.1", "gpt-4.1-mini"}, + DefaultModel: "gpt-4.1", + RelayMode: domain.AIRelayModeDirect, + TimeoutMS: 30000, + RedactionPolicy: "default", + } +} + +func validAIProviderUpdateRequest() dto.AIProviderUpdateRequest { + return dto.AIProviderUpdateRequest{ + Name: "OpenAI Relay", + Kind: domain.AIProviderKindOpenAI, + BaseURL: "https://relay.example.test/v1", + APIKeyRef: "vault://providers/openai", + Models: []string{"gpt-4.1", "gpt-4.1-mini"}, + DefaultModel: "gpt-4.1-mini", + RelayMode: domain.AIRelayModeRelay, + TimeoutMS: 45000, + RedactionPolicy: "default", + } +} + +func validGamePluginRequest() dto.GamePluginCreateRequest { + return dto.GamePluginCreateRequest{ + ID: "server.scum", + Name: "SCUM", + Version: "1.0.0", + ServerType: "scum", + ManifestRef: "artifact://manifests/server.scum/1.0.0", + CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", + RequiredRunCapabilities: []string{"process.install", "process.start", "process.stop", "logs.read"}, + Permissions: dto.PluginPermissionsResponse{ + Logs: true, + Jobs: true, + }, + LifecycleActions: dto.PluginLifecycleActionsBody{ + Install: "actions/install.json", + Start: "actions/start.json", + Stop: "actions/stop.json", + }, + } +} + +func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistrationRequest { + return dto.GamePluginManifestRegistrationRequest{ + ManifestRef: "artifact://manifests/game.example/0.1.0", + Manifest: dto.GamePluginManifestBody{ + ID: "game.example", + Name: "Example Server", + Description: "Development plugin", + Version: "0.1.0", + Kind: "game-plugin", + Tags: []string{"example", "development"}, + Server: dto.GamePluginManifestServerBody{ + Type: "example", + DisplayName: "Example Server", + SupportedOS: []string{"linux", "darwin"}, + CreateFormSchema: "schemas/create-form.schema.json", + }, + Bridge: dto.GamePluginBridgeBody{ + Actions: []string{ + string(domain.PluginBridgeActionServerInstancesRead), + string(domain.PluginBridgeActionLogsQuery), + string(domain.PluginBridgeActionFilesRequest), + string(domain.PluginBridgeActionAIInvoke), + }, + }, + Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}, + Permissions: []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"}, + Actions: dto.PluginLifecycleActionsBody{ + Install: "actions/install.json", + Start: "actions/start.json", + Stop: "actions/stop.json", + Restart: "actions/restart.json", + }, + Pages: []dto.GamePluginPageBody{ + { + Key: "logs", + Title: "Logs", + Path: "/logs", + Permissions: []string{"server.logs.read", "ai.invoke"}, + BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)}, + }, + }, + AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}}, + }, + } +} + +func validRunEndpointRequest() dto.RunEndpointCreateRequest { + return dto.RunEndpointCreateRequest{ + ID: "run-local", + DisplayName: "Local Run", + Version: "0.1.0", + Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write", "artifacts.read", "ai.invoke"}, + Capacity: dto.RunCapacityResponse{ + MaxJobs: 4, + }, + } +} diff --git a/platform/api/router.go b/platform/api/router.go new file mode 100644 index 0000000..5611dcf --- /dev/null +++ b/platform/api/router.go @@ -0,0 +1,74 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "browser.local/platform/config" + "browser.local/platform/repo" + "browser.local/platform/service" +) + +func NewRouter() http.Handler { + router, err := NewRouterFromConfig(config.Load()) + if err != nil { + panic(err) + } + return router +} + +func NewRouterFromConfig(cfg config.Config) (http.Handler, error) { + store, err := storeFromConfig(cfg) + if err != nil { + return nil, err + } + logStore, err := logStoreFromConfig(cfg) + if err != nil { + return nil, err + } + core := service.NewCoreServiceWithLogStore(store, logStore) + if err := core.SeedLocalPlatformAdmin(); err != nil { + return nil, err + } + return NewRouterWithCore(core), nil +} + +func NewRouterWithCore(core service.Core) http.Handler { + handlers := newCoreHandlers(core) + mux := http.NewServeMux() + mux.HandleFunc("/healthz", HealthHandler) + handlers.register(mux) + return mux +} + +func storeFromConfig(cfg config.Config) (repo.Store, error) { + switch strings.ToLower(strings.TrimSpace(cfg.StorageBackend)) { + case "", "file": + return repo.NewFileStore(cfg.MetadataPath) + case "memory": + return repo.NewMemoryStore(), nil + case "mysql": + return repo.NewMySQLStore(cfg.MySQLDSN) + default: + return nil, fmt.Errorf("unsupported platform storage backend %q", cfg.StorageBackend) + } +} + +func logStoreFromConfig(cfg config.Config) (service.LogBodyStore, error) { + backend := strings.ToLower(strings.TrimSpace(cfg.LogBodyBackend)) + if backend == "" { + backend = strings.ToLower(strings.TrimSpace(cfg.StorageBackend)) + if backend == "mysql" { + backend = "file" + } + } + switch backend { + case "", "file": + return service.NewFileLogBodyStore(cfg.LogDir) + case "memory": + return service.NewMemoryLogBodyStore(), nil + default: + return nil, fmt.Errorf("unsupported platform log body backend %q", backend) + } +} diff --git a/platform/api/routes.md b/platform/api/routes.md new file mode 100644 index 0000000..8d60ed8 --- /dev/null +++ b/platform/api/routes.md @@ -0,0 +1,201 @@ +# Platform API Route Catalog + +Route declarations and handler comments live in `platform/api`. Request, response, list, and error contracts live in `platform/dto`; handlers must call `platform/service.Core` rather than repositories directly. + +## Implemented Core Resource Routes + +All routes use JSON request and response bodies. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`. + +| Resource | Collection | Detail | DTO contracts | +| --- | --- | --- | --- | +| Users | `GET /api/v1/users`, `POST /api/v1/users` | `GET /api/v1/users/{id}`, `PUT /api/v1/users/{id}` | `UserCreateRequest`, `UserUpdateRequest`, `UserResponse`, `UserListResponse` | +| AI providers | `GET /api/v1/ai-providers`, `POST /api/v1/ai-providers`, `POST /api/v1/ai/invocations`, `POST /api/v1/ai/config-suggestions` | `GET /api/v1/ai-providers/{id}`, `PUT /api/v1/ai-providers/{id}` | `AIProviderCreateRequest`, `AIProviderUpdateRequest`, redacted `AIProviderResponse`, `AIProviderListResponse`, `AIInvocationRequest`, `AIInvocationResponse`, `LlmConfigSuggestionRequest`, `LlmConfigSuggestionResponse` | +| Game plugins | `GET /api/v1/game-plugins`, `POST /api/v1/game-plugins` | `GET /api/v1/game-plugins/{id}` | `GamePluginCreateRequest`, `GamePluginResponse`, `GamePluginListResponse` | +| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` | +| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` | +| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` | +| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` | +| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` | +| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` | +| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` | +| Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` | +| Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` | +| Artifacts | `GET /api/v1/artifacts`, `POST /api/v1/artifacts` | `GET /api/v1/artifacts/{id}`, `POST /api/v1/artifacts/{id}/download`, `GET /api/v1/artifacts/{id}/content` | `ArtifactCreateRequest`, `ArtifactResponse`, `ArtifactListResponse`, `ArtifactDownloadReferenceResponse`, `ArtifactContentRequest` | +| Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` | +| Audit events | `GET /api/v1/audit-events`, `POST /api/v1/audit-events` | `GET /api/v1/audit-events/{id}` | `AuditEventCreateRequest`, `AuditEventResponse`, `AuditEventListResponse` | + +## Implemented Query Filters + +- `GET /api/v1/users?status=active` +- `GET /api/v1/ai-providers?kind=openai&status=active` +- `GET /api/v1/game-plugins?serverType=scum&status=installed` +- `GET /api/v1/plugin-marketplace/plugins?serverType=scum&status=installed&capability=logs.read&keyword=scum` +- `GET /api/v1/server-instances?pluginId=server.scum&runEndpointId=run-local&state=draft` +- `GET /api/v1/metrics/server-instances` +- `GET /api/v1/run/endpoints?status=online` +- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued` +- `GET /api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading` +- `GET /api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout` +- `GET /api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success` + +## Implemented Authentication And Current User Actions + +- `POST /api/v1/auth/register`: accept `RegisterRequest`; the first registered account becomes an active platform administrator with an authenticated session, while later registrations create pending low-privilege users and return `AuthSessionResponse` with `status=pending` and no session token. +- `POST /api/v1/auth/login`: accept `LoginRequest`, authenticate an active user by ID or email, and return `AuthSessionResponse` with a bearer session token. +- `POST /api/v1/auth/logout`: invalidate the active bearer session token and return `204`. +- `GET /api/v1/users/current`: return `CurrentUserResponse` for the bearer session. +- `PUT /api/v1/users/current/profile`: update bounded current-user profile fields using `UserProfileBody`. +- `PUT /api/v1/users/current/theme`: persist current-user console theme preferences using `UserThemePreferenceRequest`. + +Auth responses never expose password hashes or raw credentials. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. Tests and local fixtures may seed one explicit platform administrator account for manual login: `operator.local@example.test` / `operator-local`. + +## Implemented Role-Scoped Server Access + +- User-facing server instance list, detail, create, and lifecycle routes require a bearer session. +- Platform administrators can view and manage all server instances. +- Server owners and server administrators can only view and manage server instances they own or administer. +- Server instance responses include bounded `ownerUserId` and `adminUserIds` membership metadata. +- `GET /api/v1/server-instances/{id}/administrators/candidates`: lets the server owner list active non-platform-admin users that can be invited. +- `POST /api/v1/server-instances/{id}/administrators`: lets the server owner invite an active non-platform-admin user using `ServerMemberRequest`. +- `DELETE /api/v1/server-instances/{id}/administrators/{userId}`: lets the server owner remove a server-scoped administrator. The route never deletes the user account. + +Server owner membership actions hide and reject platform administrators. Server administrators cannot invite or remove administrators unless they also own the target server. + +## Implemented Observability And Config Read Actions + +- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators. +- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user. +- `GET /api/v1/server-instances/{id}/config`: returns logical server config content, format, key, config version, and update timestamp for an authorized server instance. + +Observability and config read responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens. + +## Implemented Config Write And File Dispatch Actions + +- `POST /api/v1/server-instances/{id}/config/diff`: accepts `ServerConfigDiffPreviewRequest`, validates server access, expected config version, logical config key, bounded proposed content, and returns a platform-computed `ServerConfigDiffPreviewResponse` without creating a run job. +- `POST /api/v1/server-instances/{id}/config/approve`: accepts `ServerConfigWriteApprovalRequest`, revalidates the reviewed diff, rejects stale/no-change/unsafe writes, and queues a scoped `config.write` job using `ServerConfigWriteDispatchResponse`. +- `POST /api/v1/file-operations/dispatch`: accepts `FileOperationDispatchRequest`, validates server visibility plus optional plugin permissions, rejects unsafe targets, and queues `files.read` or `files.write` jobs using logical keys and refs. + +Config write and file dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents. + +## Implemented AI Provider Management Actions + +- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`. +- `POST /api/v1/ai-providers/{id}/test`: run local metadata validation using `AIProviderTestResponse`; this does not call external AI services. +- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`. +- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors. +- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow. + +AI invocation is platform-mediated. Tests and local verification use a deterministic mock provider client; live external provider calls are deferred behind the same interface and are not required for this change. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions are recommendations only and never dispatch run-side writes directly. + +## Implemented Game Plugin Registry Actions + +- `POST /api/v1/game-plugins/register-manifest`: accept `GamePluginManifestRegistrationRequest`, validate a game management plugin manifest, and persist installed registry metadata using `GamePluginResponse`. + +Plugin registry responses include identity, description, version, server type/display metadata, manifest and create-form schema references, required run capabilities, declared scoped permissions, aggregate platform permissions, lifecycle action references, plugin pages, tags, AI purposes, validation violations for invalid records, and install status. They do not expose raw host paths, raw credentials, direct run sockets, or raw AI provider keys. + +## Implemented Plugin Marketplace Actions + +- `GET /api/v1/plugin-marketplace/plugins`: list bounded marketplace plugin summaries projected from installed registry metadata. Optional filters include `status`, `serverType`, `capability`, and `keyword`. +- `GET /api/v1/plugin-marketplace/plugins/{id}`: return one marketplace plugin detail using manifest-backed registry metadata. +- `POST /api/v1/plugin-marketplace/plugins/{id}/state`: accept `MarketplacePluginStateRequest` with `install`, `enable`, or `disable` and update registry install state only. + +Marketplace responses include game management plugin identity, version, display metadata, server type, installed state, capabilities, pages, permissions, tags, AI purposes, and validation violations. They are a platform registry projection, not a commerce catalog, and they do not include billing, pricing, ratings, reviews, cloud host sales, raw credentials, host paths, direct run sockets, package bytes, or raw AI provider keys. + +Marketplace state actions are metadata-only in this change. `install` and `enable` mark the registered plugin `installed`; `disable` marks it `disabled`. These actions do not download external packages, create run jobs, execute plugin code, write files, or contact external services. + +## Implemented Plugin Bridge Actions + +- `POST /api/v1/plugin-bridge/authorize`: accepts `PluginBridgeAuthorizeRequest` and returns whether an installed plugin page may use one declared bridge action with the effective route permissions. +- `POST /api/v1/plugin-bridge/execute`: accepts `PluginBridgeExecuteRequest`, repeats backend validation and authorization, and executes only mapped platform-mediated actions. Supported execution currently includes server context reads, lifecycle job dispatch for declared run capabilities, log cursor metadata queries, scoped file dispatch, artifact open references, and platform-mediated AI invocation. + +Bridge execution responses are typed envelopes with `requestId`, plugin/page/server scope, action, status, result refs, and safe error codes. They do not expose bearer tokens, run sockets, host filesystem paths, raw credentials, storage backend credentials, provider base URLs, raw AI provider keys, or unbounded file/log contents. + +Artifact bridge execution returns safe metadata and platform content routes only. It does not return artifact bytes through the bridge message and does not expose run endpoints, storage adapter paths, presigned backend URLs, host paths, or credentials. + +## Implemented Server Lifecycle Actions + +- `POST /api/v1/server-instances/workflows/create`: accept `ServerLifecycleCreateRequest`, validate plugin/run dependencies and idempotency, create an `installing` server instance, and queue a `process.install` job using `ServerLifecycleResponse`. +- `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`. +- `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`. + +Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies. + +## Implemented Run Control Actions + +- `POST /api/v1/run/control/hello`: accept `RunControlHelloRequest`, create or update run endpoint metadata, and return `RunControlHelloResponse` with a platform-issued session token. +- `POST /api/v1/run/control/heartbeat`: accept `RunControlHeartbeatRequest`, require the active session token, update heartbeat metadata, and return `RunControlHeartbeatResponse` with the next heartbeat hint and optional capability refresh request. + +Run control actions carry only lightweight metadata: endpoint ID, display name, version, status, capability fingerprint/list, capacity, session token, and timing hints. They do not carry job bodies, logs, artifact chunks, host paths, raw credentials, or direct sockets. +Control is the highest-priority run-facing channel; artifact/file transfer pressure must not delay heartbeat processing or mutate endpoint capacity through heavy payload fields. + +## Implemented Run Job Actions + +- `POST /api/v1/run/jobs/claim`: accept `RunJobClaimRequest`, validate the active run session, lease one queued job for that endpoint, and return `RunJobClaimResponse`. +- `POST /api/v1/run/jobs/ack`: accept `RunJobAckRequest` and move an active leased job into running state. +- `POST /api/v1/run/jobs/progress`: accept `RunJobProgressRequest` and update bounded progress metadata. +- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal job result. +- `POST /api/v1/run/jobs/cancel`: accept `RunJobCancelPollRequest` and return pending cancellation metadata for active leases. +- `POST /api/v1/run/jobs/reconcile`: accept `RunJobReconcileRequest` and return platform-known active jobs plus unknown run-reported job IDs. +- `POST /api/v1/jobs/{id}/cancel`: accept `RunJobCancelRequestBody` and record a platform cancellation request for run polling. + +Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt, progress, terminal state, message, error code, result reference, and timing hints. They do not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies. +Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure. + +## Implemented Log Ingest Actions + +- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range. +- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor. + +Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data. +Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup. + +Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and audit trails. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments. + +## Implemented Run Artifact Actions + +- `POST /api/v1/run/artifacts/open`: accept `ArtifactTransferOpenRequest`, validate active run session and scoped artifact owner, create or reuse uploading artifact metadata, and return `ArtifactTransferOpenResponse` with transfer resume state. +- `POST /api/v1/run/artifacts/chunks`: accept `ArtifactChunkUploadRequest`, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes. +- `POST /api/v1/run/artifacts/status`: accept `ArtifactTransferStatusRequest` and return `ArtifactTransferStatusResponse` with received chunks and next missing chunk index. +- `POST /api/v1/run/artifacts/complete`: accept `ArtifactTransferCompleteRequest`, verify all chunks and final checksum, mark the artifact available, and return `ArtifactTransferCompleteResponse`. + +Run artifact actions carry bounded upload metadata and chunk payloads only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and JSON chunk payload bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials. +Artifact/file transfer is lower priority than control, job lifecycle metadata, and durable log ingest. Slow or retrying chunks must not block heartbeat, job ack/result delivery, cancellation/reconcile calls, or log batch acknowledgement; lightweight routes reject heavy transfer payloads rather than storing them. + +## Implemented Browser Artifact Download Actions + +- `GET /api/v1/artifacts/{id}`: returns authorized artifact metadata for the current bearer session. +- `POST /api/v1/artifacts/{id}/download`: returns `ArtifactDownloadReferenceResponse` with filename, content type, size, checksum, expiry, supported chunk size, and a platform-owned `downloadUrl`. +- `GET /api/v1/artifacts/{id}/content`: returns a bounded byte range using `offset`/`limit` query parameters or a `Range: bytes=start-end` header. Responses include `Content-Length`, `Accept-Ranges`, optional `Content-Range`, `X-Artifact-Checksum`, `X-Artifact-Content-Checksum`, and `X-Artifact-Storage` headers. + +Browser artifact downloads require an available artifact plus user access to the owning job/server context. Platform/plugin-owned artifacts are limited to platform administrators until a future storage policy adds narrower ownership. Current content reads reconstruct completed upload chunks from the in-memory platform transfer session; durable external storage adapters are deferred behind the same service contract. Browser and plugin pages receive only platform routes and integrity metadata, never raw storage backend URLs, host paths, direct run sockets, run tokens, bearer tokens, or storage credentials. + +## Error Contract + +API errors use `dto.ErrorResponse`: + +- `400`: malformed JSON or validation failure. +- `401`: missing or invalid bearer session token. +- `403`: valid credentials for an account that is pending, disabled, or otherwise forbidden. +- `404`: missing resource or missing dependency reported by the service layer. +- `409`: duplicate resource ID. +- `405`: unsupported method on an implemented route. +- `500`: unexpected platform error. + +## Deferred Route Groups + +These route groups remain documented future work beyond the currently implemented routes: + +- Authorization policy routes beyond role-scoped navigation and bearer session identity. +- Run control transport beyond hello and heartbeat, including heartbeat reconciliation policies. +- External metrics collectors, browser tail transport, external log body backends, and AI log analysis windows. +- Browser artifact upload, external artifact storage backends, presigned URLs, and production throttling policies. +- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts. +- Live AI provider connectivity tests and remote model discovery. +- Server restart/update/delete routes. + +## Core Service Boundary + +- `platform/service.Core` owns create/list/get workflows and cross-resource invariants. +- `platform/repo.Store` owns repository access and currently has a durable file-backed implementation for local startup plus an in-memory implementation for tests. +- `platform/validator` owns local resource validation and dependency compatibility checks. +- Handlers must never expose run credentials, host paths, or raw AI provider keys. diff --git a/platform/api/server_lifecycle_handlers.go b/platform/api/server_lifecycle_handlers.go new file mode 100644 index 0000000..d09f551 --- /dev/null +++ b/platform/api/server_lifecycle_handlers.go @@ -0,0 +1,100 @@ +package api + +import ( + "net/http" + + "browser.local/platform/dto" +) + +// serverInstanceCreateWorkflow godoc +// @Summary Create server instance workflow +// @Description Creates a server instance through the platform-mediated lifecycle workflow and queues an install job for the selected run endpoint. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param body body dto.ServerLifecycleCreateRequest true "Server lifecycle create request" +// @Success 200 {object} dto.ServerLifecycleResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 409 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/workflows/create [post] +func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerLifecycleCreateRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.CreateServerInstanceWorkflowForSession(bearerToken(r), request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result)) +} + +// serverInstanceStart godoc +// @Summary Start server instance +// @Description Validates lifecycle state and config version, then queues a start job through the platform job channel. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param id path string true "Server instance ID" +// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request" +// @Success 200 {object} dto.ServerLifecycleResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/start [post] +func (h *coreHandlers) serverInstanceStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.StartServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result)) +} + +// serverInstanceStop godoc +// @Summary Stop server instance +// @Description Validates lifecycle state and config version, then queues a stop job through the platform job channel. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param id path string true "Server instance ID" +// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request" +// @Success 200 {object} dto.ServerLifecycleResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/stop [post] +func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.StopServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result)) +} diff --git a/platform/cmd/platform/main.go b/platform/cmd/platform/main.go new file mode 100755 index 0000000..7e3fa1b --- /dev/null +++ b/platform/cmd/platform/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "errors" + "log" + "net/http" + "time" + + "browser.local/platform/api" + "browser.local/platform/config" +) + +const defaultReadHeaderTimeout = 5 * time.Second + +func main() { + cfg := config.Load() + router, err := api.NewRouterFromConfig(cfg) + if err != nil { + log.Fatalf("platform storage initialization failed: %v", err) + } + server := &http.Server{ + Addr: cfg.Addr, + Handler: router, + ReadHeaderTimeout: defaultReadHeaderTimeout, + } + + log.Printf("platform listening on %s", cfg.Addr) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("platform server failed: %v", err) + } +} diff --git a/platform/config/config.go b/platform/config/config.go new file mode 100644 index 0000000..8521128 --- /dev/null +++ b/platform/config/config.go @@ -0,0 +1,130 @@ +package config + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +const defaultAddr = ":8080" +const defaultDataDir = ".platform-data" +const defaultStorageBackend = "file" + +type Config struct { + Addr string + StorageBackend string + MySQLDSN string + DataDir string + MetadataPath string + LogDir string + LogBodyBackend string +} + +func Load() Config { + loadLocalEnvFiles() + + addr := os.Getenv("PLATFORM_ADDR") + if addr == "" { + addr = defaultAddr + } + dataDir := strings.TrimSpace(os.Getenv("PLATFORM_DATA_DIR")) + if dataDir == "" { + dataDir = defaultDataDir + } + metadataPath := strings.TrimSpace(os.Getenv("PLATFORM_METADATA_PATH")) + if metadataPath == "" { + metadataPath = filepath.Join(dataDir, "metadata.json") + } + logDir := strings.TrimSpace(os.Getenv("PLATFORM_LOG_DIR")) + if logDir == "" { + logDir = filepath.Join(dataDir, "logs") + } + storageBackend := strings.TrimSpace(os.Getenv("PLATFORM_STORAGE_BACKEND")) + if storageBackend == "" { + storageBackend = defaultStorageBackend + } + logBodyBackend := strings.TrimSpace(os.Getenv("PLATFORM_LOG_BODY_BACKEND")) + + return Config{ + Addr: addr, + StorageBackend: storageBackend, + MySQLDSN: strings.TrimSpace(os.Getenv("PLATFORM_MYSQL_DSN")), + DataDir: dataDir, + MetadataPath: metadataPath, + LogDir: logDir, + LogBodyBackend: logBodyBackend, + } +} + +func loadLocalEnvFiles() { + candidates := []string{".env", filepath.Join("platform", ".env")} + for _, path := range candidates { + loadEnvFile(path) + } +} + +func loadEnvFile(path string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + key, value, ok := parseEnvLine(scanner.Text()) + if !ok { + continue + } + if _, exists := os.LookupEnv(key); exists { + continue + } + _ = os.Setenv(key, value) + } +} + +func parseEnvLine(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "", "", false + } + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + key, value, found := strings.Cut(line, "=") + if !found { + return "", "", false + } + key = strings.TrimSpace(key) + if key == "" || strings.ContainsAny(key, " \t") { + return "", "", false + } + value = strings.TrimSpace(stripInlineComment(strings.TrimSpace(value))) + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + return key, value, true +} + +func stripInlineComment(value string) string { + inSingleQuote := false + inDoubleQuote := false + for index, char := range value { + switch char { + case '\'': + if !inDoubleQuote { + inSingleQuote = !inSingleQuote + } + case '"': + if !inSingleQuote { + inDoubleQuote = !inDoubleQuote + } + case '#': + if !inSingleQuote && !inDoubleQuote && index > 0 && value[index-1] == ' ' { + return strings.TrimSpace(value[:index]) + } + } + } + return value +} diff --git a/platform/config/config_test.go b/platform/config/config_test.go new file mode 100644 index 0000000..91c56a5 --- /dev/null +++ b/platform/config/config_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadUsesDefaultAddress(t *testing.T) { + t.Setenv("PLATFORM_ADDR", "") + t.Setenv("PLATFORM_STORAGE_BACKEND", "") + t.Setenv("PLATFORM_MYSQL_DSN", "") + t.Setenv("PLATFORM_DATA_DIR", "") + t.Setenv("PLATFORM_METADATA_PATH", "") + t.Setenv("PLATFORM_LOG_DIR", "") + t.Setenv("PLATFORM_LOG_BODY_BACKEND", "") + + cfg := Load() + if cfg.Addr != defaultAddr { + t.Fatalf("expected default addr %q, got %q", defaultAddr, cfg.Addr) + } + if cfg.StorageBackend != "file" || cfg.MySQLDSN != "" || cfg.DataDir != ".platform-data" || cfg.LogBodyBackend != "" { + t.Fatalf("unexpected default storage config: %+v", cfg) + } + if cfg.MetadataPath != filepath.Join(".platform-data", "metadata.json") || cfg.LogDir != filepath.Join(".platform-data", "logs") { + t.Fatalf("unexpected default storage paths: %+v", cfg) + } +} + +func TestLoadUsesConfiguredAddress(t *testing.T) { + t.Setenv("PLATFORM_ADDR", ":18080") + t.Setenv("PLATFORM_STORAGE_BACKEND", "mysql") + t.Setenv("PLATFORM_MYSQL_DSN", "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true") + t.Setenv("PLATFORM_DATA_DIR", "/tmp/platform-data") + t.Setenv("PLATFORM_METADATA_PATH", "/tmp/platform-metadata.json") + t.Setenv("PLATFORM_LOG_DIR", "/tmp/platform-logs") + t.Setenv("PLATFORM_LOG_BODY_BACKEND", "file") + + cfg := Load() + if cfg.Addr != ":18080" { + t.Fatalf("expected configured addr, got %q", cfg.Addr) + } + if cfg.StorageBackend != "mysql" || cfg.MySQLDSN != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" || cfg.DataDir != "/tmp/platform-data" || cfg.MetadataPath != "/tmp/platform-metadata.json" || cfg.LogDir != "/tmp/platform-logs" || cfg.LogBodyBackend != "file" { + t.Fatalf("unexpected configured storage: %+v", cfg) + } +} + +func TestLoadReadsPlatformEnvFile(t *testing.T) { + clearPlatformEnv(t) + chdirTemp(t) + + if err := os.Mkdir("platform", 0o755); err != nil { + t.Fatalf("create platform dir: %v", err) + } + env := strings.Join([]string{ + "PLATFORM_ADDR=:19090", + "PLATFORM_STORAGE_BACKEND=mysql", + "PLATFORM_MYSQL_DSN='platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true'", + "PLATFORM_LOG_BODY_BACKEND=file", + }, "\n") + if err := os.WriteFile(filepath.Join("platform", ".env"), []byte(env), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + cfg := Load() + if cfg.Addr != ":19090" || cfg.StorageBackend != "mysql" || cfg.MySQLDSN != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" || cfg.LogBodyBackend != "file" { + t.Fatalf("expected config from platform/.env, got %+v", cfg) + } +} + +func TestLoadKeepsProcessEnvOverEnvFile(t *testing.T) { + clearPlatformEnv(t) + chdirTemp(t) + t.Setenv("PLATFORM_STORAGE_BACKEND", "memory") + + if err := os.WriteFile(".env", []byte("PLATFORM_STORAGE_BACKEND=mysql\nPLATFORM_MYSQL_DSN=file-dsn\n"), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + cfg := Load() + if cfg.StorageBackend != "memory" { + t.Fatalf("expected process env storage backend, got %+v", cfg) + } + if cfg.MySQLDSN != "file-dsn" { + t.Fatalf("expected missing process DSN to come from env file, got %+v", cfg) + } +} + +func clearPlatformEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "PLATFORM_ADDR", + "PLATFORM_STORAGE_BACKEND", + "PLATFORM_MYSQL_DSN", + "PLATFORM_DATA_DIR", + "PLATFORM_METADATA_PATH", + "PLATFORM_LOG_DIR", + "PLATFORM_LOG_BODY_BACKEND", + } { + t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } +} + +func chdirTemp(t *testing.T) { + t.Helper() + previous, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatalf("chdir temp: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previous); err != nil { + t.Fatalf("restore working directory: %v", err) + } + }) +} diff --git a/platform/domain/ai_invocation.go b/platform/domain/ai_invocation.go new file mode 100644 index 0000000..9ea7047 --- /dev/null +++ b/platform/domain/ai_invocation.go @@ -0,0 +1,71 @@ +package domain + +type AIInvocationRequest struct { + RequestID string + PluginID string + RouteKey string + ServerInstanceID string + Purpose string + ProviderID string + Model string + Prompt string + CurrentConfig string + ContextRefs map[string]string +} + +type AIInvocationUsage struct { + ProviderID string + Model string + InputTokens int + OutputTokens int + Mocked bool +} + +type AIConfigRecommendation struct { + Key string + SuggestedConfig string + DiffSummary string +} + +type AIInvocationSafeError struct { + Code string + Message string + Details []string +} + +type AIInvocationResponse struct { + RequestID string + Purpose string + ProviderID string + Model string + Status string + Recommendation string + ConfigRecommendation *AIConfigRecommendation + Usage AIInvocationUsage + Error *AIInvocationSafeError +} + +type AIProviderInvocationResult struct { + Recommendation string + SuggestedConfig string + Usage AIInvocationUsage + Error *AIInvocationSafeError +} + +func CopyAIInvocationRequest(request AIInvocationRequest) AIInvocationRequest { + request.ContextRefs = CopyStringMap(request.ContextRefs) + return request +} + +func CopyAIInvocationResponse(response AIInvocationResponse) AIInvocationResponse { + if response.ConfigRecommendation != nil { + recommendation := *response.ConfigRecommendation + response.ConfigRecommendation = &recommendation + } + if response.Error != nil { + errorCopy := *response.Error + errorCopy.Details = CopyStringSlice(errorCopy.Details) + response.Error = &errorCopy + } + return response +} diff --git a/platform/domain/artifact_download.go b/platform/domain/artifact_download.go new file mode 100644 index 0000000..81b282c --- /dev/null +++ b/platform/domain/artifact_download.go @@ -0,0 +1,84 @@ +package domain + +import "time" + +type ArtifactDownloadReferenceRequest struct { + ArtifactID string +} + +type ArtifactDownloadReference struct { + ArtifactID string + OwnerKind ArtifactOwnerKind + OwnerID string + Filename string + ContentType string + SizeBytes int64 + Checksum string + State ArtifactState + DownloadURL string + ExpiresAt time.Time + RangeSupported bool + ChunkSizeBytes int + StorageBehavior string +} + +type ArtifactContentRequest struct { + ArtifactID string + Offset int64 + Limit int +} + +type ArtifactContent struct { + ArtifactID string + Filename string + ContentType string + Offset int64 + SizeBytes int64 + TotalSizeBytes int64 + Checksum string + ContentChecksum string + Partial bool + RangeSupported bool + Payload []byte + StorageBehavior string + ServedAt time.Time +} + +type ArtifactTransferProgress struct { + ArtifactID string + BytesRead int64 + TotalSizeBytes int64 + Complete bool +} + +type ArtifactDownloadSafeError struct { + Code string + Message string + Details []string +} + +func CopyArtifactDownloadReferenceRequest(request ArtifactDownloadReferenceRequest) ArtifactDownloadReferenceRequest { + return request +} + +func CopyArtifactDownloadReference(reference ArtifactDownloadReference) ArtifactDownloadReference { + return reference +} + +func CopyArtifactContentRequest(request ArtifactContentRequest) ArtifactContentRequest { + return request +} + +func CopyArtifactContent(content ArtifactContent) ArtifactContent { + content.Payload = CopyBytes(content.Payload) + return content +} + +func CopyArtifactTransferProgress(progress ArtifactTransferProgress) ArtifactTransferProgress { + return progress +} + +func CopyArtifactDownloadSafeError(safeError ArtifactDownloadSafeError) ArtifactDownloadSafeError { + safeError.Details = CopyStringSlice(safeError.Details) + return safeError +} diff --git a/platform/domain/artifact_transfer.go b/platform/domain/artifact_transfer.go new file mode 100644 index 0000000..b7800fd --- /dev/null +++ b/platform/domain/artifact_transfer.go @@ -0,0 +1,187 @@ +package domain + +import "time" + +type ArtifactTransferDirection string + +const ( + ArtifactTransferDirectionUpload ArtifactTransferDirection = "upload" +) + +type ArtifactTransferOpen struct { + RunEndpointID string + SessionToken string + ArtifactID string + Direction ArtifactTransferDirection + OwnerKind ArtifactOwnerKind + OwnerID string + SizeBytes int64 + ChunkSizeBytes int + Checksum string + IdempotencyKey string +} + +type ArtifactTransferOpenResult struct { + Accepted bool + TransferID string + Direction ArtifactTransferDirection + Artifact Artifact + TotalChunks int + ChunkSizeBytes int + ReceivedChunkIndexes []int + NextMissingChunkIndex int + Completed bool + Duplicate bool + ServerTime time.Time +} + +type ArtifactChunkUpload struct { + RunEndpointID string + SessionToken string + TransferID string + ArtifactID string + ChunkIndex int + Offset int64 + SizeBytes int + Checksum string + Payload []byte +} + +type ArtifactChunkUploadResult struct { + Accepted bool + TransferID string + ArtifactID string + ChunkIndex int + ReceivedChunkIndexes []int + NextMissingChunkIndex int + Duplicate bool + ServerTime time.Time +} + +type ArtifactTransferStatusQuery struct { + RunEndpointID string + SessionToken string + TransferID string + ArtifactID string +} + +type ArtifactTransferStatusResult struct { + Accepted bool + TransferID string + ArtifactID string + Direction ArtifactTransferDirection + TotalChunks int + ChunkSizeBytes int + ReceivedChunkIndexes []int + NextMissingChunkIndex int + Completed bool + ServerTime time.Time +} + +type ArtifactTransferComplete struct { + RunEndpointID string + SessionToken string + TransferID string + ArtifactID string + Checksum string + SizeBytes int64 +} + +type ArtifactTransferCompleteResult struct { + Accepted bool + TransferID string + Artifact Artifact + Completed bool + ServerTime time.Time +} + +type ArtifactChunkRecord struct { + ChunkIndex int + Offset int64 + SizeBytes int + Checksum string + Payload []byte + ReceivedAt time.Time +} + +type ArtifactTransferSession struct { + TransferID string + RunEndpointID string + ArtifactID string + Direction ArtifactTransferDirection + OwnerKind ArtifactOwnerKind + OwnerID string + SizeBytes int64 + ChunkSizeBytes int + Checksum string + IdempotencyKey string + TotalChunks int + ReceivedChunks map[int]ArtifactChunkRecord + Completed bool + CreatedAt time.Time + UpdatedAt time.Time +} + +func CopyArtifactTransferOpen(open ArtifactTransferOpen) ArtifactTransferOpen { + return open +} + +func CopyArtifactChunkUpload(chunk ArtifactChunkUpload) ArtifactChunkUpload { + chunk.Payload = CopyBytes(chunk.Payload) + return chunk +} + +func CopyArtifactTransferOpenResult(result ArtifactTransferOpenResult) ArtifactTransferOpenResult { + result.Artifact = CopyArtifact(result.Artifact) + result.ReceivedChunkIndexes = CopyIntSlice(result.ReceivedChunkIndexes) + return result +} + +func CopyArtifactChunkUploadResult(result ArtifactChunkUploadResult) ArtifactChunkUploadResult { + result.ReceivedChunkIndexes = CopyIntSlice(result.ReceivedChunkIndexes) + return result +} + +func CopyArtifactTransferStatusResult(result ArtifactTransferStatusResult) ArtifactTransferStatusResult { + result.ReceivedChunkIndexes = CopyIntSlice(result.ReceivedChunkIndexes) + return result +} + +func CopyArtifactTransferCompleteResult(result ArtifactTransferCompleteResult) ArtifactTransferCompleteResult { + result.Artifact = CopyArtifact(result.Artifact) + return result +} + +func CopyArtifactChunkRecord(record ArtifactChunkRecord) ArtifactChunkRecord { + record.Payload = CopyBytes(record.Payload) + return record +} + +func CopyArtifactTransferSession(session ArtifactTransferSession) ArtifactTransferSession { + if session.ReceivedChunks != nil { + chunks := make(map[int]ArtifactChunkRecord, len(session.ReceivedChunks)) + for index, record := range session.ReceivedChunks { + chunks[index] = CopyArtifactChunkRecord(record) + } + session.ReceivedChunks = chunks + } + return session +} + +func CopyBytes(values []byte) []byte { + if values == nil { + return nil + } + out := make([]byte, len(values)) + copy(out, values) + return out +} + +func CopyIntSlice(values []int) []int { + if values == nil { + return nil + } + out := make([]int, len(values)) + copy(out, values) + return out +} diff --git a/platform/domain/control.go b/platform/domain/control.go new file mode 100644 index 0000000..4d5f14e --- /dev/null +++ b/platform/domain/control.go @@ -0,0 +1,81 @@ +package domain + +import "time" + +type RunCapabilityReport struct { + Capabilities []string + Fingerprint string +} + +type RunControlHello struct { + RegistrationToken string + RunEndpointID string + DisplayName string + Version string + Status RunEndpointStatus + Platform string + CapabilityReport RunCapabilityReport + Capacity RunCapacity +} + +type RunControlHelloResult struct { + Accepted bool + RunEndpointID string + SessionToken string + ServerTime time.Time + HeartbeatIntervalSeconds int + FeatureFlags []string +} + +type RunControlHeartbeat struct { + RunEndpointID string + SessionToken string + Version string + Status RunEndpointStatus + CapabilityFingerprint string + Capacity RunCapacity +} + +type RunControlHeartbeatResult struct { + Accepted bool + RunEndpointID string + NextHeartbeatSeconds int + RefreshCapabilities bool + ServerTime time.Time +} + +type RunControlSession struct { + RunEndpointID string + SessionToken string + CapabilityFingerprint string + HeartbeatIntervalSeconds int + CreatedAt time.Time + UpdatedAt time.Time +} + +func CopyRunCapabilityReport(report RunCapabilityReport) RunCapabilityReport { + report.Capabilities = CopyStringSlice(report.Capabilities) + return report +} + +func CopyRunControlHello(hello RunControlHello) RunControlHello { + hello.CapabilityReport = CopyRunCapabilityReport(hello.CapabilityReport) + return hello +} + +func CopyRunControlHelloResult(result RunControlHelloResult) RunControlHelloResult { + result.FeatureFlags = CopyStringSlice(result.FeatureFlags) + return result +} + +func CopyRunControlHeartbeat(heartbeat RunControlHeartbeat) RunControlHeartbeat { + return heartbeat +} + +func CopyRunControlHeartbeatResult(result RunControlHeartbeatResult) RunControlHeartbeatResult { + return result +} + +func CopyRunControlSession(session RunControlSession) RunControlSession { + return session +} diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go new file mode 100644 index 0000000..e91e513 --- /dev/null +++ b/platform/domain/job_channel.go @@ -0,0 +1,193 @@ +package domain + +import "time" + +type RunJobProgressReport struct { + Percent int + Message string +} + +type RunJobAssignment struct { + JobID string + ServerInstanceID string + RunEndpointID string + Capability string + TargetKey string + InputRef string + IdempotencyKey string + State JobState + Progress RunJobProgressReport + ResultRef string + LeaseToken string + Attempt int + CreatedAt time.Time + UpdatedAt time.Time +} + +type RunJobClaim struct { + RunEndpointID string + SessionToken string + Capabilities []string + Capacity RunCapacity +} + +type RunJobClaimResult struct { + Accepted bool + RunEndpointID string + HasJob bool + Job *RunJobAssignment + NextPollSeconds int + ServerTime time.Time +} + +type RunJobAck struct { + RunEndpointID string + SessionToken string + JobID string + LeaseToken string + Attempt int + Message string +} + +type RunJobAckResult struct { + Accepted bool + Job RunJobAssignment + ServerTime time.Time +} + +type RunJobProgress struct { + RunEndpointID string + SessionToken string + JobID string + LeaseToken string + Attempt int + Progress RunJobProgressReport + Sequence uint64 +} + +type RunJobProgressResult struct { + Accepted bool + Job RunJobAssignment + ServerTime time.Time +} + +type RunJobResult struct { + RunEndpointID string + SessionToken string + JobID string + LeaseToken string + Attempt int + State JobState + Progress RunJobProgressReport + ResultRef string + Message string + ErrorCode string +} + +type RunJobResultResult struct { + Accepted bool + Job RunJobAssignment + ServerTime time.Time +} + +type RunJobCancelRequest struct { + JobID string + Reason string +} + +type RunJobCancelRequestResult struct { + Accepted bool + JobID string + Reason string + RequestedAt time.Time +} + +type RunJobCancelPoll struct { + RunEndpointID string + SessionToken string + JobID string + LeaseToken string +} + +type RunJobCancelPollResult struct { + Accepted bool + RunEndpointID string + HasCancel bool + JobID string + Reason string + RequestedAt time.Time + ServerTime time.Time +} + +type RunJobReconcile struct { + RunEndpointID string + SessionToken string + ActiveJobIDs []string +} + +type RunJobReconcileResult struct { + Accepted bool + RunEndpointID string + ActiveJobs []RunJobAssignment + UnknownJobIDs []string + ServerTime time.Time +} + +type RunJobLease struct { + JobID string + RunEndpointID string + SessionToken string + LeaseToken string + Attempt int + CancelReason string + CancelRequestedAt time.Time + TerminalFingerprint string + CreatedAt time.Time + UpdatedAt time.Time +} + +func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment { + return assignment +} + +func CopyRunJobAssignmentPtr(assignment *RunJobAssignment) *RunJobAssignment { + if assignment == nil { + return nil + } + copy := CopyRunJobAssignment(*assignment) + return © +} + +func CopyRunJobClaim(claim RunJobClaim) RunJobClaim { + claim.Capabilities = CopyStringSlice(claim.Capabilities) + return claim +} + +func CopyRunJobClaimResult(result RunJobClaimResult) RunJobClaimResult { + result.Job = CopyRunJobAssignmentPtr(result.Job) + return result +} + +func CopyRunJobReconcile(reconcile RunJobReconcile) RunJobReconcile { + reconcile.ActiveJobIDs = CopyStringSlice(reconcile.ActiveJobIDs) + return reconcile +} + +func CopyRunJobReconcileResult(result RunJobReconcileResult) RunJobReconcileResult { + result.ActiveJobs = CopyRunJobAssignments(result.ActiveJobs) + result.UnknownJobIDs = CopyStringSlice(result.UnknownJobIDs) + return result +} + +func CopyRunJobAssignments(assignments []RunJobAssignment) []RunJobAssignment { + if assignments == nil { + return nil + } + out := make([]RunJobAssignment, len(assignments)) + copy(out, assignments) + return out +} + +func CopyRunJobLease(lease RunJobLease) RunJobLease { + return lease +} diff --git a/platform/domain/log_ingest.go b/platform/domain/log_ingest.go new file mode 100644 index 0000000..fbbeff6 --- /dev/null +++ b/platform/domain/log_ingest.go @@ -0,0 +1,93 @@ +package domain + +import "time" + +type LogEntry struct { + Seq uint64 + Timestamp time.Time + Level string + Line string + Fields map[string]string + Redacted bool +} + +type LogBatchIngest struct { + RunEndpointID string + SessionToken string + LogStreamID string + ServerInstanceID string + StreamKey string + Source LogStreamSource + FirstSeq uint64 + LastSeq uint64 + Compression string + Checksum string + Entries []LogEntry +} + +type LogBatchIngestResult struct { + Accepted bool + LogStreamID string + AcceptedFrom uint64 + AcceptedTo uint64 + LatestSeq uint64 + Duplicate bool + ServerTime time.Time +} + +type LogStreamCursorQuery struct { + LogStreamID string + AfterSeq uint64 + Limit int +} + +type LogStreamCursorResult struct { + LogStreamID string + Entries []LogEntry + NextSeq uint64 + LatestSeq uint64 +} + +type LogBatchRecord struct { + Checksum string + FirstSeq uint64 + LastSeq uint64 + Entries []LogEntry +} + +func CopyLogEntry(entry LogEntry) LogEntry { + if entry.Fields != nil { + fields := make(map[string]string, len(entry.Fields)) + for key, value := range entry.Fields { + fields[key] = value + } + entry.Fields = fields + } + return entry +} + +func CopyLogEntries(entries []LogEntry) []LogEntry { + if entries == nil { + return nil + } + out := make([]LogEntry, len(entries)) + for i, entry := range entries { + out[i] = CopyLogEntry(entry) + } + return out +} + +func CopyLogBatchIngest(batch LogBatchIngest) LogBatchIngest { + batch.Entries = CopyLogEntries(batch.Entries) + return batch +} + +func CopyLogStreamCursorResult(result LogStreamCursorResult) LogStreamCursorResult { + result.Entries = CopyLogEntries(result.Entries) + return result +} + +func CopyLogBatchRecord(record LogBatchRecord) LogBatchRecord { + record.Entries = CopyLogEntries(record.Entries) + return record +} diff --git a/platform/domain/resources.go b/platform/domain/resources.go new file mode 100644 index 0000000..01c22b7 --- /dev/null +++ b/platform/domain/resources.go @@ -0,0 +1,816 @@ +package domain + +import "time" + +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusDisabled UserStatus = "disabled" + UserStatusPending UserStatus = "pending" +) + +type AIProviderKind string + +const ( + AIProviderKindOpenAICompatible AIProviderKind = "openai-compatible" + AIProviderKindOpenAI AIProviderKind = "openai" + AIProviderKindClaude AIProviderKind = "claude" + AIProviderKindGemini AIProviderKind = "gemini" + AIProviderKindOllama AIProviderKind = "ollama" + AIProviderKindCustom AIProviderKind = "custom" +) + +type AIRelayMode string + +const ( + AIRelayModeDirect AIRelayMode = "direct" + AIRelayModeRelay AIRelayMode = "relay" + AIRelayModeLocal AIRelayMode = "local" +) + +type AIProviderStatus string + +const ( + AIProviderStatusActive AIProviderStatus = "active" + AIProviderStatusDisabled AIProviderStatus = "disabled" + AIProviderStatusError AIProviderStatus = "error" +) + +type GamePluginStatus string + +const ( + GamePluginStatusInstalled GamePluginStatus = "installed" + GamePluginStatusDisabled GamePluginStatus = "disabled" + GamePluginStatusInvalid GamePluginStatus = "invalid" + GamePluginStatusUpdating GamePluginStatus = "updating" +) + +type PluginMarketplaceStateAction string + +const ( + PluginMarketplaceStateActionInstall PluginMarketplaceStateAction = "install" + PluginMarketplaceStateActionEnable PluginMarketplaceStateAction = "enable" + PluginMarketplaceStateActionDisable PluginMarketplaceStateAction = "disable" +) + +type ServerInstanceState string + +const ( + ServerInstanceStateDraft ServerInstanceState = "draft" + ServerInstanceStateInstalling ServerInstanceState = "installing" + ServerInstanceStateReady ServerInstanceState = "ready" + ServerInstanceStateRunning ServerInstanceState = "running" + ServerInstanceStateStopped ServerInstanceState = "stopped" + ServerInstanceStateFailed ServerInstanceState = "failed" + ServerInstanceStateDeleted ServerInstanceState = "deleted" +) + +type RunEndpointStatus string + +const ( + RunEndpointStatusOnline RunEndpointStatus = "online" + RunEndpointStatusOffline RunEndpointStatus = "offline" + RunEndpointStatusDegraded RunEndpointStatus = "degraded" + RunEndpointStatusDisabled RunEndpointStatus = "disabled" +) + +type JobState string + +const ( + JobStateQueued JobState = "queued" + JobStateAccepted JobState = "accepted" + JobStateRunning JobState = "running" + JobStateSucceeded JobState = "succeeded" + JobStateFailed JobState = "failed" + JobStateCancelled JobState = "cancelled" +) + +type ArtifactOwnerKind string + +const ( + ArtifactOwnerKindPlatform ArtifactOwnerKind = "platform" + ArtifactOwnerKindPlugin ArtifactOwnerKind = "plugin" + ArtifactOwnerKindServerInstance ArtifactOwnerKind = "server-instance" + ArtifactOwnerKindJob ArtifactOwnerKind = "job" +) + +type ArtifactState string + +const ( + ArtifactStateUploading ArtifactState = "uploading" + ArtifactStateAvailable ArtifactState = "available" + ArtifactStateExpired ArtifactState = "expired" + ArtifactStateFailed ArtifactState = "failed" +) + +type LogStreamSource string + +const ( + LogStreamSourceProcess LogStreamSource = "process" + LogStreamSourceFile LogStreamSource = "file" + LogStreamSourcePlugin LogStreamSource = "plugin" +) + +type LogStorageBackend string + +const ( + LogStorageBackendLocalSegments LogStorageBackend = "local-segments" + LogStorageBackendLoki LogStorageBackend = "loki" + LogStorageBackendClickHouse LogStorageBackend = "clickhouse" + LogStorageBackendOpenSearch LogStorageBackend = "opensearch" + LogStorageBackendElasticsearch LogStorageBackend = "elasticsearch" +) + +type AuditResult string + +const ( + AuditResultSuccess AuditResult = "success" + AuditResultDenied AuditResult = "denied" + AuditResultFailed AuditResult = "failed" + AuditResultQueued AuditResult = "queued" +) + +type User struct { + ID string + DisplayName string + Email string + Status UserStatus + Roles []string + PasswordHash string + Profile UserProfile + Theme UserThemePreference + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserProfile struct { + AvatarURL string + Phone string + QQ string + ContactNote string +} + +type UserThemePreference struct { + UserID string + PaletteID string + BackgroundPresetID string + BackgroundImage string + Persistence string + UpdatedAt time.Time +} + +type UserRegistration struct { + DisplayName string + Email string + Password string + Profile UserProfile +} + +type UserLogin struct { + Account string + Password string +} + +type AuthSession struct { + SessionID string + User User + Status string + Message string +} + +type AIProvider struct { + ID string + Name string + Kind AIProviderKind + BaseURL string + APIKeyRef string + Models []string + DefaultModel string + RelayMode AIRelayMode + TimeoutMS int + Status AIProviderStatus + RedactionPolicy string +} + +type AIProviderTestResult struct { + ProviderID string + Mode string + Success bool + Message string + Violations []string +} + +type AIProviderModels struct { + ProviderID string + DefaultModel string + Models []string +} + +type PluginPermissions struct { + AI bool + Logs bool + Files bool + Jobs bool + Artifacts bool +} + +type PluginLifecycleActions struct { + Install string + Start string + Stop string + Restart string + Status string +} + +type GamePluginPage struct { + Key string + Title string + Path string + Permissions []string + BridgeActions []string +} + +type GamePluginBridge struct { + Actions []string +} + +type GamePluginManifestServer struct { + Type string + DisplayName string + SupportedOS []string + CreateFormSchema string +} + +type GamePluginManifestAI struct { + Purposes []string +} + +type GamePluginManifest struct { + ID string + Name string + Description string + Version string + Kind string + Tags []string + Server GamePluginManifestServer + Bridge GamePluginBridge + Capabilities []string + Permissions []string + Actions PluginLifecycleActions + Pages []GamePluginPage + AI GamePluginManifestAI +} + +type GamePluginManifestRegistration struct { + ManifestRef string + Manifest GamePluginManifest +} + +type GamePlugin struct { + ID string + Name string + Description string + Version string + ServerType string + ServerDisplayName string + SupportedOS []string + ManifestRef string + CreateFormSchemaRef string + RequiredRunCapabilities []string + DeclaredPermissions []string + Permissions PluginPermissions + LifecycleActions PluginLifecycleActions + BridgeActions []string + Pages []GamePluginPage + Tags []string + AIPurposes []string + ValidationViolations []string + Status GamePluginStatus +} + +type PluginMarketplacePlugin struct { + ID string + Name string + Description string + Version string + ServerType string + ServerDisplayName string + SupportedOS []string + ManifestRef string + CreateFormSchemaRef string + Capabilities []string + DeclaredPermissions []string + Permissions PluginPermissions + LifecycleActions PluginLifecycleActions + BridgeActions []string + Pages []GamePluginPage + Tags []string + AIPurposes []string + ValidationViolations []string + Status GamePluginStatus + Source string +} + +type PluginBridgeAction string + +const ( + PluginBridgeActionServerInstancesRead PluginBridgeAction = "server.instances.read" + PluginBridgeActionJobsDispatch PluginBridgeAction = "jobs.dispatch" + PluginBridgeActionLogsQuery PluginBridgeAction = "logs.query" + PluginBridgeActionArtifactsOpen PluginBridgeAction = "artifacts.open" + PluginBridgeActionFilesRequest PluginBridgeAction = "files.request" + PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke" +) + +type PluginBridgeAuthorizeRequest struct { + PluginID string + RouteKey string + ServerInstanceID string + Action PluginBridgeAction + AIPurpose string +} + +type PluginBridgeAuthorization struct { + PluginID string + RouteKey string + ServerInstanceID string + Action PluginBridgeAction + Allowed bool + RequiredPermissions []string + EffectivePermissions []string + Reason string +} + +type PluginBridgeExecuteRequest struct { + RequestID string + PluginID string + RouteKey string + ServerInstanceID string + Action PluginBridgeAction + AIPurpose string + Payload map[string]string +} + +type PluginBridgeSafeError struct { + Code string + Message string + Details []string +} + +type PluginBridgeExecuteResponse struct { + RequestID string + PluginID string + RouteKey string + ServerInstanceID string + Action PluginBridgeAction + Status string + Result map[string]string + Error *PluginBridgeSafeError +} + +type ServerInstance struct { + ID string + PluginID string + PluginVersion string + RunEndpointID string + Name string + OwnerUserID string + AdminUserIDs []string + State ServerInstanceState + ConfigVersion int + CreatedAt time.Time + UpdatedAt time.Time +} + +type PlatformResourceUsage struct { + CPUPercent float64 + MemoryPercent float64 + DiskPercent float64 + Source string + CollectedAt time.Time +} + +type ServerMetrics struct { + ServerInstanceID string + Online bool + PlayerCount *int + MaxPlayers *int + TPS *float64 + LatencyMS *float64 + CPUPercent *float64 + MemoryPercent *float64 + DiskPercent *float64 + Source string + CollectedAt time.Time +} + +type ServerConfig struct { + ServerInstanceID string + ConfigVersion int + Format string + Key string + Content string + Source string + UpdatedAt time.Time +} + +type ConfigDiffLine struct { + Kind string + OldNumber int + NewNumber int + Content string +} + +type ServerConfigDiffRequest struct { + ServerInstanceID string + ExpectedConfigVersion int + Key string + ProposedContent string + ProposedContentInputRef string +} + +type ServerConfigDiffPreview struct { + ServerInstanceID string + ConfigVersion int + Key string + CurrentContent string + ProposedContent string + ProposedContentInputRef string + Diff []ConfigDiffLine + HasChanges bool + Source string + ReviewedAt time.Time +} + +type ServerConfigWriteApproval struct { + ServerInstanceID string + ExpectedConfigVersion int + Key string + ProposedContent string + ProposedContentInputRef string + IdempotencyKey string +} + +type ServerConfigWriteDispatch struct { + Preview ServerConfigDiffPreview + Job Job + Status string +} + +type FileOperationKind string + +const ( + FileOperationRead FileOperationKind = "read" + FileOperationWrite FileOperationKind = "write" +) + +type FileOperationDispatchRequest struct { + ServerInstanceID string + PluginID string + Operation FileOperationKind + Key string + InputRef string + ExpectedConfigVersion int + IdempotencyKey string +} + +type FileOperationDispatchResult struct { + ServerInstanceID string + PluginID string + Operation FileOperationKind + Key string + InputRef string + Job Job + Status string +} + +type RunCapacity struct { + MaxJobs int + RunningJobs int + QueuedJobs int + Summary string +} + +const ( + JobCapabilityConfigWrite = "config.write" + JobCapabilityFilesRead = "files.read" + JobCapabilityFilesWrite = "files.write" +) + +type RunEndpoint struct { + ID string + DisplayName string + Version string + Status RunEndpointStatus + Capabilities []string + Capacity RunCapacity + LastHeartbeatAt time.Time +} + +type JobProgress struct { + Percent int + Message string +} + +type Job struct { + ID string + ServerInstanceID string + RunEndpointID string + Capability string + TargetKey string + InputRef string + IdempotencyKey string + State JobState + Progress JobProgress + ResultRef string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Artifact struct { + ID string + OwnerKind ArtifactOwnerKind + OwnerID string + SizeBytes int64 + Checksum string + State ArtifactState + CreatedAt time.Time + UpdatedAt time.Time +} + +type LogStream struct { + ID string + ServerInstanceID string + Source LogStreamSource + StreamKey string + LatestSeq uint64 + StorageBackend LogStorageBackend + RetentionPolicy string + CreatedAt time.Time + UpdatedAt time.Time +} + +type AuditEvent struct { + ID string + ActorID string + Action string + ResourceKind string + ResourceID string + Result AuditResult + Summary string + CreatedAt time.Time +} + +type UserFilter struct { + Status UserStatus +} + +type AIProviderFilter struct { + Kind AIProviderKind + Status AIProviderStatus +} + +type GamePluginFilter struct { + ServerType string + Status GamePluginStatus +} + +type PluginMarketplaceFilter struct { + ServerType string + Status GamePluginStatus + Capability string + Keyword string +} + +type ServerInstanceFilter struct { + PluginID string + RunEndpointID string + State ServerInstanceState + VisibleToUserID string +} + +type RunEndpointFilter struct { + Status RunEndpointStatus +} + +type JobFilter struct { + ServerInstanceID string + RunEndpointID string + State JobState +} + +type ArtifactFilter struct { + OwnerKind ArtifactOwnerKind + OwnerID string + State ArtifactState +} + +type LogStreamFilter struct { + ServerInstanceID string + StreamKey string +} + +type AuditEventFilter struct { + ActorID string + ResourceKind string + ResourceID string + Result AuditResult +} + +func CopyStringSlice(values []string) []string { + if values == nil { + return nil + } + out := make([]string, len(values)) + copy(out, values) + return out +} + +func CopyStringMap(values map[string]string) map[string]string { + if values == nil { + return nil + } + out := make(map[string]string, len(values)) + for key, value := range values { + out[key] = value + } + return out +} + +func CopyUser(user User) User { + user.Roles = CopyStringSlice(user.Roles) + return user +} + +func CopyAIProvider(provider AIProvider) AIProvider { + provider.Models = CopyStringSlice(provider.Models) + return provider +} + +func CopyAIProviderTestResult(result AIProviderTestResult) AIProviderTestResult { + result.Violations = CopyStringSlice(result.Violations) + return result +} + +func CopyAIProviderModels(models AIProviderModels) AIProviderModels { + models.Models = CopyStringSlice(models.Models) + return models +} + +func CopyGamePlugin(plugin GamePlugin) GamePlugin { + plugin.RequiredRunCapabilities = CopyStringSlice(plugin.RequiredRunCapabilities) + plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions) + plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS) + plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions) + plugin.Pages = CopyGamePluginPageSlice(plugin.Pages) + plugin.Tags = CopyStringSlice(plugin.Tags) + plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes) + plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) + return plugin +} + +func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketplacePlugin { + plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS) + plugin.Capabilities = CopyStringSlice(plugin.Capabilities) + plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions) + plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions) + plugin.Pages = CopyGamePluginPageSlice(plugin.Pages) + plugin.Tags = CopyStringSlice(plugin.Tags) + plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes) + plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) + return plugin +} + +func CopyPluginMarketplacePluginSlice(plugins []PluginMarketplacePlugin) []PluginMarketplacePlugin { + if plugins == nil { + return nil + } + out := make([]PluginMarketplacePlugin, len(plugins)) + for i, plugin := range plugins { + out[i] = CopyPluginMarketplacePlugin(plugin) + } + return out +} + +func CopyGamePluginManifestRegistration(registration GamePluginManifestRegistration) GamePluginManifestRegistration { + registration.Manifest = CopyGamePluginManifest(registration.Manifest) + return registration +} + +func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { + manifest.Tags = CopyStringSlice(manifest.Tags) + manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS) + manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions) + manifest.Capabilities = CopyStringSlice(manifest.Capabilities) + manifest.Permissions = CopyStringSlice(manifest.Permissions) + manifest.Pages = CopyGamePluginPageSlice(manifest.Pages) + manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes) + return manifest +} + +func CopyGamePluginPageSlice(pages []GamePluginPage) []GamePluginPage { + if pages == nil { + return nil + } + out := make([]GamePluginPage, len(pages)) + for i, page := range pages { + out[i] = page + out[i].Permissions = CopyStringSlice(page.Permissions) + out[i].BridgeActions = CopyStringSlice(page.BridgeActions) + } + return out +} + +func CopyPluginBridgeAuthorization(result PluginBridgeAuthorization) PluginBridgeAuthorization { + result.RequiredPermissions = CopyStringSlice(result.RequiredPermissions) + result.EffectivePermissions = CopyStringSlice(result.EffectivePermissions) + return result +} + +func CopyPluginBridgeExecuteRequest(request PluginBridgeExecuteRequest) PluginBridgeExecuteRequest { + request.Payload = CopyStringMap(request.Payload) + return request +} + +func CopyPluginBridgeExecuteResponse(response PluginBridgeExecuteResponse) PluginBridgeExecuteResponse { + response.Result = CopyStringMap(response.Result) + if response.Error != nil { + errorCopy := *response.Error + errorCopy.Details = CopyStringSlice(errorCopy.Details) + response.Error = &errorCopy + } + return response +} + +func CopyServerInstance(instance ServerInstance) ServerInstance { + instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs) + return instance +} + +func CopyPlatformResourceUsage(usage PlatformResourceUsage) PlatformResourceUsage { + return usage +} + +func CopyServerMetrics(metrics ServerMetrics) ServerMetrics { + return metrics +} + +func CopyServerMetricsSlice(items []ServerMetrics) []ServerMetrics { + if items == nil { + return nil + } + out := make([]ServerMetrics, len(items)) + copy(out, items) + return out +} + +func CopyServerConfig(config ServerConfig) ServerConfig { + return config +} + +func CopyConfigDiffLines(lines []ConfigDiffLine) []ConfigDiffLine { + if lines == nil { + return nil + } + out := make([]ConfigDiffLine, len(lines)) + copy(out, lines) + return out +} + +func CopyServerConfigDiffPreview(preview ServerConfigDiffPreview) ServerConfigDiffPreview { + preview.Diff = CopyConfigDiffLines(preview.Diff) + return preview +} + +func CopyServerConfigWriteDispatch(dispatch ServerConfigWriteDispatch) ServerConfigWriteDispatch { + dispatch.Preview = CopyServerConfigDiffPreview(dispatch.Preview) + dispatch.Job = CopyJob(dispatch.Job) + return dispatch +} + +func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOperationDispatchResult { + result.Job = CopyJob(result.Job) + return result +} + +func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint { + endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities) + return endpoint +} + +func CopyJob(job Job) Job { + return job +} + +func CopyArtifact(artifact Artifact) Artifact { + return artifact +} + +func CopyLogStream(stream LogStream) LogStream { + return stream +} + +func CopyAuditEvent(event AuditEvent) AuditEvent { + return event +} diff --git a/platform/domain/resources.md b/platform/domain/resources.md new file mode 100644 index 0000000..e4ee33b --- /dev/null +++ b/platform/domain/resources.md @@ -0,0 +1,130 @@ +# Platform Domain Resources + +This file defines the first platform resource contracts. Concrete Go domain structs are implemented in `platform/domain/resources.go`; API DTO projections live in `platform/dto/resources.go`; database model projections live in `platform/model/resources.go`. Do not define these resource shapes inside handlers or service functions. + +## Implemented Boundaries + +- Domain constants centralize allowed status, state, provider kind, relay mode, artifact owner, storage backend, and audit result values. +- DTO responses expose `apiKeyRef` for AI providers but never raw key material. +- Model structs include JSON/database tags and explicit `TableName()` mappings for future persistence work. +- `platform/repo.NewFileStore` provides durable local metadata snapshots for platform startup, while `platform/repo.NewMemoryStore` provides deterministic in-memory repository behavior for unit tests and disposable local runs. +- Log stream metadata records the selected body backend. The current durable local body backend uses `local-segments`; future production adapters should target log-optimized stores such as `clickhouse`, `loki`, `opensearch`, or `elasticsearch` rather than row-per-line relational tables. +- `platform/service.Core` enforces create/list/get workflows and cross-resource invariants before resources are persisted. + +## User + +- `id`: stable user ID. +- `displayName`: visible user name. +- `email`: optional login email. +- `status`: `active`, `disabled`, or `pending`. +- `roles`: role keys assigned to the user. +- `createdAt`: creation time. +- `updatedAt`: last update time. + +## AIProvider + +- `id`: stable provider ID. +- `name`: display name. +- `kind`: `openai-compatible`, `openai`, `claude`, `gemini`, `ollama`, or `custom`. +- `baseUrl`: provider or relay base URL. +- `apiKeyRef`: secret reference, never the raw key. +- `models`: allowed model IDs. +- `defaultModel`: optional default model. +- `relayMode`: `direct`, `relay`, or `local`. +- `timeoutMs`: request timeout. +- `status`: `active`, `disabled`, or `error`. +- `redactionPolicy`: policy key for prompt/input/output redaction. + +## GamePlugin + +- `id`: plugin ID such as `game.example`. +- `name`: display name. +- `description`: bounded marketplace/registry summary. +- `version`: installed version. +- `serverType`: game/server type key. +- `serverDisplayName`: visible server type name. +- `supportedOs`: operating systems declared by the plugin manifest. +- `manifestRef`: immutable manifest artifact reference. +- `createFormSchemaRef`: create form schema reference. +- `requiredRunCapabilities`: run capabilities required by this plugin. +- `declaredPermissions`: scoped manifest permission keys used by plugin bridge and marketplace views. +- `permissions`: aggregate platform ability declarations for AI, logs, files, jobs, and artifacts. +- `lifecycleActions`: manifest action contract references for install/start/stop and optional restart/status. +- `pages`: plugin-local page metadata with scoped permission requirements. +- `tags`: bounded catalog tags. +- `aiPurposes`: platform-mediated AI purposes such as config suggestions or log diagnosis. +- `validationViolations`: safe validation findings for invalid plugin records. +- `status`: `installed`, `disabled`, `invalid`, or `updating`. + +Manifest registration uses `GamePluginManifestRegistrationRequest` at `POST /api/v1/game-plugins/register-manifest`. Platform validation repeats plugin workspace safety checks and rejects raw host paths, direct run sockets, raw credentials, and raw AI/provider keys before metadata reaches the registry. + +## ServerInstance + +- `id`: server instance ID. +- `pluginId`: installed game management plugin ID. +- `pluginVersion`: plugin version used to create or last reconcile the instance. +- `runEndpointId`: selected run endpoint. +- `name`: server display name. +- `state`: `draft`, `installing`, `ready`, `running`, `stopped`, `failed`, or `deleted`. +- `configVersion`: optimistic concurrency version for platform-managed config. +- `createdAt`: creation time. +- `updatedAt`: last update time. + +## RunEndpoint + +- `id`: run endpoint ID. +- `displayName`: visible executor name. +- `version`: run binary version. +- `status`: `online`, `offline`, `degraded`, or `disabled`. +- `capabilities`: current capability keys. +- `capacity`: current queue and resource summary. +- `lastHeartbeatAt`: last control heartbeat time. + +## Job + +- `id`: job ID. +- `serverInstanceId`: optional target server. +- `runEndpointId`: target run endpoint. +- `capability`: requested capability key. +- `idempotencyKey`: duplicate detection key. +- `state`: `queued`, `accepted`, `running`, `succeeded`, `failed`, or `cancelled`. +- `progress`: bounded progress summary. +- `resultRef`: optional terminal result reference. + +Lifecycle workflow jobs use fixed capabilities: + +- `process.install`: dispatched by server create workflow and projects successful terminal results to `ready`. +- `process.start`: dispatched by server start workflow and projects successful terminal results to `running`. +- `process.stop`: dispatched by server stop workflow and projects successful terminal results to `stopped`. + +Failed or cancelled lifecycle jobs project the server instance to `failed`. Active start/stop jobs are visible through job metadata; this change does not add separate `starting` or `stopping` server states. + +## Artifact + +- `id`: artifact ID. +- `ownerKind`: `platform`, `plugin`, `server-instance`, or `job`. +- `ownerId`: owning resource ID. +- `sizeBytes`: expected or final size. +- `checksum`: final checksum. +- `state`: `uploading`, `available`, `expired`, or `failed`. + +## LogStream + +- `id`: log stream ID. +- `serverInstanceId`: target server. +- `source`: `process`, `file`, `plugin`, or custom source. +- `streamKey`: stable stream key. +- `latestSeq`: latest accepted sequence. +- `storageBackend`: `local-segments`, `loki`, `clickhouse`, `opensearch`, or `elasticsearch`. +- `retentionPolicy`: retention key. + +## AuditEvent + +- `id`: audit event ID. +- `actorId`: user or system actor. +- `action`: stable action key. +- `resourceKind`: resource kind. +- `resourceId`: resource ID. +- `result`: `success`, `denied`, `failed`, or `queued`. +- `summary`: bounded redacted summary. +- `createdAt`: event time. diff --git a/platform/domain/resources_test.go b/platform/domain/resources_test.go new file mode 100644 index 0000000..6f32932 --- /dev/null +++ b/platform/domain/resources_test.go @@ -0,0 +1,36 @@ +package domain + +import "testing" + +func TestCopyHelpersIsolateSlices(t *testing.T) { + plugin := GamePlugin{ + ID: "server.scum", + RequiredRunCapabilities: []string{"process.start", "logs.read"}, + DeclaredPermissions: []string{"server.logs.read"}, + Pages: []GamePluginPage{ + {Key: "logs", Permissions: []string{"server.logs.read"}}, + }, + AIPurposes: []string{"logs.diagnose"}, + } + + copy := CopyGamePlugin(plugin) + copy.RequiredRunCapabilities[0] = "files.read" + copy.DeclaredPermissions[0] = "ai.invoke" + copy.Pages[0].Permissions[0] = "ai.invoke" + copy.AIPurposes[0] = "config.suggest" + + if plugin.RequiredRunCapabilities[0] != "process.start" { + t.Fatalf("expected copied plugin slice mutation not to affect original: %+v", plugin.RequiredRunCapabilities) + } + if plugin.DeclaredPermissions[0] != "server.logs.read" || plugin.Pages[0].Permissions[0] != "server.logs.read" || plugin.AIPurposes[0] != "logs.diagnose" { + t.Fatalf("expected copied plugin registry metadata mutation not to affect original: %+v", plugin) + } + + provider := AIProvider{ID: "ai.openai", Models: []string{"gpt-4.1"}} + providerCopy := CopyAIProvider(provider) + providerCopy.Models[0] = "gpt-4.1-mini" + + if provider.Models[0] != "gpt-4.1" { + t.Fatalf("expected copied provider slice mutation not to affect original: %+v", provider.Models) + } +} diff --git a/platform/domain/server_lifecycle.go b/platform/domain/server_lifecycle.go new file mode 100644 index 0000000..cbb95e3 --- /dev/null +++ b/platform/domain/server_lifecycle.go @@ -0,0 +1,64 @@ +package domain + +type ServerLifecycleAction string + +const ( + ServerLifecycleActionCreate ServerLifecycleAction = "create" + ServerLifecycleActionStart ServerLifecycleAction = "start" + ServerLifecycleActionStop ServerLifecycleAction = "stop" +) + +const ( + LifecycleCapabilityInstall = "process.install" + LifecycleCapabilityStart = "process.start" + LifecycleCapabilityStop = "process.stop" +) + +type ServerLifecycleCreate struct { + ID string + PluginID string + RunEndpointID string + Name string + OwnerUserID string + IdempotencyKey string +} + +type ServerLifecycleCommand struct { + ServerInstanceID string + ExpectedConfigVersion int + IdempotencyKey string +} + +type ServerLifecycleResult struct { + Accepted bool + Action ServerLifecycleAction + Instance ServerInstance + Job Job +} + +func LifecycleCapabilityForAction(action ServerLifecycleAction) string { + switch action { + case ServerLifecycleActionCreate: + return LifecycleCapabilityInstall + case ServerLifecycleActionStart: + return LifecycleCapabilityStart + case ServerLifecycleActionStop: + return LifecycleCapabilityStop + default: + return "" + } +} + +func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate { + return create +} + +func CopyServerLifecycleCommand(command ServerLifecycleCommand) ServerLifecycleCommand { + return command +} + +func CopyServerLifecycleResult(result ServerLifecycleResult) ServerLifecycleResult { + result.Instance = CopyServerInstance(result.Instance) + result.Job = CopyJob(result.Job) + return result +} diff --git a/platform/dto/README.md b/platform/dto/README.md new file mode 100644 index 0000000..7ca259b --- /dev/null +++ b/platform/dto/README.md @@ -0,0 +1,15 @@ +# platform/dto + +Request and response DTOs live here. Do not define API request or response structs inside handlers. + +Initial DTO groups: + +- `auth`: login/session payloads. +- `users`: user and role management payloads. +- `game_plugins`: game management plugin marketplace and installation payloads. +- `server_instances`: create server, update config, lifecycle, and detail payloads. +- `ai_providers`: AI provider create/update/test/invoke payloads. +- `run`: run registration, capability, and status payloads. +- `jobs`: job claim, ack, progress, result, cancel, and reconcile payloads. +- `artifacts`: chunk upload/download and metadata payloads. +- `logs`: ingest, query, tail, and analysis-window payloads. diff --git a/platform/dto/ai_invocation.go b/platform/dto/ai_invocation.go new file mode 100644 index 0000000..774c2fa --- /dev/null +++ b/platform/dto/ai_invocation.go @@ -0,0 +1,108 @@ +package dto + +import "browser.local/platform/domain" + +type AIInvocationRequest struct { + RequestID string `json:"requestId"` + PluginID string `json:"pluginId,omitempty"` + RouteKey string `json:"routeKey,omitempty"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + Purpose string `json:"purpose"` + ProviderID string `json:"providerId,omitempty"` + Model string `json:"model,omitempty"` + Prompt string `json:"prompt"` + CurrentConfig string `json:"currentConfig,omitempty"` + ContextRefs map[string]string `json:"contextRefs,omitempty"` +} + +type LlmConfigSuggestionRequest struct { + ServerInstanceID string `json:"serverInstanceId"` + Prompt string `json:"prompt"` + CurrentConfig string `json:"currentConfig"` +} + +type LlmConfigSuggestionResponse struct { + ServerInstanceID string `json:"serverInstanceId"` + Recommendation string `json:"recommendation"` + SuggestedConfig string `json:"suggestedConfig,omitempty"` +} + +type AIInvocationUsageResponse struct { + ProviderID string `json:"providerId"` + Model string `json:"model"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + Mocked bool `json:"mocked"` +} + +type AIConfigRecommendationResponse struct { + Key string `json:"key"` + SuggestedConfig string `json:"suggestedConfig,omitempty"` + DiffSummary string `json:"diffSummary"` +} + +type AIInvocationSafeErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Details []string `json:"details,omitempty"` +} + +type AIInvocationResponse struct { + RequestID string `json:"requestId"` + Purpose string `json:"purpose"` + ProviderID string `json:"providerId,omitempty"` + Model string `json:"model,omitempty"` + Status string `json:"status"` + Recommendation string `json:"recommendation,omitempty"` + ConfigRecommendation *AIConfigRecommendationResponse `json:"configRecommendation,omitempty"` + Usage AIInvocationUsageResponse `json:"usage"` + Error *AIInvocationSafeErrorResponse `json:"error,omitempty"` +} + +func (request AIInvocationRequest) ToDomain() domain.AIInvocationRequest { + return domain.AIInvocationRequest{ + RequestID: request.RequestID, + PluginID: request.PluginID, + RouteKey: request.RouteKey, + ServerInstanceID: request.ServerInstanceID, + Purpose: request.Purpose, + ProviderID: request.ProviderID, + Model: request.Model, + Prompt: request.Prompt, + CurrentConfig: request.CurrentConfig, + ContextRefs: domain.CopyStringMap(request.ContextRefs), + } +} + +func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationResponse { + response = domain.CopyAIInvocationResponse(response) + var config *AIConfigRecommendationResponse + if response.ConfigRecommendation != nil { + config = &AIConfigRecommendationResponse{ + Key: response.ConfigRecommendation.Key, + SuggestedConfig: response.ConfigRecommendation.SuggestedConfig, + DiffSummary: response.ConfigRecommendation.DiffSummary, + } + } + var safeError *AIInvocationSafeErrorResponse + if response.Error != nil { + safeError = &AIInvocationSafeErrorResponse{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details} + } + return AIInvocationResponse{ + RequestID: response.RequestID, + Purpose: response.Purpose, + ProviderID: response.ProviderID, + Model: response.Model, + Status: response.Status, + Recommendation: response.Recommendation, + ConfigRecommendation: config, + Usage: AIInvocationUsageResponse{ + ProviderID: response.Usage.ProviderID, + Model: response.Usage.Model, + InputTokens: response.Usage.InputTokens, + OutputTokens: response.Usage.OutputTokens, + Mocked: response.Usage.Mocked, + }, + Error: safeError, + } +} diff --git a/platform/dto/artifact_download.go b/platform/dto/artifact_download.go new file mode 100644 index 0000000..415d3e6 --- /dev/null +++ b/platform/dto/artifact_download.go @@ -0,0 +1,88 @@ +package dto + +import ( + "time" + + "browser.local/platform/domain" +) + +type ArtifactDownloadReferenceRequest struct { + ArtifactID string `json:"artifactId"` +} + +type ArtifactDownloadReferenceResponse struct { + ArtifactID string `json:"artifactId"` + OwnerKind domain.ArtifactOwnerKind `json:"ownerKind"` + OwnerID string `json:"ownerId"` + Filename string `json:"filename"` + ContentType string `json:"contentType"` + SizeBytes int64 `json:"sizeBytes"` + Checksum string `json:"checksum"` + State domain.ArtifactState `json:"state"` + DownloadURL string `json:"downloadUrl"` + ExpiresAt time.Time `json:"expiresAt"` + RangeSupported bool `json:"rangeSupported"` + ChunkSizeBytes int `json:"chunkSizeBytes"` + StorageBehavior string `json:"storageBehavior"` +} + +type ArtifactContentRequest struct { + ArtifactID string `json:"artifactId"` + Offset int64 `json:"offset"` + Limit int `json:"limit"` +} + +type ArtifactTransferProgressResponse struct { + ArtifactID string `json:"artifactId"` + BytesRead int64 `json:"bytesRead"` + TotalSizeBytes int64 `json:"totalSizeBytes"` + Complete bool `json:"complete"` +} + +type ArtifactDownloadSafeErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Details []string `json:"details,omitempty"` +} + +func (request ArtifactDownloadReferenceRequest) ToDomain() domain.ArtifactDownloadReferenceRequest { + return domain.ArtifactDownloadReferenceRequest{ArtifactID: request.ArtifactID} +} + +func (request ArtifactContentRequest) ToDomain() domain.ArtifactContentRequest { + return domain.ArtifactContentRequest{ArtifactID: request.ArtifactID, Offset: request.Offset, Limit: request.Limit} +} + +func ArtifactDownloadReferenceFromDomain(reference domain.ArtifactDownloadReference) ArtifactDownloadReferenceResponse { + reference = domain.CopyArtifactDownloadReference(reference) + return ArtifactDownloadReferenceResponse{ + ArtifactID: reference.ArtifactID, + OwnerKind: reference.OwnerKind, + OwnerID: reference.OwnerID, + Filename: reference.Filename, + ContentType: reference.ContentType, + SizeBytes: reference.SizeBytes, + Checksum: reference.Checksum, + State: reference.State, + DownloadURL: reference.DownloadURL, + ExpiresAt: reference.ExpiresAt, + RangeSupported: reference.RangeSupported, + ChunkSizeBytes: reference.ChunkSizeBytes, + StorageBehavior: reference.StorageBehavior, + } +} + +func ArtifactTransferProgressFromDomain(progress domain.ArtifactTransferProgress) ArtifactTransferProgressResponse { + progress = domain.CopyArtifactTransferProgress(progress) + return ArtifactTransferProgressResponse{ + ArtifactID: progress.ArtifactID, + BytesRead: progress.BytesRead, + TotalSizeBytes: progress.TotalSizeBytes, + Complete: progress.Complete, + } +} + +func ArtifactDownloadSafeErrorFromDomain(safeError domain.ArtifactDownloadSafeError) ArtifactDownloadSafeErrorResponse { + safeError = domain.CopyArtifactDownloadSafeError(safeError) + return ArtifactDownloadSafeErrorResponse{Code: safeError.Code, Message: safeError.Message, Details: safeError.Details} +} diff --git a/platform/dto/artifact_transfer.go b/platform/dto/artifact_transfer.go new file mode 100644 index 0000000..11a4329 --- /dev/null +++ b/platform/dto/artifact_transfer.go @@ -0,0 +1,201 @@ +package dto + +import ( + "time" + + "browser.local/platform/domain" +) + +type ArtifactTransferOpenRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + ArtifactID string `json:"artifactId"` + Direction domain.ArtifactTransferDirection `json:"direction"` + OwnerKind domain.ArtifactOwnerKind `json:"ownerKind"` + OwnerID string `json:"ownerId"` + SizeBytes int64 `json:"sizeBytes"` + ChunkSizeBytes int `json:"chunkSizeBytes"` + Checksum string `json:"checksum"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type ArtifactTransferOpenResponse struct { + Accepted bool `json:"accepted"` + TransferID string `json:"transferId"` + Direction domain.ArtifactTransferDirection `json:"direction"` + Artifact ArtifactResponse `json:"artifact"` + TotalChunks int `json:"totalChunks"` + ChunkSizeBytes int `json:"chunkSizeBytes"` + ReceivedChunkIndexes []int `json:"receivedChunkIndexes"` + NextMissingChunkIndex int `json:"nextMissingChunkIndex"` + Completed bool `json:"completed"` + Duplicate bool `json:"duplicate"` + ServerTime time.Time `json:"serverTime"` +} + +type ArtifactChunkUploadRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + TransferID string `json:"transferId"` + ArtifactID string `json:"artifactId"` + ChunkIndex int `json:"chunkIndex"` + Offset int64 `json:"offset"` + SizeBytes int `json:"sizeBytes"` + Checksum string `json:"checksum"` + Payload []byte `json:"payload"` +} + +type ArtifactChunkUploadResponse struct { + Accepted bool `json:"accepted"` + TransferID string `json:"transferId"` + ArtifactID string `json:"artifactId"` + ChunkIndex int `json:"chunkIndex"` + ReceivedChunkIndexes []int `json:"receivedChunkIndexes"` + NextMissingChunkIndex int `json:"nextMissingChunkIndex"` + Duplicate bool `json:"duplicate"` + ServerTime time.Time `json:"serverTime"` +} + +type ArtifactTransferStatusRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + TransferID string `json:"transferId"` + ArtifactID string `json:"artifactId"` +} + +type ArtifactTransferStatusResponse struct { + Accepted bool `json:"accepted"` + TransferID string `json:"transferId"` + ArtifactID string `json:"artifactId"` + Direction domain.ArtifactTransferDirection `json:"direction"` + TotalChunks int `json:"totalChunks"` + ChunkSizeBytes int `json:"chunkSizeBytes"` + ReceivedChunkIndexes []int `json:"receivedChunkIndexes"` + NextMissingChunkIndex int `json:"nextMissingChunkIndex"` + Completed bool `json:"completed"` + ServerTime time.Time `json:"serverTime"` +} + +type ArtifactTransferCompleteRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + TransferID string `json:"transferId"` + ArtifactID string `json:"artifactId"` + Checksum string `json:"checksum"` + SizeBytes int64 `json:"sizeBytes"` +} + +type ArtifactTransferCompleteResponse struct { + Accepted bool `json:"accepted"` + TransferID string `json:"transferId"` + Artifact ArtifactResponse `json:"artifact"` + Completed bool `json:"completed"` + ServerTime time.Time `json:"serverTime"` +} + +func (request ArtifactTransferOpenRequest) ToDomain() domain.ArtifactTransferOpen { + return domain.ArtifactTransferOpen{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + ArtifactID: request.ArtifactID, + Direction: request.Direction, + OwnerKind: request.OwnerKind, + OwnerID: request.OwnerID, + SizeBytes: request.SizeBytes, + ChunkSizeBytes: request.ChunkSizeBytes, + Checksum: request.Checksum, + IdempotencyKey: request.IdempotencyKey, + } +} + +func (request ArtifactChunkUploadRequest) ToDomain() domain.ArtifactChunkUpload { + return domain.ArtifactChunkUpload{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + TransferID: request.TransferID, + ArtifactID: request.ArtifactID, + ChunkIndex: request.ChunkIndex, + Offset: request.Offset, + SizeBytes: request.SizeBytes, + Checksum: request.Checksum, + Payload: domain.CopyBytes(request.Payload), + } +} + +func (request ArtifactTransferStatusRequest) ToDomain() domain.ArtifactTransferStatusQuery { + return domain.ArtifactTransferStatusQuery{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + TransferID: request.TransferID, + ArtifactID: request.ArtifactID, + } +} + +func (request ArtifactTransferCompleteRequest) ToDomain() domain.ArtifactTransferComplete { + return domain.ArtifactTransferComplete{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + TransferID: request.TransferID, + ArtifactID: request.ArtifactID, + Checksum: request.Checksum, + SizeBytes: request.SizeBytes, + } +} + +func ArtifactTransferOpenFromDomain(result domain.ArtifactTransferOpenResult) ArtifactTransferOpenResponse { + result = domain.CopyArtifactTransferOpenResult(result) + return ArtifactTransferOpenResponse{ + Accepted: result.Accepted, + TransferID: result.TransferID, + Direction: result.Direction, + Artifact: ArtifactFromDomain(result.Artifact), + TotalChunks: result.TotalChunks, + ChunkSizeBytes: result.ChunkSizeBytes, + ReceivedChunkIndexes: result.ReceivedChunkIndexes, + NextMissingChunkIndex: result.NextMissingChunkIndex, + Completed: result.Completed, + Duplicate: result.Duplicate, + ServerTime: result.ServerTime, + } +} + +func ArtifactChunkUploadFromDomain(result domain.ArtifactChunkUploadResult) ArtifactChunkUploadResponse { + result = domain.CopyArtifactChunkUploadResult(result) + return ArtifactChunkUploadResponse{ + Accepted: result.Accepted, + TransferID: result.TransferID, + ArtifactID: result.ArtifactID, + ChunkIndex: result.ChunkIndex, + ReceivedChunkIndexes: result.ReceivedChunkIndexes, + NextMissingChunkIndex: result.NextMissingChunkIndex, + Duplicate: result.Duplicate, + ServerTime: result.ServerTime, + } +} + +func ArtifactTransferStatusFromDomain(result domain.ArtifactTransferStatusResult) ArtifactTransferStatusResponse { + result = domain.CopyArtifactTransferStatusResult(result) + return ArtifactTransferStatusResponse{ + Accepted: result.Accepted, + TransferID: result.TransferID, + ArtifactID: result.ArtifactID, + Direction: result.Direction, + TotalChunks: result.TotalChunks, + ChunkSizeBytes: result.ChunkSizeBytes, + ReceivedChunkIndexes: result.ReceivedChunkIndexes, + NextMissingChunkIndex: result.NextMissingChunkIndex, + Completed: result.Completed, + ServerTime: result.ServerTime, + } +} + +func ArtifactTransferCompleteFromDomain(result domain.ArtifactTransferCompleteResult) ArtifactTransferCompleteResponse { + result = domain.CopyArtifactTransferCompleteResult(result) + return ArtifactTransferCompleteResponse{ + Accepted: result.Accepted, + TransferID: result.TransferID, + Artifact: ArtifactFromDomain(result.Artifact), + Completed: result.Completed, + ServerTime: result.ServerTime, + } +} diff --git a/platform/dto/control.go b/platform/dto/control.go new file mode 100644 index 0000000..d4544f6 --- /dev/null +++ b/platform/dto/control.go @@ -0,0 +1,98 @@ +package dto + +import ( + "time" + + "browser.local/platform/domain" +) + +type RunCapabilityReport struct { + Capabilities []string `json:"capabilities"` + Fingerprint string `json:"fingerprint"` +} + +type RunControlHelloRequest struct { + RegistrationToken string `json:"registrationToken"` + RunEndpointID string `json:"runEndpointId"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Status domain.RunEndpointStatus `json:"status"` + Platform string `json:"platform,omitempty"` + CapabilityReport RunCapabilityReport `json:"capabilityReport"` + Capacity RunCapacityResponse `json:"capacity"` +} + +type RunControlHelloResponse struct { + Accepted bool `json:"accepted"` + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + ServerTime time.Time `json:"serverTime"` + HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"` + FeatureFlags []string `json:"featureFlags,omitempty"` +} + +type RunControlHeartbeatRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + Version string `json:"version"` + Status domain.RunEndpointStatus `json:"status"` + CapabilityFingerprint string `json:"capabilityFingerprint"` + Capacity RunCapacityResponse `json:"capacity"` +} + +type RunControlHeartbeatResponse struct { + Accepted bool `json:"accepted"` + RunEndpointID string `json:"runEndpointId"` + NextHeartbeatSeconds int `json:"nextHeartbeatSeconds"` + RefreshCapabilities bool `json:"refreshCapabilities"` + ServerTime time.Time `json:"serverTime"` +} + +func (request RunControlHelloRequest) ToDomain() domain.RunControlHello { + return domain.RunControlHello{ + RegistrationToken: request.RegistrationToken, + RunEndpointID: request.RunEndpointID, + DisplayName: request.DisplayName, + Version: request.Version, + Status: request.Status, + Platform: request.Platform, + CapabilityReport: domain.RunCapabilityReport{ + Capabilities: domain.CopyStringSlice(request.CapabilityReport.Capabilities), + Fingerprint: request.CapabilityReport.Fingerprint, + }, + Capacity: capacityToDomain(request.Capacity), + } +} + +func (request RunControlHeartbeatRequest) ToDomain() domain.RunControlHeartbeat { + return domain.RunControlHeartbeat{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + Version: request.Version, + Status: request.Status, + CapabilityFingerprint: request.CapabilityFingerprint, + Capacity: capacityToDomain(request.Capacity), + } +} + +func RunControlHelloFromDomain(result domain.RunControlHelloResult) RunControlHelloResponse { + result = domain.CopyRunControlHelloResult(result) + return RunControlHelloResponse{ + Accepted: result.Accepted, + RunEndpointID: result.RunEndpointID, + SessionToken: result.SessionToken, + ServerTime: result.ServerTime, + HeartbeatIntervalSeconds: result.HeartbeatIntervalSeconds, + FeatureFlags: result.FeatureFlags, + } +} + +func RunControlHeartbeatFromDomain(result domain.RunControlHeartbeatResult) RunControlHeartbeatResponse { + return RunControlHeartbeatResponse{ + Accepted: result.Accepted, + RunEndpointID: result.RunEndpointID, + NextHeartbeatSeconds: result.NextHeartbeatSeconds, + RefreshCapabilities: result.RefreshCapabilities, + ServerTime: result.ServerTime, + } +} diff --git a/platform/dto/health.go b/platform/dto/health.go new file mode 100644 index 0000000..52f96d6 --- /dev/null +++ b/platform/dto/health.go @@ -0,0 +1,8 @@ +package dto + +type HealthResponse struct { + Service string `json:"service"` + Status string `json:"status"` + Version string `json:"version"` + Time string `json:"time"` +} diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go new file mode 100644 index 0000000..762e05c --- /dev/null +++ b/platform/dto/job_channel.go @@ -0,0 +1,317 @@ +package dto + +import ( + "time" + + "browser.local/platform/domain" +) + +type RunJobAssignmentResponse struct { + JobID string `json:"jobId"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + RunEndpointID string `json:"runEndpointId"` + Capability string `json:"capability"` + TargetKey string `json:"targetKey,omitempty"` + InputRef string `json:"inputRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` + State domain.JobState `json:"state"` + Progress JobProgressBody `json:"progress"` + ResultRef string `json:"resultRef,omitempty"` + LeaseToken string `json:"leaseToken"` + Attempt int `json:"attempt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type RunJobClaimRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + Capabilities []string `json:"capabilities"` + Capacity RunCapacityResponse `json:"capacity"` +} + +type RunJobClaimResponse struct { + Accepted bool `json:"accepted"` + RunEndpointID string `json:"runEndpointId"` + HasJob bool `json:"hasJob"` + Job *RunJobAssignmentResponse `json:"job,omitempty"` + NextPollSeconds int `json:"nextPollSeconds"` + ServerTime time.Time `json:"serverTime"` +} + +type RunJobAckRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + JobID string `json:"jobId"` + LeaseToken string `json:"leaseToken"` + Attempt int `json:"attempt"` + Message string `json:"message,omitempty"` +} + +type RunJobAckResponse struct { + Accepted bool `json:"accepted"` + Job RunJobAssignmentResponse `json:"job"` + ServerTime time.Time `json:"serverTime"` +} + +type RunJobProgressRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + JobID string `json:"jobId"` + LeaseToken string `json:"leaseToken"` + Attempt int `json:"attempt"` + Progress JobProgressBody `json:"progress"` + Sequence uint64 `json:"sequence,omitempty"` +} + +type RunJobProgressResponse struct { + Accepted bool `json:"accepted"` + Job RunJobAssignmentResponse `json:"job"` + ServerTime time.Time `json:"serverTime"` +} + +type RunJobResultRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + JobID string `json:"jobId"` + LeaseToken string `json:"leaseToken"` + Attempt int `json:"attempt"` + State domain.JobState `json:"state"` + Progress JobProgressBody `json:"progress"` + ResultRef string `json:"resultRef,omitempty"` + Message string `json:"message,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` +} + +type RunJobResultResponse struct { + Accepted bool `json:"accepted"` + Job RunJobAssignmentResponse `json:"job"` + ServerTime time.Time `json:"serverTime"` +} + +type RunJobCancelRequestBody struct { + JobID string `json:"jobId"` + Reason string `json:"reason"` +} + +type RunJobCancelRequestResponse struct { + Accepted bool `json:"accepted"` + JobID string `json:"jobId"` + Reason string `json:"reason"` + RequestedAt time.Time `json:"requestedAt"` +} + +type RunJobCancelPollRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + JobID string `json:"jobId,omitempty"` + LeaseToken string `json:"leaseToken,omitempty"` +} + +type RunJobCancelPollResponse struct { + Accepted bool `json:"accepted"` + RunEndpointID string `json:"runEndpointId"` + HasCancel bool `json:"hasCancel"` + JobID string `json:"jobId,omitempty"` + Reason string `json:"reason,omitempty"` + RequestedAt time.Time `json:"requestedAt,omitempty"` + ServerTime time.Time `json:"serverTime"` +} + +type RunJobReconcileRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + ActiveJobIDs []string `json:"activeJobIds"` +} + +type RunJobReconcileResponse struct { + Accepted bool `json:"accepted"` + RunEndpointID string `json:"runEndpointId"` + ActiveJobs []RunJobAssignmentResponse `json:"activeJobs"` + UnknownJobIDs []string `json:"unknownJobIds"` + ServerTime time.Time `json:"serverTime"` +} + +func (request RunJobClaimRequest) ToDomain() domain.RunJobClaim { + return domain.RunJobClaim{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + Capabilities: domain.CopyStringSlice(request.Capabilities), + Capacity: capacityToDomain(request.Capacity), + } +} + +func (request RunJobAckRequest) ToDomain() domain.RunJobAck { + return domain.RunJobAck{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + JobID: request.JobID, + LeaseToken: request.LeaseToken, + Attempt: request.Attempt, + Message: request.Message, + } +} + +func (request RunJobProgressRequest) ToDomain() domain.RunJobProgress { + return domain.RunJobProgress{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + JobID: request.JobID, + LeaseToken: request.LeaseToken, + Attempt: request.Attempt, + Progress: progressReportToDomain(request.Progress), + Sequence: request.Sequence, + } +} + +func (request RunJobResultRequest) ToDomain() domain.RunJobResult { + return domain.RunJobResult{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + JobID: request.JobID, + LeaseToken: request.LeaseToken, + Attempt: request.Attempt, + State: request.State, + Progress: progressReportToDomain(request.Progress), + ResultRef: request.ResultRef, + Message: request.Message, + ErrorCode: request.ErrorCode, + } +} + +func (request RunJobCancelRequestBody) ToDomain() domain.RunJobCancelRequest { + return domain.RunJobCancelRequest{ + JobID: request.JobID, + Reason: request.Reason, + } +} + +func (request RunJobCancelPollRequest) ToDomain() domain.RunJobCancelPoll { + return domain.RunJobCancelPoll{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + JobID: request.JobID, + LeaseToken: request.LeaseToken, + } +} + +func (request RunJobReconcileRequest) ToDomain() domain.RunJobReconcile { + return domain.RunJobReconcile{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + ActiveJobIDs: domain.CopyStringSlice(request.ActiveJobIDs), + } +} + +func RunJobClaimFromDomain(result domain.RunJobClaimResult) RunJobClaimResponse { + result = domain.CopyRunJobClaimResult(result) + return RunJobClaimResponse{ + Accepted: result.Accepted, + RunEndpointID: result.RunEndpointID, + HasJob: result.HasJob, + Job: RunJobAssignmentPtrFromDomain(result.Job), + NextPollSeconds: result.NextPollSeconds, + ServerTime: result.ServerTime, + } +} + +func RunJobAckFromDomain(result domain.RunJobAckResult) RunJobAckResponse { + return RunJobAckResponse{ + Accepted: result.Accepted, + Job: RunJobAssignmentFromDomain(result.Job), + ServerTime: result.ServerTime, + } +} + +func RunJobProgressFromDomain(result domain.RunJobProgressResult) RunJobProgressResponse { + return RunJobProgressResponse{ + Accepted: result.Accepted, + Job: RunJobAssignmentFromDomain(result.Job), + ServerTime: result.ServerTime, + } +} + +func RunJobResultFromDomain(result domain.RunJobResultResult) RunJobResultResponse { + return RunJobResultResponse{ + Accepted: result.Accepted, + Job: RunJobAssignmentFromDomain(result.Job), + ServerTime: result.ServerTime, + } +} + +func RunJobCancelRequestFromDomain(result domain.RunJobCancelRequestResult) RunJobCancelRequestResponse { + return RunJobCancelRequestResponse{ + Accepted: result.Accepted, + JobID: result.JobID, + Reason: result.Reason, + RequestedAt: result.RequestedAt, + } +} + +func RunJobCancelPollFromDomain(result domain.RunJobCancelPollResult) RunJobCancelPollResponse { + return RunJobCancelPollResponse{ + Accepted: result.Accepted, + RunEndpointID: result.RunEndpointID, + HasCancel: result.HasCancel, + JobID: result.JobID, + Reason: result.Reason, + RequestedAt: result.RequestedAt, + ServerTime: result.ServerTime, + } +} + +func RunJobReconcileFromDomain(result domain.RunJobReconcileResult) RunJobReconcileResponse { + result = domain.CopyRunJobReconcileResult(result) + items := make([]RunJobAssignmentResponse, len(result.ActiveJobs)) + for i, assignment := range result.ActiveJobs { + items[i] = RunJobAssignmentFromDomain(assignment) + } + return RunJobReconcileResponse{ + Accepted: result.Accepted, + RunEndpointID: result.RunEndpointID, + ActiveJobs: items, + UnknownJobIDs: result.UnknownJobIDs, + ServerTime: result.ServerTime, + } +} + +func RunJobAssignmentPtrFromDomain(assignment *domain.RunJobAssignment) *RunJobAssignmentResponse { + if assignment == nil { + return nil + } + response := RunJobAssignmentFromDomain(*assignment) + return &response +} + +func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssignmentResponse { + return RunJobAssignmentResponse{ + JobID: assignment.JobID, + ServerInstanceID: assignment.ServerInstanceID, + RunEndpointID: assignment.RunEndpointID, + Capability: assignment.Capability, + TargetKey: assignment.TargetKey, + InputRef: assignment.InputRef, + IdempotencyKey: assignment.IdempotencyKey, + State: assignment.State, + Progress: progressReportFromDomain(assignment.Progress), + ResultRef: assignment.ResultRef, + LeaseToken: assignment.LeaseToken, + Attempt: assignment.Attempt, + CreatedAt: assignment.CreatedAt, + UpdatedAt: assignment.UpdatedAt, + } +} + +func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport { + return domain.RunJobProgressReport{ + Percent: progress.Percent, + Message: progress.Message, + } +} + +func progressReportFromDomain(progress domain.RunJobProgressReport) JobProgressBody { + return JobProgressBody{ + Percent: progress.Percent, + Message: progress.Message, + } +} diff --git a/platform/dto/log_ingest.go b/platform/dto/log_ingest.go new file mode 100644 index 0000000..2712843 --- /dev/null +++ b/platform/dto/log_ingest.go @@ -0,0 +1,147 @@ +package dto + +import ( + "time" + + "browser.local/platform/domain" +) + +type LogEntryBody struct { + Seq uint64 `json:"seq"` + Timestamp time.Time `json:"timestamp"` + Level string `json:"level,omitempty"` + Line string `json:"line"` + Fields map[string]string `json:"fields,omitempty"` + Redacted bool `json:"redacted"` +} + +type LogBatchIngestRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + LogStreamID string `json:"logStreamId"` + ServerInstanceID string `json:"serverInstanceId"` + StreamKey string `json:"streamKey"` + Source domain.LogStreamSource `json:"source"` + FirstSeq uint64 `json:"firstSeq"` + LastSeq uint64 `json:"lastSeq"` + Compression string `json:"compression"` + Checksum string `json:"checksum"` + Entries []LogEntryBody `json:"entries"` +} + +type LogBatchIngestResponse struct { + Accepted bool `json:"accepted"` + LogStreamID string `json:"logStreamId"` + AcceptedFrom uint64 `json:"acceptedFrom"` + AcceptedTo uint64 `json:"acceptedTo"` + LatestSeq uint64 `json:"latestSeq"` + Duplicate bool `json:"duplicate"` + RetryAfterSec int `json:"retryAfterSec,omitempty"` + ServerTime time.Time `json:"serverTime"` +} + +type LogStreamCursorRequest struct { + LogStreamID string `json:"logStreamId"` + AfterSeq uint64 `json:"afterSeq"` + Limit int `json:"limit"` +} + +type LogStreamCursorResponse struct { + LogStreamID string `json:"logStreamId"` + Entries []LogEntryBody `json:"entries"` + NextSeq uint64 `json:"nextSeq"` + LatestSeq uint64 `json:"latestSeq"` +} + +func (request LogBatchIngestRequest) ToDomain() domain.LogBatchIngest { + return domain.LogBatchIngest{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + LogStreamID: request.LogStreamID, + ServerInstanceID: request.ServerInstanceID, + StreamKey: request.StreamKey, + Source: request.Source, + FirstSeq: request.FirstSeq, + LastSeq: request.LastSeq, + Compression: request.Compression, + Checksum: request.Checksum, + Entries: logEntriesToDomain(request.Entries), + } +} + +func (request LogStreamCursorRequest) ToDomain() domain.LogStreamCursorQuery { + return domain.LogStreamCursorQuery{ + LogStreamID: request.LogStreamID, + AfterSeq: request.AfterSeq, + Limit: request.Limit, + } +} + +func LogBatchIngestFromDomain(result domain.LogBatchIngestResult) LogBatchIngestResponse { + return LogBatchIngestResponse{ + Accepted: result.Accepted, + LogStreamID: result.LogStreamID, + AcceptedFrom: result.AcceptedFrom, + AcceptedTo: result.AcceptedTo, + LatestSeq: result.LatestSeq, + Duplicate: result.Duplicate, + ServerTime: result.ServerTime, + } +} + +func LogStreamCursorFromDomain(result domain.LogStreamCursorResult) LogStreamCursorResponse { + result = domain.CopyLogStreamCursorResult(result) + return LogStreamCursorResponse{ + LogStreamID: result.LogStreamID, + Entries: logEntriesFromDomain(result.Entries), + NextSeq: result.NextSeq, + LatestSeq: result.LatestSeq, + } +} + +func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry { + if entries == nil { + return nil + } + out := make([]domain.LogEntry, len(entries)) + for i, entry := range entries { + out[i] = domain.LogEntry{ + Seq: entry.Seq, + Timestamp: entry.Timestamp, + Level: entry.Level, + Line: entry.Line, + Fields: copyStringMap(entry.Fields), + Redacted: entry.Redacted, + } + } + return out +} + +func logEntriesFromDomain(entries []domain.LogEntry) []LogEntryBody { + if entries == nil { + return nil + } + out := make([]LogEntryBody, len(entries)) + for i, entry := range entries { + out[i] = LogEntryBody{ + Seq: entry.Seq, + Timestamp: entry.Timestamp, + Level: entry.Level, + Line: entry.Line, + Fields: copyStringMap(entry.Fields), + Redacted: entry.Redacted, + } + } + return out +} + +func copyStringMap(values map[string]string) map[string]string { + if values == nil { + return nil + } + out := make(map[string]string, len(values)) + for key, value := range values { + out[key] = value + } + return out +} diff --git a/platform/dto/resources.go b/platform/dto/resources.go new file mode 100644 index 0000000..07ccf84 --- /dev/null +++ b/platform/dto/resources.go @@ -0,0 +1,1498 @@ +package dto + +import ( + "time" + + "browser.local/platform/domain" +) + +type UserCreateRequest struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Email string `json:"email,omitempty"` + Roles []string `json:"roles,omitempty"` + Status domain.UserStatus `json:"status,omitempty"` + Password string `json:"password,omitempty"` + Profile UserProfileBody `json:"profile,omitempty"` +} + +type UserUpdateRequest struct { + DisplayName *string `json:"displayName,omitempty"` + Email *string `json:"email,omitempty"` + Status *domain.UserStatus `json:"status,omitempty"` + Roles []string `json:"roles,omitempty"` + Profile *UserProfileBody `json:"profile,omitempty"` +} + +type UserProfileBody struct { + DisplayName string `json:"displayName,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + Phone string `json:"phone,omitempty"` + QQ string `json:"qq,omitempty"` + ContactNote string `json:"contactNote,omitempty"` +} + +type UserThemePreferenceRequest struct { + PaletteID string `json:"paletteId"` + BackgroundPresetID string `json:"backgroundPresetId"` + BackgroundImage *string `json:"backgroundImage,omitempty"` +} + +type UserThemePreferenceResponse struct { + UserID string `json:"userId"` + PaletteID string `json:"paletteId"` + BackgroundPresetID string `json:"backgroundPresetId"` + BackgroundImage string `json:"backgroundImage,omitempty"` + Persistence string `json:"persistence"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserResponse struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Email string `json:"email,omitempty"` + Status domain.UserStatus `json:"status"` + Roles []string `json:"roles"` + Profile UserProfileBody `json:"profile,omitempty"` + ThemePreference *UserThemePreferenceResponse `json:"themePreference,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserListResponse struct { + Items []UserResponse `json:"items"` + Count int `json:"count"` +} + +type CurrentUserResponse struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Email string `json:"email,omitempty"` + Status domain.UserStatus `json:"status,omitempty"` + Roles []string `json:"roles"` + Capabilities []string `json:"capabilities,omitempty"` + Profile UserProfileBody `json:"profile,omitempty"` + ThemePreference *UserThemePreferenceResponse `json:"themePreference,omitempty"` +} + +type LoginRequest struct { + Account string `json:"account"` + Password string `json:"password"` +} + +type RegisterRequest struct { + DisplayName string `json:"displayName"` + Email string `json:"email"` + Password string `json:"password"` + Phone string `json:"phone,omitempty"` + QQ string `json:"qq,omitempty"` +} + +type AuthSessionResponse struct { + User CurrentUserResponse `json:"user"` + SessionID string `json:"sessionId,omitempty"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +type AIProviderCreateRequest struct { + ID string `json:"id"` + Name string `json:"name"` + Kind domain.AIProviderKind `json:"kind"` + BaseURL string `json:"baseUrl"` + APIKeyRef string `json:"apiKeyRef"` + Models []string `json:"models"` + DefaultModel string `json:"defaultModel,omitempty"` + RelayMode domain.AIRelayMode `json:"relayMode"` + TimeoutMS int `json:"timeoutMs"` + RedactionPolicy string `json:"redactionPolicy"` +} + +type AIProviderUpdateRequest struct { + Name string `json:"name"` + Kind domain.AIProviderKind `json:"kind"` + BaseURL string `json:"baseUrl"` + APIKeyRef string `json:"apiKeyRef"` + Models []string `json:"models"` + DefaultModel string `json:"defaultModel,omitempty"` + RelayMode domain.AIRelayMode `json:"relayMode"` + TimeoutMS int `json:"timeoutMs"` + RedactionPolicy string `json:"redactionPolicy"` +} + +type AIProviderStatusRequest struct { + Status domain.AIProviderStatus `json:"status"` +} + +type AIProviderResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Kind domain.AIProviderKind `json:"kind"` + BaseURL string `json:"baseUrl"` + APIKeyRef string `json:"apiKeyRef"` + Models []string `json:"models"` + DefaultModel string `json:"defaultModel,omitempty"` + RelayMode domain.AIRelayMode `json:"relayMode"` + TimeoutMS int `json:"timeoutMs"` + Status domain.AIProviderStatus `json:"status"` + RedactionPolicy string `json:"redactionPolicy"` +} + +type AIProviderListResponse struct { + Items []AIProviderResponse `json:"items"` + Count int `json:"count"` +} + +type AIProviderTestResponse struct { + ProviderID string `json:"providerId"` + Mode string `json:"mode"` + Success bool `json:"success"` + Message string `json:"message"` + Violations []string `json:"violations,omitempty"` +} + +type AIProviderModelsResponse struct { + ProviderID string `json:"providerId"` + DefaultModel string `json:"defaultModel,omitempty"` + Models []string `json:"models"` +} + +type PluginPermissionsResponse struct { + AI bool `json:"ai"` + Logs bool `json:"logs"` + Files bool `json:"files"` + Jobs bool `json:"jobs"` + Artifacts bool `json:"artifacts"` +} + +type PluginLifecycleActionsBody struct { + Install string `json:"install"` + Start string `json:"start"` + Stop string `json:"stop"` + Restart string `json:"restart,omitempty"` + Status string `json:"status,omitempty"` +} + +type GamePluginPageBody struct { + Key string `json:"key"` + Title string `json:"title"` + Path string `json:"path"` + Permissions []string `json:"permissions,omitempty"` + BridgeActions []string `json:"bridgeActions,omitempty"` +} + +type GamePluginBridgeBody struct { + Actions []string `json:"actions"` +} + +type GamePluginManifestServerBody struct { + Type string `json:"type"` + DisplayName string `json:"displayName"` + SupportedOS []string `json:"supportedOs,omitempty"` + CreateFormSchema string `json:"createFormSchema"` +} + +type GamePluginManifestAIBody struct { + Purposes []string `json:"purposes,omitempty"` +} + +type GamePluginManifestBody struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + Kind string `json:"kind"` + Tags []string `json:"tags,omitempty"` + Server GamePluginManifestServerBody `json:"server"` + Bridge GamePluginBridgeBody `json:"bridge,omitempty"` + Capabilities []string `json:"capabilities"` + Permissions []string `json:"permissions"` + Actions PluginLifecycleActionsBody `json:"actions"` + Pages []GamePluginPageBody `json:"pages,omitempty"` + AI GamePluginManifestAIBody `json:"ai,omitempty"` +} + +type GamePluginManifestRegistrationRequest struct { + ManifestRef string `json:"manifestRef"` + Manifest GamePluginManifestBody `json:"manifest"` +} + +type GamePluginCreateRequest struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + ServerType string `json:"serverType"` + ServerDisplayName string `json:"serverDisplayName,omitempty"` + SupportedOS []string `json:"supportedOs,omitempty"` + ManifestRef string `json:"manifestRef"` + CreateFormSchemaRef string `json:"createFormSchemaRef"` + RequiredRunCapabilities []string `json:"requiredRunCapabilities"` + DeclaredPermissions []string `json:"declaredPermissions,omitempty"` + Permissions PluginPermissionsResponse `json:"permissions"` + LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"` + BridgeActions []string `json:"bridgeActions,omitempty"` + Pages []GamePluginPageBody `json:"pages,omitempty"` + Tags []string `json:"tags,omitempty"` + AIPurposes []string `json:"aiPurposes,omitempty"` + ValidationViolations []string `json:"validationViolations,omitempty"` +} + +type GamePluginResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + ServerType string `json:"serverType"` + ServerDisplayName string `json:"serverDisplayName,omitempty"` + SupportedOS []string `json:"supportedOs,omitempty"` + ManifestRef string `json:"manifestRef"` + CreateFormSchemaRef string `json:"createFormSchemaRef"` + RequiredRunCapabilities []string `json:"requiredRunCapabilities"` + DeclaredPermissions []string `json:"declaredPermissions"` + Permissions PluginPermissionsResponse `json:"permissions"` + LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"` + BridgeActions []string `json:"bridgeActions"` + Pages []GamePluginPageBody `json:"pages"` + Tags []string `json:"tags"` + AIPurposes []string `json:"aiPurposes"` + ValidationViolations []string `json:"validationViolations,omitempty"` + Status domain.GamePluginStatus `json:"status"` +} + +type GamePluginListResponse struct { + Items []GamePluginResponse `json:"items"` + Count int `json:"count"` +} + +type MarketplacePluginResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + ServerType string `json:"serverType"` + ServerDisplayName string `json:"serverDisplayName,omitempty"` + SupportedOS []string `json:"supportedOs,omitempty"` + ManifestRef string `json:"manifestRef"` + CreateFormSchemaRef string `json:"createFormSchemaRef"` + Capabilities []string `json:"capabilities"` + DeclaredPermissions []string `json:"declaredPermissions"` + Permissions PluginPermissionsResponse `json:"permissions"` + LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"` + BridgeActions []string `json:"bridgeActions"` + Pages []GamePluginPageBody `json:"pages"` + Tags []string `json:"tags"` + AIPurposes []string `json:"aiPurposes"` + ValidationViolations []string `json:"validationViolations,omitempty"` + Status domain.GamePluginStatus `json:"status"` + Source string `json:"source"` +} + +type MarketplacePluginListResponse struct { + Items []MarketplacePluginResponse `json:"items"` + Count int `json:"count"` +} + +type MarketplacePluginStateRequest struct { + Action domain.PluginMarketplaceStateAction `json:"action"` +} + +type PluginBridgeAuthorizeRequest struct { + PluginID string `json:"pluginId"` + RouteKey string `json:"routeKey"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + Action string `json:"action"` + AIPurpose string `json:"aiPurpose,omitempty"` +} + +type PluginBridgeAuthorizeResponse struct { + PluginID string `json:"pluginId"` + RouteKey string `json:"routeKey"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + Action string `json:"action"` + Allowed bool `json:"allowed"` + RequiredPermissions []string `json:"requiredPermissions"` + EffectivePermissions []string `json:"effectivePermissions"` + Reason string `json:"reason,omitempty"` +} + +type PluginBridgeExecuteRequest struct { + RequestID string `json:"requestId"` + PluginID string `json:"pluginId"` + RouteKey string `json:"routeKey"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + Action string `json:"action"` + AIPurpose string `json:"aiPurpose,omitempty"` + Payload map[string]string `json:"payload,omitempty"` +} + +type PluginBridgeSafeErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Details []string `json:"details,omitempty"` +} + +type PluginBridgeExecuteResponse struct { + RequestID string `json:"requestId"` + PluginID string `json:"pluginId"` + RouteKey string `json:"routeKey"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + Action string `json:"action"` + Status string `json:"status"` + Result map[string]string `json:"result,omitempty"` + Error *PluginBridgeSafeErrorResponse `json:"error,omitempty"` +} + +type ServerInstanceCreateRequest struct { + ID string `json:"id"` + PluginID string `json:"pluginId"` + RunEndpointID string `json:"runEndpointId"` + Name string `json:"name"` + OwnerUserID string `json:"ownerUserId,omitempty"` + AdminUserIDs []string `json:"adminUserIds,omitempty"` + State domain.ServerInstanceState `json:"state,omitempty"` +} + +type ServerMemberRequest struct { + UserID string `json:"userId"` +} + +type ServerMemberResponse struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Email string `json:"email,omitempty"` + Status domain.UserStatus `json:"status"` + Roles []string `json:"roles"` + Profile UserProfileBody `json:"profile,omitempty"` +} + +type ServerMemberListResponse struct { + Items []ServerMemberResponse `json:"items"` + Count int `json:"count"` +} + +type ServerInstanceResponse struct { + ID string `json:"id"` + PluginID string `json:"pluginId"` + PluginVersion string `json:"pluginVersion"` + RunEndpointID string `json:"runEndpointId"` + Name string `json:"name"` + OwnerUserID string `json:"ownerUserId,omitempty"` + AdminUserIDs []string `json:"adminUserIds"` + State domain.ServerInstanceState `json:"state"` + ConfigVersion int `json:"configVersion"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ServerInstanceListResponse struct { + Items []ServerInstanceResponse `json:"items"` + Count int `json:"count"` +} + +type PlatformResourceUsageResponse struct { + CPUPercent float64 `json:"cpuPercent"` + MemoryPercent float64 `json:"memoryPercent"` + DiskPercent float64 `json:"diskPercent"` + Source string `json:"source,omitempty"` + CollectedAt time.Time `json:"collectedAt"` +} + +type ServerMetricsResponse struct { + ServerInstanceID string `json:"serverInstanceId"` + Online bool `json:"online"` + PlayerCount *int `json:"playerCount,omitempty"` + MaxPlayers *int `json:"maxPlayers,omitempty"` + TPS *float64 `json:"tps,omitempty"` + LatencyMS *float64 `json:"latencyMs,omitempty"` + CPUPercent *float64 `json:"cpuPercent,omitempty"` + MemoryPercent *float64 `json:"memoryPercent,omitempty"` + DiskPercent *float64 `json:"diskPercent,omitempty"` + Source string `json:"source,omitempty"` + CollectedAt time.Time `json:"collectedAt"` +} + +type ServerMetricsListResponse struct { + Items []ServerMetricsResponse `json:"items"` + Count int `json:"count"` +} + +type ServerConfigResponse struct { + ServerInstanceID string `json:"serverInstanceId"` + ConfigVersion int `json:"configVersion"` + Format string `json:"format"` + Key string `json:"key,omitempty"` + Content string `json:"content"` + Source string `json:"source,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ConfigDiffLineResponse struct { + Kind string `json:"kind"` + OldNumber int `json:"oldNumber,omitempty"` + NewNumber int `json:"newNumber,omitempty"` + Content string `json:"content"` +} + +type ServerConfigDiffPreviewRequest struct { + ExpectedConfigVersion int `json:"expectedConfigVersion"` + Key string `json:"key"` + ProposedContent string `json:"proposedContent,omitempty"` + ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"` +} + +type ServerConfigDiffPreviewResponse struct { + ServerInstanceID string `json:"serverInstanceId"` + ConfigVersion int `json:"configVersion"` + Key string `json:"key"` + CurrentContent string `json:"currentContent"` + ProposedContent string `json:"proposedContent,omitempty"` + ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"` + Diff []ConfigDiffLineResponse `json:"diff"` + HasChanges bool `json:"hasChanges"` + Source string `json:"source"` + ReviewedAt time.Time `json:"reviewedAt"` +} + +type ServerConfigWriteApprovalRequest struct { + ExpectedConfigVersion int `json:"expectedConfigVersion"` + Key string `json:"key"` + ProposedContent string `json:"proposedContent,omitempty"` + ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type ServerConfigWriteDispatchResponse struct { + Status string `json:"status"` + Preview ServerConfigDiffPreviewResponse `json:"preview"` + Job JobResponse `json:"job"` +} + +type FileOperationDispatchRequest struct { + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId,omitempty"` + Operation domain.FileOperationKind `json:"operation"` + Key string `json:"key"` + InputRef string `json:"inputRef,omitempty"` + ExpectedConfigVersion int `json:"expectedConfigVersion,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type FileOperationDispatchResponse struct { + Status string `json:"status"` + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId,omitempty"` + Operation domain.FileOperationKind `json:"operation"` + Key string `json:"key"` + InputRef string `json:"inputRef,omitempty"` + Job JobResponse `json:"job"` +} + +type RunCapacityResponse struct { + MaxJobs int `json:"maxJobs"` + RunningJobs int `json:"runningJobs"` + QueuedJobs int `json:"queuedJobs"` + Summary string `json:"summary,omitempty"` +} + +type RunEndpointCreateRequest struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Status domain.RunEndpointStatus `json:"status"` + Capabilities []string `json:"capabilities"` + Capacity RunCapacityResponse `json:"capacity"` + LastHeartbeatAt time.Time `json:"lastHeartbeatAt"` +} + +type RunEndpointResponse struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Status domain.RunEndpointStatus `json:"status"` + Capabilities []string `json:"capabilities"` + Capacity RunCapacityResponse `json:"capacity"` + LastHeartbeatAt time.Time `json:"lastHeartbeatAt"` +} + +type RunEndpointListResponse struct { + Items []RunEndpointResponse `json:"items"` + Count int `json:"count"` +} + +type JobCreateRequest struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + RunEndpointID string `json:"runEndpointId"` + Capability string `json:"capability"` + TargetKey string `json:"targetKey,omitempty"` + InputRef string `json:"inputRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` + Progress JobProgressBody `json:"progress,omitempty"` +} + +type JobProgressBody struct { + Percent int `json:"percent"` + Message string `json:"message,omitempty"` +} + +type JobResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + RunEndpointID string `json:"runEndpointId"` + Capability string `json:"capability"` + TargetKey string `json:"targetKey,omitempty"` + InputRef string `json:"inputRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` + State domain.JobState `json:"state"` + Progress JobProgressBody `json:"progress"` + ResultRef string `json:"resultRef,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type JobListResponse struct { + Items []JobResponse `json:"items"` + Count int `json:"count"` +} + +type ArtifactCreateRequest struct { + ID string `json:"id"` + OwnerKind domain.ArtifactOwnerKind `json:"ownerKind"` + OwnerID string `json:"ownerId"` + SizeBytes int64 `json:"sizeBytes"` + Checksum string `json:"checksum"` +} + +type ArtifactResponse struct { + ID string `json:"id"` + OwnerKind domain.ArtifactOwnerKind `json:"ownerKind"` + OwnerID string `json:"ownerId"` + SizeBytes int64 `json:"sizeBytes"` + Checksum string `json:"checksum"` + State domain.ArtifactState `json:"state"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ArtifactListResponse struct { + Items []ArtifactResponse `json:"items"` + Count int `json:"count"` +} + +type LogStreamCreateRequest struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + Source domain.LogStreamSource `json:"source"` + StreamKey string `json:"streamKey"` + StorageBackend domain.LogStorageBackend `json:"storageBackend"` + RetentionPolicy string `json:"retentionPolicy"` +} + +type LogStreamResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + Source domain.LogStreamSource `json:"source"` + StreamKey string `json:"streamKey"` + LatestSeq uint64 `json:"latestSeq"` + StorageBackend domain.LogStorageBackend `json:"storageBackend"` + RetentionPolicy string `json:"retentionPolicy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type LogStreamListResponse struct { + Items []LogStreamResponse `json:"items"` + Count int `json:"count"` +} + +type AuditEventCreateRequest struct { + ID string `json:"id"` + ActorID string `json:"actorId"` + Action string `json:"action"` + ResourceKind string `json:"resourceKind"` + ResourceID string `json:"resourceId"` + Result domain.AuditResult `json:"result"` + Summary string `json:"summary"` +} + +type AuditEventResponse struct { + ID string `json:"id"` + ActorID string `json:"actorId"` + Action string `json:"action"` + ResourceKind string `json:"resourceKind"` + ResourceID string `json:"resourceId"` + Result domain.AuditResult `json:"result"` + Summary string `json:"summary"` + CreatedAt time.Time `json:"createdAt"` +} + +type AuditEventListResponse struct { + Items []AuditEventResponse `json:"items"` + Count int `json:"count"` +} + +type ErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Details []string `json:"details,omitempty"` +} + +func (request UserCreateRequest) ToDomain() domain.User { + return domain.User{ + ID: request.ID, + DisplayName: request.DisplayName, + Email: request.Email, + Status: request.Status, + Roles: domain.CopyStringSlice(request.Roles), + PasswordHash: request.Password, + Profile: request.Profile.ToDomain(), + } +} + +func (request UserUpdateRequest) ApplyTo(user domain.User) domain.User { + if request.DisplayName != nil { + user.DisplayName = *request.DisplayName + } + if request.Email != nil { + user.Email = *request.Email + } + if request.Status != nil { + user.Status = *request.Status + } + if request.Roles != nil { + user.Roles = domain.CopyStringSlice(request.Roles) + } + if request.Profile != nil { + user.Profile = request.Profile.ToDomain() + } + return user +} + +func (profile UserProfileBody) ToDomain() domain.UserProfile { + return domain.UserProfile{ + AvatarURL: profile.AvatarURL, + Phone: profile.Phone, + QQ: profile.QQ, + ContactNote: profile.ContactNote, + } +} + +func UserProfileFromDomain(profile domain.UserProfile) UserProfileBody { + return UserProfileBody{ + AvatarURL: profile.AvatarURL, + Phone: profile.Phone, + QQ: profile.QQ, + ContactNote: profile.ContactNote, + } +} + +func (request UserThemePreferenceRequest) ToDomain(userID string) domain.UserThemePreference { + preference := domain.UserThemePreference{ + UserID: userID, + PaletteID: request.PaletteID, + BackgroundPresetID: request.BackgroundPresetID, + Persistence: "api", + } + if request.BackgroundImage != nil { + preference.BackgroundImage = *request.BackgroundImage + } + return preference +} + +func (request LoginRequest) ToDomain() domain.UserLogin { + return domain.UserLogin{Account: request.Account, Password: request.Password} +} + +func (request RegisterRequest) ToDomain() domain.UserRegistration { + return domain.UserRegistration{ + DisplayName: request.DisplayName, + Email: request.Email, + Password: request.Password, + Profile: domain.UserProfile{ + Phone: request.Phone, + QQ: request.QQ, + }, + } +} + +func (request AIProviderCreateRequest) ToDomain() domain.AIProvider { + return domain.AIProvider{ + ID: request.ID, + Name: request.Name, + Kind: request.Kind, + BaseURL: request.BaseURL, + APIKeyRef: request.APIKeyRef, + Models: domain.CopyStringSlice(request.Models), + DefaultModel: request.DefaultModel, + RelayMode: request.RelayMode, + TimeoutMS: request.TimeoutMS, + RedactionPolicy: request.RedactionPolicy, + } +} + +func (request AIProviderUpdateRequest) ToDomain(id string, status domain.AIProviderStatus) domain.AIProvider { + return domain.AIProvider{ + ID: id, + Name: request.Name, + Kind: request.Kind, + BaseURL: request.BaseURL, + APIKeyRef: request.APIKeyRef, + Models: domain.CopyStringSlice(request.Models), + DefaultModel: request.DefaultModel, + RelayMode: request.RelayMode, + TimeoutMS: request.TimeoutMS, + Status: status, + RedactionPolicy: request.RedactionPolicy, + } +} + +func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePluginManifestRegistration { + return domain.GamePluginManifestRegistration{ + ManifestRef: request.ManifestRef, + Manifest: domain.GamePluginManifest{ + ID: request.Manifest.ID, + Name: request.Manifest.Name, + Description: request.Manifest.Description, + Version: request.Manifest.Version, + Kind: request.Manifest.Kind, + Tags: domain.CopyStringSlice(request.Manifest.Tags), + Server: request.Manifest.Server.ToDomain(), + Bridge: request.Manifest.Bridge.ToDomain(), + Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities), + Permissions: domain.CopyStringSlice(request.Manifest.Permissions), + Actions: request.Manifest.Actions.ToDomain(), + Pages: pagesToDomain(request.Manifest.Pages), + AI: request.Manifest.AI.ToDomain(), + }, + } +} + +func (bridge GamePluginBridgeBody) ToDomain() domain.GamePluginBridge { + return domain.GamePluginBridge{Actions: domain.CopyStringSlice(bridge.Actions)} +} + +func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestServer { + return domain.GamePluginManifestServer{ + Type: server.Type, + DisplayName: server.DisplayName, + SupportedOS: domain.CopyStringSlice(server.SupportedOS), + CreateFormSchema: server.CreateFormSchema, + } +} + +func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI { + return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes)} +} + +func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions { + return domain.PluginLifecycleActions{ + Install: actions.Install, + Start: actions.Start, + Stop: actions.Stop, + Restart: actions.Restart, + Status: actions.Status, + } +} + +func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin { + return domain.GamePlugin{ + ID: request.ID, + Name: request.Name, + Description: request.Description, + Version: request.Version, + ServerType: request.ServerType, + ServerDisplayName: request.ServerDisplayName, + SupportedOS: domain.CopyStringSlice(request.SupportedOS), + ManifestRef: request.ManifestRef, + CreateFormSchemaRef: request.CreateFormSchemaRef, + RequiredRunCapabilities: domain.CopyStringSlice(request.RequiredRunCapabilities), + DeclaredPermissions: domain.CopyStringSlice(request.DeclaredPermissions), + Permissions: permissionsToDomain(request.Permissions), + LifecycleActions: request.LifecycleActions.ToDomain(), + BridgeActions: domain.CopyStringSlice(request.BridgeActions), + Pages: pagesToDomain(request.Pages), + Tags: domain.CopyStringSlice(request.Tags), + AIPurposes: domain.CopyStringSlice(request.AIPurposes), + ValidationViolations: domain.CopyStringSlice(request.ValidationViolations), + } +} + +func (request ServerInstanceCreateRequest) ToDomain() domain.ServerInstance { + return domain.ServerInstance{ + ID: request.ID, + PluginID: request.PluginID, + RunEndpointID: request.RunEndpointID, + Name: request.Name, + OwnerUserID: request.OwnerUserID, + AdminUserIDs: domain.CopyStringSlice(request.AdminUserIDs), + State: request.State, + } +} + +func (request ServerConfigDiffPreviewRequest) ToDomain(serverInstanceID string) domain.ServerConfigDiffRequest { + return domain.ServerConfigDiffRequest{ + ServerInstanceID: serverInstanceID, + ExpectedConfigVersion: request.ExpectedConfigVersion, + Key: request.Key, + ProposedContent: request.ProposedContent, + ProposedContentInputRef: request.ProposedContentInputRef, + } +} + +func (request ServerConfigWriteApprovalRequest) ToDomain(serverInstanceID string) domain.ServerConfigWriteApproval { + return domain.ServerConfigWriteApproval{ + ServerInstanceID: serverInstanceID, + ExpectedConfigVersion: request.ExpectedConfigVersion, + Key: request.Key, + ProposedContent: request.ProposedContent, + ProposedContentInputRef: request.ProposedContentInputRef, + IdempotencyKey: request.IdempotencyKey, + } +} + +func (request FileOperationDispatchRequest) ToDomain() domain.FileOperationDispatchRequest { + return domain.FileOperationDispatchRequest{ + ServerInstanceID: request.ServerInstanceID, + PluginID: request.PluginID, + Operation: request.Operation, + Key: request.Key, + InputRef: request.InputRef, + ExpectedConfigVersion: request.ExpectedConfigVersion, + IdempotencyKey: request.IdempotencyKey, + } +} + +func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint { + return domain.RunEndpoint{ + ID: request.ID, + DisplayName: request.DisplayName, + Version: request.Version, + Status: request.Status, + Capabilities: domain.CopyStringSlice(request.Capabilities), + Capacity: capacityToDomain(request.Capacity), + LastHeartbeatAt: request.LastHeartbeatAt, + } +} + +func (request JobCreateRequest) ToDomain() domain.Job { + return domain.Job{ + ID: request.ID, + ServerInstanceID: request.ServerInstanceID, + RunEndpointID: request.RunEndpointID, + Capability: request.Capability, + TargetKey: request.TargetKey, + InputRef: request.InputRef, + IdempotencyKey: request.IdempotencyKey, + Progress: progressToDomain(request.Progress), + } +} + +func (request ArtifactCreateRequest) ToDomain() domain.Artifact { + return domain.Artifact{ + ID: request.ID, + OwnerKind: request.OwnerKind, + OwnerID: request.OwnerID, + SizeBytes: request.SizeBytes, + Checksum: request.Checksum, + } +} + +func (request LogStreamCreateRequest) ToDomain() domain.LogStream { + return domain.LogStream{ + ID: request.ID, + ServerInstanceID: request.ServerInstanceID, + Source: request.Source, + StreamKey: request.StreamKey, + StorageBackend: request.StorageBackend, + RetentionPolicy: request.RetentionPolicy, + } +} + +func (request AuditEventCreateRequest) ToDomain() domain.AuditEvent { + return domain.AuditEvent{ + ID: request.ID, + ActorID: request.ActorID, + Action: request.Action, + ResourceKind: request.ResourceKind, + ResourceID: request.ResourceID, + Result: request.Result, + Summary: request.Summary, + } +} + +func UserFromDomain(user domain.User) UserResponse { + user = domain.CopyUser(user) + return UserResponse{ + ID: user.ID, + DisplayName: user.DisplayName, + Email: user.Email, + Status: user.Status, + Roles: user.Roles, + Profile: UserProfileFromDomain(user.Profile), + ThemePreference: UserThemePreferenceFromDomain(user.Theme), + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} + +func CurrentUserFromDomain(user domain.User) CurrentUserResponse { + user = domain.CopyUser(user) + return CurrentUserResponse{ + ID: user.ID, + DisplayName: user.DisplayName, + Email: user.Email, + Status: user.Status, + Roles: user.Roles, + Profile: UserProfileFromDomain(user.Profile), + ThemePreference: UserThemePreferenceFromDomain(user.Theme), + } +} + +func AuthSessionFromDomain(session domain.AuthSession) AuthSessionResponse { + return AuthSessionResponse{ + User: CurrentUserFromDomain(session.User), + SessionID: session.SessionID, + Status: session.Status, + Message: session.Message, + } +} + +func UserThemePreferenceFromDomain(preference domain.UserThemePreference) *UserThemePreferenceResponse { + if preference.UserID == "" { + return nil + } + return &UserThemePreferenceResponse{ + UserID: preference.UserID, + PaletteID: preference.PaletteID, + BackgroundPresetID: preference.BackgroundPresetID, + BackgroundImage: preference.BackgroundImage, + Persistence: preference.Persistence, + UpdatedAt: preference.UpdatedAt, + } +} + +func UserListFromDomain(users []domain.User) UserListResponse { + items := make([]UserResponse, len(users)) + for i, user := range users { + items[i] = UserFromDomain(user) + } + return UserListResponse{Items: items, Count: len(items)} +} + +func AIProviderFromDomain(provider domain.AIProvider) AIProviderResponse { + provider = domain.CopyAIProvider(provider) + return AIProviderResponse{ + ID: provider.ID, + Name: provider.Name, + Kind: provider.Kind, + BaseURL: provider.BaseURL, + APIKeyRef: provider.APIKeyRef, + Models: provider.Models, + DefaultModel: provider.DefaultModel, + RelayMode: provider.RelayMode, + TimeoutMS: provider.TimeoutMS, + Status: provider.Status, + RedactionPolicy: provider.RedactionPolicy, + } +} + +func AIProviderListFromDomain(providers []domain.AIProvider) AIProviderListResponse { + items := make([]AIProviderResponse, len(providers)) + for i, provider := range providers { + items[i] = AIProviderFromDomain(provider) + } + return AIProviderListResponse{Items: items, Count: len(items)} +} + +func AIProviderTestFromDomain(result domain.AIProviderTestResult) AIProviderTestResponse { + result = domain.CopyAIProviderTestResult(result) + return AIProviderTestResponse{ + ProviderID: result.ProviderID, + Mode: result.Mode, + Success: result.Success, + Message: result.Message, + Violations: result.Violations, + } +} + +func AIProviderModelsFromDomain(models domain.AIProviderModels) AIProviderModelsResponse { + models = domain.CopyAIProviderModels(models) + return AIProviderModelsResponse{ + ProviderID: models.ProviderID, + DefaultModel: models.DefaultModel, + Models: models.Models, + } +} + +func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse { + plugin = domain.CopyGamePlugin(plugin) + return GamePluginResponse{ + ID: plugin.ID, + Name: plugin.Name, + Description: plugin.Description, + Version: plugin.Version, + ServerType: plugin.ServerType, + ServerDisplayName: plugin.ServerDisplayName, + SupportedOS: plugin.SupportedOS, + ManifestRef: plugin.ManifestRef, + CreateFormSchemaRef: plugin.CreateFormSchemaRef, + RequiredRunCapabilities: plugin.RequiredRunCapabilities, + DeclaredPermissions: plugin.DeclaredPermissions, + Permissions: permissionsFromDomain(plugin.Permissions), + LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions), + BridgeActions: plugin.BridgeActions, + Pages: pagesFromDomain(plugin.Pages), + Tags: plugin.Tags, + AIPurposes: plugin.AIPurposes, + ValidationViolations: plugin.ValidationViolations, + Status: plugin.Status, + } +} + +func (request PluginBridgeAuthorizeRequest) ToDomain() domain.PluginBridgeAuthorizeRequest { + return domain.PluginBridgeAuthorizeRequest{ + PluginID: request.PluginID, + RouteKey: request.RouteKey, + ServerInstanceID: request.ServerInstanceID, + Action: domain.PluginBridgeAction(request.Action), + AIPurpose: request.AIPurpose, + } +} + +func (request PluginBridgeExecuteRequest) ToDomain() domain.PluginBridgeExecuteRequest { + return domain.PluginBridgeExecuteRequest{ + RequestID: request.RequestID, + PluginID: request.PluginID, + RouteKey: request.RouteKey, + ServerInstanceID: request.ServerInstanceID, + Action: domain.PluginBridgeAction(request.Action), + AIPurpose: request.AIPurpose, + Payload: domain.CopyStringMap(request.Payload), + } +} + +func PluginBridgeAuthorizeFromDomain(result domain.PluginBridgeAuthorization) PluginBridgeAuthorizeResponse { + result = domain.CopyPluginBridgeAuthorization(result) + return PluginBridgeAuthorizeResponse{ + PluginID: result.PluginID, + RouteKey: result.RouteKey, + ServerInstanceID: result.ServerInstanceID, + Action: string(result.Action), + Allowed: result.Allowed, + RequiredPermissions: result.RequiredPermissions, + EffectivePermissions: result.EffectivePermissions, + Reason: result.Reason, + } +} + +func PluginBridgeExecuteFromDomain(response domain.PluginBridgeExecuteResponse) PluginBridgeExecuteResponse { + response = domain.CopyPluginBridgeExecuteResponse(response) + var safeError *PluginBridgeSafeErrorResponse + if response.Error != nil { + safeError = &PluginBridgeSafeErrorResponse{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details} + } + return PluginBridgeExecuteResponse{ + RequestID: response.RequestID, + PluginID: response.PluginID, + RouteKey: response.RouteKey, + ServerInstanceID: response.ServerInstanceID, + Action: string(response.Action), + Status: response.Status, + Result: domain.CopyStringMap(response.Result), + Error: safeError, + } +} + +func GamePluginListFromDomain(plugins []domain.GamePlugin) GamePluginListResponse { + items := make([]GamePluginResponse, len(plugins)) + for i, plugin := range plugins { + items[i] = GamePluginFromDomain(plugin) + } + return GamePluginListResponse{Items: items, Count: len(items)} +} + +func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) MarketplacePluginResponse { + plugin = domain.CopyPluginMarketplacePlugin(plugin) + return MarketplacePluginResponse{ + ID: plugin.ID, + Name: plugin.Name, + Description: plugin.Description, + Version: plugin.Version, + ServerType: plugin.ServerType, + ServerDisplayName: plugin.ServerDisplayName, + SupportedOS: plugin.SupportedOS, + ManifestRef: plugin.ManifestRef, + CreateFormSchemaRef: plugin.CreateFormSchemaRef, + Capabilities: plugin.Capabilities, + DeclaredPermissions: plugin.DeclaredPermissions, + Permissions: permissionsFromDomain(plugin.Permissions), + LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions), + BridgeActions: plugin.BridgeActions, + Pages: pagesFromDomain(plugin.Pages), + Tags: plugin.Tags, + AIPurposes: plugin.AIPurposes, + ValidationViolations: plugin.ValidationViolations, + Status: plugin.Status, + Source: plugin.Source, + } +} + +func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse { + items := make([]MarketplacePluginResponse, len(plugins)) + for i, plugin := range plugins { + items[i] = MarketplacePluginFromDomain(plugin) + } + return MarketplacePluginListResponse{Items: items, Count: len(items)} +} + +func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstanceResponse { + instance = domain.CopyServerInstance(instance) + adminUserIDs := instance.AdminUserIDs + if adminUserIDs == nil { + adminUserIDs = []string{} + } + return ServerInstanceResponse{ + ID: instance.ID, + PluginID: instance.PluginID, + PluginVersion: instance.PluginVersion, + RunEndpointID: instance.RunEndpointID, + Name: instance.Name, + OwnerUserID: instance.OwnerUserID, + AdminUserIDs: adminUserIDs, + State: instance.State, + ConfigVersion: instance.ConfigVersion, + CreatedAt: instance.CreatedAt, + UpdatedAt: instance.UpdatedAt, + } +} + +func ServerMemberFromDomain(user domain.User) ServerMemberResponse { + user = domain.CopyUser(user) + return ServerMemberResponse{ + ID: user.ID, + DisplayName: user.DisplayName, + Email: user.Email, + Status: user.Status, + Roles: user.Roles, + Profile: UserProfileFromDomain(user.Profile), + } +} + +func ServerMemberListFromDomain(users []domain.User) ServerMemberListResponse { + items := make([]ServerMemberResponse, len(users)) + for i, user := range users { + items[i] = ServerMemberFromDomain(user) + } + return ServerMemberListResponse{Items: items, Count: len(items)} +} + +func ServerInstanceListFromDomain(instances []domain.ServerInstance) ServerInstanceListResponse { + items := make([]ServerInstanceResponse, len(instances)) + for i, instance := range instances { + items[i] = ServerInstanceFromDomain(instance) + } + return ServerInstanceListResponse{Items: items, Count: len(items)} +} + +func PlatformResourceUsageFromDomain(usage domain.PlatformResourceUsage) PlatformResourceUsageResponse { + usage = domain.CopyPlatformResourceUsage(usage) + return PlatformResourceUsageResponse{ + CPUPercent: usage.CPUPercent, + MemoryPercent: usage.MemoryPercent, + DiskPercent: usage.DiskPercent, + Source: usage.Source, + CollectedAt: usage.CollectedAt, + } +} + +func ServerMetricsFromDomain(metrics domain.ServerMetrics) ServerMetricsResponse { + metrics = domain.CopyServerMetrics(metrics) + return ServerMetricsResponse{ + ServerInstanceID: metrics.ServerInstanceID, + Online: metrics.Online, + PlayerCount: metrics.PlayerCount, + MaxPlayers: metrics.MaxPlayers, + TPS: metrics.TPS, + LatencyMS: metrics.LatencyMS, + CPUPercent: metrics.CPUPercent, + MemoryPercent: metrics.MemoryPercent, + DiskPercent: metrics.DiskPercent, + Source: metrics.Source, + CollectedAt: metrics.CollectedAt, + } +} + +func ServerMetricsListFromDomain(items []domain.ServerMetrics) ServerMetricsListResponse { + responses := make([]ServerMetricsResponse, len(items)) + for i, item := range items { + responses[i] = ServerMetricsFromDomain(item) + } + return ServerMetricsListResponse{Items: responses, Count: len(responses)} +} + +func ServerConfigFromDomain(config domain.ServerConfig) ServerConfigResponse { + config = domain.CopyServerConfig(config) + return ServerConfigResponse{ + ServerInstanceID: config.ServerInstanceID, + ConfigVersion: config.ConfigVersion, + Format: config.Format, + Key: config.Key, + Content: config.Content, + Source: config.Source, + UpdatedAt: config.UpdatedAt, + } +} + +func ServerConfigDiffPreviewFromDomain(preview domain.ServerConfigDiffPreview) ServerConfigDiffPreviewResponse { + preview = domain.CopyServerConfigDiffPreview(preview) + lines := make([]ConfigDiffLineResponse, len(preview.Diff)) + for i, line := range preview.Diff { + lines[i] = ConfigDiffLineResponse{ + Kind: line.Kind, + OldNumber: line.OldNumber, + NewNumber: line.NewNumber, + Content: line.Content, + } + } + return ServerConfigDiffPreviewResponse{ + ServerInstanceID: preview.ServerInstanceID, + ConfigVersion: preview.ConfigVersion, + Key: preview.Key, + CurrentContent: preview.CurrentContent, + ProposedContent: preview.ProposedContent, + ProposedContentInputRef: preview.ProposedContentInputRef, + Diff: lines, + HasChanges: preview.HasChanges, + Source: preview.Source, + ReviewedAt: preview.ReviewedAt, + } +} + +func ServerConfigWriteDispatchFromDomain(dispatch domain.ServerConfigWriteDispatch) ServerConfigWriteDispatchResponse { + dispatch = domain.CopyServerConfigWriteDispatch(dispatch) + return ServerConfigWriteDispatchResponse{ + Status: dispatch.Status, + Preview: ServerConfigDiffPreviewFromDomain(dispatch.Preview), + Job: JobFromDomain(dispatch.Job), + } +} + +func FileOperationDispatchFromDomain(result domain.FileOperationDispatchResult) FileOperationDispatchResponse { + result = domain.CopyFileOperationDispatchResult(result) + return FileOperationDispatchResponse{ + Status: result.Status, + ServerInstanceID: result.ServerInstanceID, + PluginID: result.PluginID, + Operation: result.Operation, + Key: result.Key, + InputRef: result.InputRef, + Job: JobFromDomain(result.Job), + } +} + +func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse { + endpoint = domain.CopyRunEndpoint(endpoint) + return RunEndpointResponse{ + ID: endpoint.ID, + DisplayName: endpoint.DisplayName, + Version: endpoint.Version, + Status: endpoint.Status, + Capabilities: endpoint.Capabilities, + Capacity: capacityFromDomain(endpoint.Capacity), + LastHeartbeatAt: endpoint.LastHeartbeatAt, + } +} + +func RunEndpointListFromDomain(endpoints []domain.RunEndpoint) RunEndpointListResponse { + items := make([]RunEndpointResponse, len(endpoints)) + for i, endpoint := range endpoints { + items[i] = RunEndpointFromDomain(endpoint) + } + return RunEndpointListResponse{Items: items, Count: len(items)} +} + +func JobFromDomain(job domain.Job) JobResponse { + return JobResponse{ + ID: job.ID, + ServerInstanceID: job.ServerInstanceID, + RunEndpointID: job.RunEndpointID, + Capability: job.Capability, + TargetKey: job.TargetKey, + InputRef: job.InputRef, + IdempotencyKey: job.IdempotencyKey, + State: job.State, + Progress: progressFromDomain(job.Progress), + ResultRef: job.ResultRef, + CreatedAt: job.CreatedAt, + UpdatedAt: job.UpdatedAt, + } +} + +func JobListFromDomain(jobs []domain.Job) JobListResponse { + items := make([]JobResponse, len(jobs)) + for i, job := range jobs { + items[i] = JobFromDomain(job) + } + return JobListResponse{Items: items, Count: len(items)} +} + +func ArtifactFromDomain(artifact domain.Artifact) ArtifactResponse { + return ArtifactResponse{ + ID: artifact.ID, + OwnerKind: artifact.OwnerKind, + OwnerID: artifact.OwnerID, + SizeBytes: artifact.SizeBytes, + Checksum: artifact.Checksum, + State: artifact.State, + CreatedAt: artifact.CreatedAt, + UpdatedAt: artifact.UpdatedAt, + } +} + +func ArtifactListFromDomain(artifacts []domain.Artifact) ArtifactListResponse { + items := make([]ArtifactResponse, len(artifacts)) + for i, artifact := range artifacts { + items[i] = ArtifactFromDomain(artifact) + } + return ArtifactListResponse{Items: items, Count: len(items)} +} + +func LogStreamFromDomain(stream domain.LogStream) LogStreamResponse { + return LogStreamResponse{ + ID: stream.ID, + ServerInstanceID: stream.ServerInstanceID, + Source: stream.Source, + StreamKey: stream.StreamKey, + LatestSeq: stream.LatestSeq, + StorageBackend: stream.StorageBackend, + RetentionPolicy: stream.RetentionPolicy, + CreatedAt: stream.CreatedAt, + UpdatedAt: stream.UpdatedAt, + } +} + +func LogStreamListFromDomain(streams []domain.LogStream) LogStreamListResponse { + items := make([]LogStreamResponse, len(streams)) + for i, stream := range streams { + items[i] = LogStreamFromDomain(stream) + } + return LogStreamListResponse{Items: items, Count: len(items)} +} + +func AuditEventFromDomain(event domain.AuditEvent) AuditEventResponse { + return AuditEventResponse{ + ID: event.ID, + ActorID: event.ActorID, + Action: event.Action, + ResourceKind: event.ResourceKind, + ResourceID: event.ResourceID, + Result: event.Result, + Summary: event.Summary, + CreatedAt: event.CreatedAt, + } +} + +func AuditEventListFromDomain(events []domain.AuditEvent) AuditEventListResponse { + items := make([]AuditEventResponse, len(events)) + for i, event := range events { + items[i] = AuditEventFromDomain(event) + } + return AuditEventListResponse{Items: items, Count: len(items)} +} + +func permissionsFromDomain(permissions domain.PluginPermissions) PluginPermissionsResponse { + return PluginPermissionsResponse{ + AI: permissions.AI, + Logs: permissions.Logs, + Files: permissions.Files, + Jobs: permissions.Jobs, + Artifacts: permissions.Artifacts, + } +} + +func permissionsToDomain(permissions PluginPermissionsResponse) domain.PluginPermissions { + return domain.PluginPermissions{ + AI: permissions.AI, + Logs: permissions.Logs, + Files: permissions.Files, + Jobs: permissions.Jobs, + Artifacts: permissions.Artifacts, + } +} + +func lifecycleActionsFromDomain(actions domain.PluginLifecycleActions) PluginLifecycleActionsBody { + return PluginLifecycleActionsBody{ + Install: actions.Install, + Start: actions.Start, + Stop: actions.Stop, + Restart: actions.Restart, + Status: actions.Status, + } +} + +func pagesToDomain(pages []GamePluginPageBody) []domain.GamePluginPage { + if pages == nil { + return nil + } + out := make([]domain.GamePluginPage, len(pages)) + for i, page := range pages { + out[i] = domain.GamePluginPage{ + Key: page.Key, + Title: page.Title, + Path: page.Path, + Permissions: domain.CopyStringSlice(page.Permissions), + BridgeActions: domain.CopyStringSlice(page.BridgeActions), + } + } + return out +} + +func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPageBody { + if pages == nil { + return nil + } + out := make([]GamePluginPageBody, len(pages)) + for i, page := range pages { + out[i] = GamePluginPageBody{ + Key: page.Key, + Title: page.Title, + Path: page.Path, + Permissions: domain.CopyStringSlice(page.Permissions), + BridgeActions: domain.CopyStringSlice(page.BridgeActions), + } + } + return out +} + +func capacityFromDomain(capacity domain.RunCapacity) RunCapacityResponse { + return RunCapacityResponse{ + MaxJobs: capacity.MaxJobs, + RunningJobs: capacity.RunningJobs, + QueuedJobs: capacity.QueuedJobs, + Summary: capacity.Summary, + } +} + +func capacityToDomain(capacity RunCapacityResponse) domain.RunCapacity { + return domain.RunCapacity{ + MaxJobs: capacity.MaxJobs, + RunningJobs: capacity.RunningJobs, + QueuedJobs: capacity.QueuedJobs, + Summary: capacity.Summary, + } +} + +func progressFromDomain(progress domain.JobProgress) JobProgressBody { + return JobProgressBody{ + Percent: progress.Percent, + Message: progress.Message, + } +} + +func progressToDomain(progress JobProgressBody) domain.JobProgress { + return domain.JobProgress{ + Percent: progress.Percent, + Message: progress.Message, + } +} diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go new file mode 100644 index 0000000..b2dc46b --- /dev/null +++ b/platform/dto/resources_test.go @@ -0,0 +1,112 @@ +package dto + +import ( + "reflect" + "testing" + + "browser.local/platform/domain" +) + +func TestAIProviderResponseExposesOnlyKeyReference(t *testing.T) { + responseType := reflect.TypeOf(AIProviderResponse{}) + if _, ok := responseType.FieldByName("APIKey"); ok { + t.Fatal("AI provider response must not expose raw API key") + } + if _, ok := responseType.FieldByName("RawAPIKey"); ok { + t.Fatal("AI provider response must not expose raw API key") + } + if _, ok := responseType.FieldByName("APIKeyRef"); !ok { + t.Fatal("AI provider response must expose API key reference") + } +} + +func TestAIProviderFromDomainCopiesModels(t *testing.T) { + provider := domain.AIProvider{ + ID: "ai.openai", + Name: "OpenAI", + Kind: domain.AIProviderKindOpenAI, + BaseURL: "https://api.openai.com/v1", + APIKeyRef: "secret://providers/openai", + Models: []string{"gpt-4.1"}, + DefaultModel: "gpt-4.1", + RelayMode: domain.AIRelayModeDirect, + TimeoutMS: 30000, + Status: domain.AIProviderStatusActive, + RedactionPolicy: "default", + } + + response := AIProviderFromDomain(provider) + response.Models[0] = "mutated" + + if provider.Models[0] != "gpt-4.1" { + t.Fatalf("expected response models to be copied, got source models %+v", provider.Models) + } + if response.APIKeyRef != provider.APIKeyRef { + t.Fatalf("expected API key reference to be preserved, got %q", response.APIKeyRef) + } +} + +func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) { + request := GamePluginManifestRegistrationRequest{ + ManifestRef: "artifact://manifests/game.example/0.1.0", + Manifest: GamePluginManifestBody{ + ID: "game.example", + Name: "Example Server", + Version: "0.1.0", + Kind: "game-plugin", + Tags: []string{"example"}, + Capabilities: []string{"process.start"}, + Permissions: []string{"server.lifecycle"}, + Server: GamePluginManifestServerBody{ + Type: "example", + DisplayName: "Example Server", + SupportedOS: []string{"linux"}, + CreateFormSchema: "schemas/create-form.schema.json", + }, + Actions: PluginLifecycleActionsBody{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"}, + Pages: []GamePluginPageBody{ + {Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}}, + }, + AI: GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}}, + }, + } + + domainRegistration := request.ToDomain() + domainRegistration.Manifest.Tags[0] = "mutated" + domainRegistration.Manifest.Server.SupportedOS[0] = "darwin" + domainRegistration.Manifest.Pages[0].Permissions[0] = "ai.invoke" + domainRegistration.Manifest.AI.Purposes[0] = "config.suggest" + + if request.Manifest.Tags[0] != "example" || request.Manifest.Server.SupportedOS[0] != "linux" || request.Manifest.Pages[0].Permissions[0] != "server.logs.read" || request.Manifest.AI.Purposes[0] != "logs.diagnose" { + t.Fatalf("expected manifest request slices to be copied, got %+v", request) + } +} + +func TestGamePluginFromDomainCopiesRegistryMetadata(t *testing.T) { + plugin := domain.GamePlugin{ + ID: "game.example", + Name: "Example Server", + Version: "0.1.0", + ServerType: "example", + RequiredRunCapabilities: []string{"process.start"}, + DeclaredPermissions: []string{"server.lifecycle"}, + SupportedOS: []string{"linux"}, + Pages: []domain.GamePluginPage{ + {Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}}, + }, + Tags: []string{"example"}, + AIPurposes: []string{"logs.diagnose"}, + } + + response := GamePluginFromDomain(plugin) + response.RequiredRunCapabilities[0] = "files.read" + response.DeclaredPermissions[0] = "ai.invoke" + response.SupportedOS[0] = "darwin" + response.Pages[0].Permissions[0] = "ai.invoke" + response.Tags[0] = "mutated" + response.AIPurposes[0] = "config.suggest" + + if plugin.RequiredRunCapabilities[0] != "process.start" || plugin.DeclaredPermissions[0] != "server.lifecycle" || plugin.SupportedOS[0] != "linux" || plugin.Pages[0].Permissions[0] != "server.logs.read" || plugin.Tags[0] != "example" || plugin.AIPurposes[0] != "logs.diagnose" { + t.Fatalf("expected plugin response registry metadata to be copied, got %+v", plugin) + } +} diff --git a/platform/dto/server_lifecycle.go b/platform/dto/server_lifecycle.go new file mode 100644 index 0000000..041afd5 --- /dev/null +++ b/platform/dto/server_lifecycle.go @@ -0,0 +1,53 @@ +package dto + +import "browser.local/platform/domain" + +type ServerLifecycleCreateRequest struct { + ID string `json:"id"` + PluginID string `json:"pluginId"` + RunEndpointID string `json:"runEndpointId"` + Name string `json:"name"` + OwnerUserID string `json:"ownerUserId,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type ServerLifecycleCommandRequest struct { + ExpectedConfigVersion int `json:"expectedConfigVersion"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type ServerLifecycleResponse struct { + Accepted bool `json:"accepted"` + Action domain.ServerLifecycleAction `json:"action"` + Instance ServerInstanceResponse `json:"instance"` + Job JobResponse `json:"job"` +} + +func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCreate { + return domain.ServerLifecycleCreate{ + ID: request.ID, + PluginID: request.PluginID, + RunEndpointID: request.RunEndpointID, + Name: request.Name, + OwnerUserID: request.OwnerUserID, + IdempotencyKey: request.IdempotencyKey, + } +} + +func (request ServerLifecycleCommandRequest) ToDomain(serverInstanceID string) domain.ServerLifecycleCommand { + return domain.ServerLifecycleCommand{ + ServerInstanceID: serverInstanceID, + ExpectedConfigVersion: request.ExpectedConfigVersion, + IdempotencyKey: request.IdempotencyKey, + } +} + +func ServerLifecycleFromDomain(result domain.ServerLifecycleResult) ServerLifecycleResponse { + result = domain.CopyServerLifecycleResult(result) + return ServerLifecycleResponse{ + Accepted: result.Accepted, + Action: result.Action, + Instance: ServerInstanceFromDomain(result.Instance), + Job: JobFromDomain(result.Job), + } +} diff --git a/platform/go.mod b/platform/go.mod new file mode 100644 index 0000000..2743630 --- /dev/null +++ b/platform/go.mod @@ -0,0 +1,8 @@ +module browser.local/platform + +go 1.25.1 + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect +) diff --git a/platform/go.sum b/platform/go.sum new file mode 100644 index 0000000..1a983c8 --- /dev/null +++ b/platform/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= diff --git a/platform/main b/platform/main new file mode 100755 index 0000000..fc3128f Binary files /dev/null and b/platform/main differ diff --git a/platform/model/README.md b/platform/model/README.md new file mode 100644 index 0000000..04f5802 --- /dev/null +++ b/platform/model/README.md @@ -0,0 +1,17 @@ +# platform/model + +Database models live here and are the source of truth for table structure. Migrations must reference these models or be kept in sync with them. + +Required model groups: + +- users and roles. +- game management plugins and installed plugin versions. +- server instances and config versions. +- AI providers and secret references. +- run endpoints and capabilities. +- jobs and job events. +- artifacts and chunks. +- log streams and ingestion cursors. +- audit events. + +Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack. diff --git a/platform/model/resources.go b/platform/model/resources.go new file mode 100644 index 0000000..a7f18cc --- /dev/null +++ b/platform/model/resources.go @@ -0,0 +1,677 @@ +package model + +import ( + "time" + + "browser.local/platform/domain" +) + +type User struct { + // ID is the stable platform user identifier. + ID string `json:"id" db:"id"` + // DisplayName is the user-visible account name. + DisplayName string `json:"displayName" db:"display_name"` + // Email is the optional login email. + Email string `json:"email,omitempty" db:"email"` + // Status is the user lifecycle status. + Status domain.UserStatus `json:"status" db:"status"` + // Roles stores assigned role keys. + Roles []string `json:"roles" db:"roles"` + // PasswordHash stores a platform-owned password verifier. + PasswordHash string `json:"passwordHash" db:"password_hash"` + // Profile stores bounded user contact metadata. + Profile domain.UserProfile `json:"profile" db:"profile"` + // Theme stores the user's persisted console theme preference. + Theme domain.UserThemePreference `json:"theme" db:"theme"` + // CreatedAt is the record creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + // UpdatedAt is the last update timestamp. + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (User) TableName() string { return "users" } + +type AIProvider struct { + // ID is the stable AI provider identifier. + ID string `json:"id" db:"id"` + // Name is the display name shown to operators. + Name string `json:"name" db:"name"` + // Kind identifies the provider protocol family. + Kind domain.AIProviderKind `json:"kind" db:"kind"` + // BaseURL is the provider or relay endpoint. + BaseURL string `json:"baseUrl" db:"base_url"` + // APIKeyRef references secret storage and never stores raw key material. + APIKeyRef string `json:"apiKeyRef" db:"api_key_ref"` + // Models lists allowed model identifiers. + Models []string `json:"models" db:"models"` + // DefaultModel is the optional default model identifier. + DefaultModel string `json:"defaultModel,omitempty" db:"default_model"` + // RelayMode controls direct, relay, or local routing. + RelayMode domain.AIRelayMode `json:"relayMode" db:"relay_mode"` + // TimeoutMS is the provider request timeout in milliseconds. + TimeoutMS int `json:"timeoutMs" db:"timeout_ms"` + // Status is the provider lifecycle status. + Status domain.AIProviderStatus `json:"status" db:"status"` + // RedactionPolicy identifies prompt/input/output redaction behavior. + RedactionPolicy string `json:"redactionPolicy" db:"redaction_policy"` +} + +func (AIProvider) TableName() string { return "ai_providers" } + +type PluginPermissions struct { + // AI allows platform-mediated AI requests. + AI bool `json:"ai" db:"ai"` + // Logs allows scoped log queries. + Logs bool `json:"logs" db:"logs"` + // Files allows scoped file/artifact operations. + Files bool `json:"files" db:"files"` + // Jobs allows lifecycle job dispatch. + Jobs bool `json:"jobs" db:"jobs"` + // Artifacts allows artifact metadata and transfer references. + Artifacts bool `json:"artifacts" db:"artifacts"` +} + +type PluginLifecycleActions struct { + // Install references the install action contract. + Install string `json:"install" db:"install"` + // Start references the start action contract. + Start string `json:"start" db:"start"` + // Stop references the stop action contract. + Stop string `json:"stop" db:"stop"` + // Restart references the optional restart action contract. + Restart string `json:"restart,omitempty" db:"restart"` + // Status references the optional status action contract. + Status string `json:"status,omitempty" db:"status"` +} + +type GamePluginPage struct { + // Key is stable within the plugin manifest. + Key string `json:"key" db:"key"` + // Title is the page label shown by platform clients. + Title string `json:"title" db:"title"` + // Path is the plugin-local page route. + Path string `json:"path" db:"path"` + // Permissions lists scoped platform bridge permissions required by the page. + Permissions []string `json:"permissions" db:"permissions"` +} + +type GamePlugin struct { + // ID is the installed game management plugin identifier. + ID string `json:"id" db:"id"` + // Name is the plugin display name. + Name string `json:"name" db:"name"` + // Description is bounded marketplace metadata from the manifest. + Description string `json:"description,omitempty" db:"description"` + // Version is the installed plugin version. + Version string `json:"version" db:"version"` + // ServerType is the game/server type key this plugin manages. + ServerType string `json:"serverType" db:"server_type"` + // ServerDisplayName is the user-visible server type name. + ServerDisplayName string `json:"serverDisplayName,omitempty" db:"server_display_name"` + // SupportedOS lists run operating systems declared by the plugin. + SupportedOS []string `json:"supportedOs" db:"supported_os"` + // ManifestRef points to the immutable manifest artifact. + ManifestRef string `json:"manifestRef" db:"manifest_ref"` + // CreateFormSchemaRef points to the create form schema artifact. + CreateFormSchemaRef string `json:"createFormSchemaRef" db:"create_form_schema_ref"` + // RequiredRunCapabilities lists run capabilities needed by this plugin. + RequiredRunCapabilities []string `json:"requiredRunCapabilities" db:"required_run_capabilities"` + // DeclaredPermissions lists scoped manifest permission keys. + DeclaredPermissions []string `json:"declaredPermissions" db:"declared_permissions"` + // Permissions declares platform-mediated plugin abilities. + Permissions PluginPermissions `json:"permissions" db:"permissions"` + // LifecycleActions stores manifest lifecycle action references. + LifecycleActions PluginLifecycleActions `json:"lifecycleActions" db:"lifecycle_actions"` + // Pages stores plugin-local page metadata. + Pages []GamePluginPage `json:"pages" db:"pages"` + // Tags stores bounded catalog tags. + Tags []string `json:"tags" db:"tags"` + // AIPurposes stores platform-mediated AI usage purposes. + AIPurposes []string `json:"aiPurposes" db:"ai_purposes"` + // ValidationViolations stores safe validation findings for invalid plugins. + ValidationViolations []string `json:"validationViolations" db:"validation_violations"` + // Status is the plugin lifecycle status. + Status domain.GamePluginStatus `json:"status" db:"status"` +} + +func (GamePlugin) TableName() string { return "game_plugins" } + +type ServerInstance struct { + // ID is the stable server instance identifier. + ID string `json:"id" db:"id"` + // PluginID references the installed game management plugin. + PluginID string `json:"pluginId" db:"plugin_id"` + // PluginVersion records the plugin version used for creation or reconcile. + PluginVersion string `json:"pluginVersion" db:"plugin_version"` + // RunEndpointID references the selected run endpoint. + RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"` + // Name is the server display name. + Name string `json:"name" db:"name"` + // OwnerUserID identifies the server owner account. + OwnerUserID string `json:"ownerUserId" db:"owner_user_id"` + // AdminUserIDs identifies server-scoped administrator accounts. + AdminUserIDs []string `json:"adminUserIds" db:"admin_user_ids"` + // State is the server lifecycle state. + State domain.ServerInstanceState `json:"state" db:"state"` + // ConfigVersion is the platform-managed optimistic concurrency version. + ConfigVersion int `json:"configVersion" db:"config_version"` + // CreatedAt is the record creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + // UpdatedAt is the last update timestamp. + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (ServerInstance) TableName() string { return "server_instances" } + +type RunCapacity struct { + // MaxJobs is the advertised job concurrency. + MaxJobs int `json:"maxJobs" db:"max_jobs"` + // RunningJobs is the current running job count. + RunningJobs int `json:"runningJobs" db:"running_jobs"` + // QueuedJobs is the current queued job count. + QueuedJobs int `json:"queuedJobs" db:"queued_jobs"` + // Summary is a bounded human-readable capacity summary. + Summary string `json:"summary,omitempty" db:"summary"` +} + +type RunEndpoint struct { + // ID is the stable run endpoint identifier. + ID string `json:"id" db:"id"` + // DisplayName is the visible executor name. + DisplayName string `json:"displayName" db:"display_name"` + // Version is the run binary version. + Version string `json:"version" db:"version"` + // Status is the current endpoint status. + Status domain.RunEndpointStatus `json:"status" db:"status"` + // Capabilities lists advertised run capability keys. + Capabilities []string `json:"capabilities" db:"capabilities"` + // Capacity stores current queue and resource summary. + Capacity RunCapacity `json:"capacity" db:"capacity"` + // LastHeartbeatAt is the latest control heartbeat timestamp. + LastHeartbeatAt time.Time `json:"lastHeartbeatAt" db:"last_heartbeat_at"` +} + +func (RunEndpoint) TableName() string { return "run_endpoints" } + +type JobProgress struct { + // Percent is bounded from 0 to 100. + Percent int `json:"percent" db:"percent"` + // Message is a bounded progress summary. + Message string `json:"message,omitempty" db:"message"` +} + +type Job struct { + // ID is the stable job identifier. + ID string `json:"id" db:"id"` + // ServerInstanceID optionally references the target server. + ServerInstanceID string `json:"serverInstanceId,omitempty" db:"server_instance_id"` + // RunEndpointID references the target run endpoint. + RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"` + // Capability is the requested run capability key. + Capability string `json:"capability" db:"capability"` + // TargetKey is a logical config/file key, never a host path. + TargetKey string `json:"targetKey,omitempty" db:"target_key"` + // InputRef points to a platform-scoped write payload or artifact. + InputRef string `json:"inputRef,omitempty" db:"input_ref"` + // IdempotencyKey detects duplicate job requests per run endpoint. + IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"` + // State is the job lifecycle state. + State domain.JobState `json:"state" db:"state"` + // Progress stores bounded progress metadata. + Progress JobProgress `json:"progress" db:"progress"` + // ResultRef references the terminal result artifact or summary. + ResultRef string `json:"resultRef,omitempty" db:"result_ref"` + // CreatedAt is the record creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + // UpdatedAt is the last update timestamp. + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (Job) TableName() string { return "jobs" } + +type Artifact struct { + // ID is the stable artifact identifier. + ID string `json:"id" db:"id"` + // OwnerKind identifies the owning resource class. + OwnerKind domain.ArtifactOwnerKind `json:"ownerKind" db:"owner_kind"` + // OwnerID identifies the owning resource. + OwnerID string `json:"ownerId" db:"owner_id"` + // SizeBytes stores the expected or final artifact size. + SizeBytes int64 `json:"sizeBytes" db:"size_bytes"` + // Checksum stores the final checksum. + Checksum string `json:"checksum" db:"checksum"` + // State is the artifact lifecycle state. + State domain.ArtifactState `json:"state" db:"state"` + // CreatedAt is the record creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + // UpdatedAt is the last update timestamp. + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (Artifact) TableName() string { return "artifacts" } + +type LogStream struct { + // ID is the stable log stream identifier. + ID string `json:"id" db:"id"` + // ServerInstanceID references the target server. + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + // Source identifies process, file, plugin, or custom source. + Source domain.LogStreamSource `json:"source" db:"source"` + // StreamKey is stable within the server instance. + StreamKey string `json:"streamKey" db:"stream_key"` + // LatestSeq is the latest accepted sequence number. + LatestSeq uint64 `json:"latestSeq" db:"latest_seq"` + // StorageBackend identifies the log body backend. + StorageBackend domain.LogStorageBackend `json:"storageBackend" db:"storage_backend"` + // RetentionPolicy identifies retention behavior. + RetentionPolicy string `json:"retentionPolicy" db:"retention_policy"` + // CreatedAt is the record creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + // UpdatedAt is the last update timestamp. + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (LogStream) TableName() string { return "log_streams" } + +type AuditEvent struct { + // ID is the stable audit event identifier. + ID string `json:"id" db:"id"` + // ActorID references the user or system actor. + ActorID string `json:"actorId" db:"actor_id"` + // Action is the stable action key. + Action string `json:"action" db:"action"` + // ResourceKind identifies the audited resource type. + ResourceKind string `json:"resourceKind" db:"resource_kind"` + // ResourceID identifies the audited resource. + ResourceID string `json:"resourceId" db:"resource_id"` + // Result is the audit outcome. + Result domain.AuditResult `json:"result" db:"result"` + // Summary is a bounded redacted summary. + Summary string `json:"summary" db:"summary"` + // CreatedAt is the audit timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +func (AuditEvent) TableName() string { return "audit_events" } + +func UserFromDomain(user domain.User) User { + user = domain.CopyUser(user) + return User{ + ID: user.ID, + DisplayName: user.DisplayName, + Email: user.Email, + Status: user.Status, + Roles: user.Roles, + PasswordHash: user.PasswordHash, + Profile: user.Profile, + Theme: user.Theme, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} + +func (user User) ToDomain() domain.User { + return domain.User{ + ID: user.ID, + DisplayName: user.DisplayName, + Email: user.Email, + Status: user.Status, + Roles: domain.CopyStringSlice(user.Roles), + PasswordHash: user.PasswordHash, + Profile: user.Profile, + Theme: user.Theme, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} + +func AIProviderFromDomain(provider domain.AIProvider) AIProvider { + provider = domain.CopyAIProvider(provider) + return AIProvider{ + ID: provider.ID, + Name: provider.Name, + Kind: provider.Kind, + BaseURL: provider.BaseURL, + APIKeyRef: provider.APIKeyRef, + Models: provider.Models, + DefaultModel: provider.DefaultModel, + RelayMode: provider.RelayMode, + TimeoutMS: provider.TimeoutMS, + Status: provider.Status, + RedactionPolicy: provider.RedactionPolicy, + } +} + +func (provider AIProvider) ToDomain() domain.AIProvider { + return domain.AIProvider{ + ID: provider.ID, + Name: provider.Name, + Kind: provider.Kind, + BaseURL: provider.BaseURL, + APIKeyRef: provider.APIKeyRef, + Models: domain.CopyStringSlice(provider.Models), + DefaultModel: provider.DefaultModel, + RelayMode: provider.RelayMode, + TimeoutMS: provider.TimeoutMS, + Status: provider.Status, + RedactionPolicy: provider.RedactionPolicy, + } +} + +func GamePluginFromDomain(plugin domain.GamePlugin) GamePlugin { + plugin = domain.CopyGamePlugin(plugin) + return GamePlugin{ + ID: plugin.ID, + Name: plugin.Name, + Description: plugin.Description, + Version: plugin.Version, + ServerType: plugin.ServerType, + ServerDisplayName: plugin.ServerDisplayName, + SupportedOS: plugin.SupportedOS, + ManifestRef: plugin.ManifestRef, + CreateFormSchemaRef: plugin.CreateFormSchemaRef, + RequiredRunCapabilities: plugin.RequiredRunCapabilities, + DeclaredPermissions: plugin.DeclaredPermissions, + Permissions: permissionsFromDomain(plugin.Permissions), + LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions), + Pages: pagesFromDomain(plugin.Pages), + Tags: plugin.Tags, + AIPurposes: plugin.AIPurposes, + ValidationViolations: plugin.ValidationViolations, + Status: plugin.Status, + } +} + +func (plugin GamePlugin) ToDomain() domain.GamePlugin { + return domain.GamePlugin{ + ID: plugin.ID, + Name: plugin.Name, + Description: plugin.Description, + Version: plugin.Version, + ServerType: plugin.ServerType, + ServerDisplayName: plugin.ServerDisplayName, + SupportedOS: domain.CopyStringSlice(plugin.SupportedOS), + ManifestRef: plugin.ManifestRef, + CreateFormSchemaRef: plugin.CreateFormSchemaRef, + RequiredRunCapabilities: domain.CopyStringSlice(plugin.RequiredRunCapabilities), + DeclaredPermissions: domain.CopyStringSlice(plugin.DeclaredPermissions), + Permissions: plugin.Permissions.ToDomain(), + LifecycleActions: plugin.LifecycleActions.ToDomain(), + Pages: pagesToDomain(plugin.Pages), + Tags: domain.CopyStringSlice(plugin.Tags), + AIPurposes: domain.CopyStringSlice(plugin.AIPurposes), + ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations), + Status: plugin.Status, + } +} + +func (actions PluginLifecycleActions) ToDomain() domain.PluginLifecycleActions { + return domain.PluginLifecycleActions{ + Install: actions.Install, + Start: actions.Start, + Stop: actions.Stop, + Restart: actions.Restart, + Status: actions.Status, + } +} + +func lifecycleActionsFromDomain(actions domain.PluginLifecycleActions) PluginLifecycleActions { + return PluginLifecycleActions{ + Install: actions.Install, + Start: actions.Start, + Stop: actions.Stop, + Restart: actions.Restart, + Status: actions.Status, + } +} + +func pagesToDomain(pages []GamePluginPage) []domain.GamePluginPage { + if pages == nil { + return nil + } + out := make([]domain.GamePluginPage, len(pages)) + for i, page := range pages { + out[i] = domain.GamePluginPage{ + Key: page.Key, + Title: page.Title, + Path: page.Path, + Permissions: domain.CopyStringSlice(page.Permissions), + } + } + return out +} + +func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPage { + if pages == nil { + return nil + } + out := make([]GamePluginPage, len(pages)) + for i, page := range pages { + out[i] = GamePluginPage{ + Key: page.Key, + Title: page.Title, + Path: page.Path, + Permissions: domain.CopyStringSlice(page.Permissions), + } + } + return out +} + +func (permissions PluginPermissions) ToDomain() domain.PluginPermissions { + return domain.PluginPermissions{ + AI: permissions.AI, + Logs: permissions.Logs, + Files: permissions.Files, + Jobs: permissions.Jobs, + Artifacts: permissions.Artifacts, + } +} + +func permissionsFromDomain(permissions domain.PluginPermissions) PluginPermissions { + return PluginPermissions{ + AI: permissions.AI, + Logs: permissions.Logs, + Files: permissions.Files, + Jobs: permissions.Jobs, + Artifacts: permissions.Artifacts, + } +} + +func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstance { + return ServerInstance{ + ID: instance.ID, + PluginID: instance.PluginID, + PluginVersion: instance.PluginVersion, + RunEndpointID: instance.RunEndpointID, + Name: instance.Name, + State: instance.State, + ConfigVersion: instance.ConfigVersion, + CreatedAt: instance.CreatedAt, + UpdatedAt: instance.UpdatedAt, + } +} + +func (instance ServerInstance) ToDomain() domain.ServerInstance { + return domain.ServerInstance{ + ID: instance.ID, + PluginID: instance.PluginID, + PluginVersion: instance.PluginVersion, + RunEndpointID: instance.RunEndpointID, + Name: instance.Name, + State: instance.State, + ConfigVersion: instance.ConfigVersion, + CreatedAt: instance.CreatedAt, + UpdatedAt: instance.UpdatedAt, + } +} + +func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpoint { + endpoint = domain.CopyRunEndpoint(endpoint) + return RunEndpoint{ + ID: endpoint.ID, + DisplayName: endpoint.DisplayName, + Version: endpoint.Version, + Status: endpoint.Status, + Capabilities: endpoint.Capabilities, + Capacity: capacityFromDomain(endpoint.Capacity), + LastHeartbeatAt: endpoint.LastHeartbeatAt, + } +} + +func (endpoint RunEndpoint) ToDomain() domain.RunEndpoint { + return domain.RunEndpoint{ + ID: endpoint.ID, + DisplayName: endpoint.DisplayName, + Version: endpoint.Version, + Status: endpoint.Status, + Capabilities: domain.CopyStringSlice(endpoint.Capabilities), + Capacity: endpoint.Capacity.ToDomain(), + LastHeartbeatAt: endpoint.LastHeartbeatAt, + } +} + +func (capacity RunCapacity) ToDomain() domain.RunCapacity { + return domain.RunCapacity{ + MaxJobs: capacity.MaxJobs, + RunningJobs: capacity.RunningJobs, + QueuedJobs: capacity.QueuedJobs, + Summary: capacity.Summary, + } +} + +func capacityFromDomain(capacity domain.RunCapacity) RunCapacity { + return RunCapacity{ + MaxJobs: capacity.MaxJobs, + RunningJobs: capacity.RunningJobs, + QueuedJobs: capacity.QueuedJobs, + Summary: capacity.Summary, + } +} + +func JobFromDomain(job domain.Job) Job { + return Job{ + ID: job.ID, + ServerInstanceID: job.ServerInstanceID, + RunEndpointID: job.RunEndpointID, + Capability: job.Capability, + TargetKey: job.TargetKey, + InputRef: job.InputRef, + IdempotencyKey: job.IdempotencyKey, + State: job.State, + Progress: progressFromDomain(job.Progress), + ResultRef: job.ResultRef, + CreatedAt: job.CreatedAt, + UpdatedAt: job.UpdatedAt, + } +} + +func (job Job) ToDomain() domain.Job { + return domain.Job{ + ID: job.ID, + ServerInstanceID: job.ServerInstanceID, + RunEndpointID: job.RunEndpointID, + Capability: job.Capability, + TargetKey: job.TargetKey, + InputRef: job.InputRef, + IdempotencyKey: job.IdempotencyKey, + State: job.State, + Progress: job.Progress.ToDomain(), + ResultRef: job.ResultRef, + CreatedAt: job.CreatedAt, + UpdatedAt: job.UpdatedAt, + } +} + +func (progress JobProgress) ToDomain() domain.JobProgress { + return domain.JobProgress{ + Percent: progress.Percent, + Message: progress.Message, + } +} + +func progressFromDomain(progress domain.JobProgress) JobProgress { + return JobProgress{ + Percent: progress.Percent, + Message: progress.Message, + } +} + +func ArtifactFromDomain(artifact domain.Artifact) Artifact { + return Artifact{ + ID: artifact.ID, + OwnerKind: artifact.OwnerKind, + OwnerID: artifact.OwnerID, + SizeBytes: artifact.SizeBytes, + Checksum: artifact.Checksum, + State: artifact.State, + CreatedAt: artifact.CreatedAt, + UpdatedAt: artifact.UpdatedAt, + } +} + +func (artifact Artifact) ToDomain() domain.Artifact { + return domain.Artifact{ + ID: artifact.ID, + OwnerKind: artifact.OwnerKind, + OwnerID: artifact.OwnerID, + SizeBytes: artifact.SizeBytes, + Checksum: artifact.Checksum, + State: artifact.State, + CreatedAt: artifact.CreatedAt, + UpdatedAt: artifact.UpdatedAt, + } +} + +func LogStreamFromDomain(stream domain.LogStream) LogStream { + return LogStream{ + ID: stream.ID, + ServerInstanceID: stream.ServerInstanceID, + Source: stream.Source, + StreamKey: stream.StreamKey, + LatestSeq: stream.LatestSeq, + StorageBackend: stream.StorageBackend, + RetentionPolicy: stream.RetentionPolicy, + CreatedAt: stream.CreatedAt, + UpdatedAt: stream.UpdatedAt, + } +} + +func (stream LogStream) ToDomain() domain.LogStream { + return domain.LogStream{ + ID: stream.ID, + ServerInstanceID: stream.ServerInstanceID, + Source: stream.Source, + StreamKey: stream.StreamKey, + LatestSeq: stream.LatestSeq, + StorageBackend: stream.StorageBackend, + RetentionPolicy: stream.RetentionPolicy, + CreatedAt: stream.CreatedAt, + UpdatedAt: stream.UpdatedAt, + } +} + +func AuditEventFromDomain(event domain.AuditEvent) AuditEvent { + return AuditEvent{ + ID: event.ID, + ActorID: event.ActorID, + Action: event.Action, + ResourceKind: event.ResourceKind, + ResourceID: event.ResourceID, + Result: event.Result, + Summary: event.Summary, + CreatedAt: event.CreatedAt, + } +} + +func (event AuditEvent) ToDomain() domain.AuditEvent { + return domain.AuditEvent{ + ID: event.ID, + ActorID: event.ActorID, + Action: event.Action, + ResourceKind: event.ResourceKind, + ResourceID: event.ResourceID, + Result: event.Result, + Summary: event.Summary, + CreatedAt: event.CreatedAt, + } +} diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go new file mode 100644 index 0000000..cccaed1 --- /dev/null +++ b/platform/model/resources_test.go @@ -0,0 +1,100 @@ +package model + +import ( + "testing" + + "browser.local/platform/domain" +) + +func TestTableNames(t *testing.T) { + tests := map[string]string{ + User{}.TableName(): "users", + AIProvider{}.TableName(): "ai_providers", + GamePlugin{}.TableName(): "game_plugins", + ServerInstance{}.TableName(): "server_instances", + RunEndpoint{}.TableName(): "run_endpoints", + Job{}.TableName(): "jobs", + Artifact{}.TableName(): "artifacts", + LogStream{}.TableName(): "log_streams", + AuditEvent{}.TableName(): "audit_events", + } + + for got, want := range tests { + if got != want { + t.Fatalf("expected table name %q, got %q", want, got) + } + } +} + +func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) { + source := domain.GamePlugin{ + ID: "server.scum", + Name: "SCUM", + Version: "1.0.0", + ServerType: "scum", + ManifestRef: "artifact://manifest", + CreateFormSchemaRef: "artifact://schema", + RequiredRunCapabilities: []string{"process.start", "logs.read"}, + DeclaredPermissions: []string{"server.logs.read"}, + SupportedOS: []string{"linux"}, + Pages: []domain.GamePluginPage{ + {Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}}, + }, + Tags: []string{"survival"}, + AIPurposes: []string{"logs.diagnose"}, + Permissions: domain.PluginPermissions{ + Jobs: true, + Logs: true, + }, + Status: domain.GamePluginStatusInstalled, + } + + row := GamePluginFromDomain(source) + roundTrip := row.ToDomain() + roundTrip.RequiredRunCapabilities[0] = "files.read" + roundTrip.DeclaredPermissions[0] = "ai.invoke" + roundTrip.SupportedOS[0] = "darwin" + roundTrip.Pages[0].Permissions[0] = "ai.invoke" + roundTrip.Tags[0] = "mutated" + roundTrip.AIPurposes[0] = "config.suggest" + + if source.RequiredRunCapabilities[0] != "process.start" { + t.Fatalf("expected source plugin capabilities to remain unchanged, got %+v", source.RequiredRunCapabilities) + } + if row.RequiredRunCapabilities[0] != "process.start" { + t.Fatalf("expected model plugin capabilities to remain unchanged, got %+v", row.RequiredRunCapabilities) + } + if source.DeclaredPermissions[0] != "server.logs.read" || source.Pages[0].Permissions[0] != "server.logs.read" || source.Tags[0] != "survival" || source.AIPurposes[0] != "logs.diagnose" { + t.Fatalf("expected source plugin registry metadata to remain unchanged, got %+v", source) + } + if row.DeclaredPermissions[0] != "server.logs.read" || row.Pages[0].Permissions[0] != "server.logs.read" || row.Tags[0] != "survival" || row.AIPurposes[0] != "logs.diagnose" { + t.Fatalf("expected model plugin registry metadata to remain unchanged, got %+v", row) + } +} + +func TestAIProviderModelUsesKeyReference(t *testing.T) { + source := domain.AIProvider{ + ID: "ai.openai", + Name: "OpenAI", + Kind: domain.AIProviderKindOpenAI, + BaseURL: "https://api.openai.com/v1", + APIKeyRef: "secret://providers/openai", + Models: []string{"gpt-4.1"}, + DefaultModel: "gpt-4.1", + RelayMode: domain.AIRelayModeDirect, + TimeoutMS: 30000, + Status: domain.AIProviderStatusActive, + RedactionPolicy: "default", + } + + row := AIProviderFromDomain(source) + if row.APIKeyRef != source.APIKeyRef { + t.Fatalf("expected API key reference %q, got %q", source.APIKeyRef, row.APIKeyRef) + } + + roundTrip := row.ToDomain() + roundTrip.Models[0] = "mutated" + if row.Models[0] != "gpt-4.1" { + t.Fatalf("expected model provider models to remain unchanged, got %+v", row.Models) + } +} diff --git a/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/list b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/list new file mode 100644 index 0000000..79127d8 --- /dev/null +++ b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/list @@ -0,0 +1 @@ +v1.2.0 diff --git a/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.info b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.info new file mode 100644 index 0000000..10f5975 --- /dev/null +++ b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.info @@ -0,0 +1 @@ +{"Version":"v1.2.0","Time":"2026-02-17T17:23:26Z","Origin":{"VCS":"git","URL":"https://github.com/FiloSottile/edwards25519","Hash":"b182a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7","Ref":"refs/tags/v1.2.0"}} \ No newline at end of file diff --git a/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.lock b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.lock new file mode 100644 index 0000000..e69de29 diff --git a/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.mod b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.mod new file mode 100644 index 0000000..f481953 --- /dev/null +++ b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.mod @@ -0,0 +1,3 @@ +module filippo.io/edwards25519 + +go 1.24.0 diff --git a/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.zip b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.zip new file mode 100644 index 0000000..d8e11b1 Binary files /dev/null and b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.zip differ diff --git a/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.ziphash b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.ziphash new file mode 100644 index 0000000..772b56c --- /dev/null +++ b/platform/pkg/mod/cache/download/filippo.io/edwards25519/@v/v1.2.0.ziphash @@ -0,0 +1 @@ +h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= \ No newline at end of file diff --git a/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/list b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/list new file mode 100644 index 0000000..bf7b70e --- /dev/null +++ b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/list @@ -0,0 +1 @@ +v1.10.0 diff --git a/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.info b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.info new file mode 100644 index 0000000..2c25ee0 --- /dev/null +++ b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.info @@ -0,0 +1 @@ +{"Version":"v1.10.0","Time":"2026-04-29T13:28:57Z","Origin":{"VCS":"git","URL":"https://github.com/go-sql-driver/mysql","Hash":"a065b60ab6d0c8e15468e7709c7f76acf4431647","Ref":"refs/tags/v1.10.0"}} \ No newline at end of file diff --git a/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.lock b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.lock new file mode 100644 index 0000000..e69de29 diff --git a/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.mod b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.mod new file mode 100644 index 0000000..728ab9f --- /dev/null +++ b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.mod @@ -0,0 +1,5 @@ +module github.com/go-sql-driver/mysql + +go 1.24.0 + +require filippo.io/edwards25519 v1.2.0 diff --git a/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.zip b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.zip new file mode 100644 index 0000000..a7ec58d Binary files /dev/null and b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.zip differ diff --git a/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.ziphash b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.ziphash new file mode 100644 index 0000000..97e0027 --- /dev/null +++ b/platform/pkg/mod/cache/download/github.com/go-sql-driver/mysql/@v/v1.10.0.ziphash @@ -0,0 +1 @@ +h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099.info b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099.info new file mode 100644 index 0000000..12f0be8 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099.info @@ -0,0 +1 @@ +git3:https://github.com/FiloSottile/edwards25519 \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099.lock b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099.lock new file mode 100644 index 0000000..e69de29 diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/FETCH_HEAD b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/FETCH_HEAD new file mode 100644 index 0000000..f10607c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/FETCH_HEAD @@ -0,0 +1 @@ +b182a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7 tag 'v1.2.0' of https://github.com/FiloSottile/edwards25519 diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/HEAD b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/config b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/config new file mode 100644 index 0000000..be5ff91 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/config @@ -0,0 +1,9 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/FiloSottile/edwards25519 + fetch = +refs/heads/*:refs/remotes/origin/* diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/description b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/applypatch-msg.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/applypatch-msg.sample new file mode 100755 index 0000000..a5d7b84 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/commit-msg.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/commit-msg.sample new file mode 100755 index 0000000..b58d118 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/fsmonitor-watchman.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000..23e856f --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/fsmonitor-watchman.sample @@ -0,0 +1,174 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 2) and last update token +# formatted as a string and outputs to stdout a new update token and +# all files that have been modified since the update token. Paths must +# be relative to the root of the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $last_update_token) = @ARGV; + +# Uncomment for debugging +# print STDERR "$0 $version $last_update_token\n"; + +# Check the hook interface version +if ($version ne 2) { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree = get_working_dir(); + +my $retry = 1; + +my $json_pkg; +eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; +} or do { + require JSON::PP; + $json_pkg = "JSON::PP"; +}; + +launch_watchman(); + +sub launch_watchman { + my $o = watchman_query(); + if (is_work_tree_watched($o)) { + output_result($o->{clock}, @{$o->{files}}); + } +} + +sub output_result { + my ($clockid, @files) = @_; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # binmode $fh, ":utf8"; + # print $fh "$clockid\n@files\n"; + # close $fh; + + binmode STDOUT, ":utf8"; + print $clockid; + print "\0"; + local $, = "\0"; + print @files; +} + +sub watchman_clock { + my $response = qx/watchman clock "$git_work_tree"/; + die "Failed to get clock id on '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + return $json_pkg->new->utf8->decode($response); +} + +sub watchman_query { + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $last_update_token but not from the .git folder. + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + my $last_update_line = ""; + if (substr($last_update_token, 0, 1) eq "c") { + $last_update_token = "\"$last_update_token\""; + $last_update_line = qq[\n"since": $last_update_token,]; + } + my $query = <<" END"; + ["query", "$git_work_tree", {$last_update_line + "fields": ["name"], + "expression": ["not", ["dirname", ".git"]] + }] + END + + # Uncomment for debugging the watchman query + # open (my $fh, ">", ".git/watchman-query.json"); + # print $fh $query; + # close $fh; + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + # Uncomment for debugging the watch response + # open ($fh, ">", ".git/watchman-response.json"); + # print $fh $response; + # close $fh; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + return $json_pkg->new->utf8->decode($response); +} + +sub is_work_tree_watched { + my ($output) = @_; + my $error = $output->{error}; + if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { + $retry--; + my $response = qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + $output = $json_pkg->new->utf8->decode($response); + $error = $output->{error}; + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # close $fh; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + my $o = watchman_clock(); + $error = $output->{error}; + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + output_result($o->{clock}, ("/")); + $last_update_token = $o->{clock}; + + eval { launch_watchman() }; + return 0; + } + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + return 1; +} + +sub get_working_dir { + my $working_dir; + if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $working_dir = Win32::GetCwd(); + $working_dir =~ tr/\\/\//; + } else { + require Cwd; + $working_dir = Cwd::cwd(); + } + + return $working_dir; +} diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/post-update.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/post-update.sample new file mode 100755 index 0000000..ec17ec1 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-applypatch.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-applypatch.sample new file mode 100755 index 0000000..4142082 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-commit.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-commit.sample new file mode 100755 index 0000000..29ed5ee --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff-index --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-merge-commit.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-merge-commit.sample new file mode 100755 index 0000000..399eab1 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-merge-commit.sample @@ -0,0 +1,13 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git merge" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message to +# stderr if it wants to stop the merge commit. +# +# To enable this hook, rename this file to "pre-merge-commit". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" +: diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-push.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-push.sample new file mode 100755 index 0000000..4ce688d --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-rebase.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-rebase.sample new file mode 100755 index 0000000..6cbef5c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-receive.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-receive.sample new file mode 100755 index 0000000..a1fd29e --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/prepare-commit-msg.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..10fa14c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/push-to-checkout.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/push-to-checkout.sample new file mode 100755 index 0000000..af5a0c0 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/push-to-checkout.sample @@ -0,0 +1,78 @@ +#!/bin/sh + +# An example hook script to update a checked-out tree on a git push. +# +# This hook is invoked by git-receive-pack(1) when it reacts to git +# push and updates reference(s) in its repository, and when the push +# tries to update the branch that is currently checked out and the +# receive.denyCurrentBranch configuration variable is set to +# updateInstead. +# +# By default, such a push is refused if the working tree and the index +# of the remote repository has any difference from the currently +# checked out commit; when both the working tree and the index match +# the current commit, they are updated to match the newly pushed tip +# of the branch. This hook is to be used to override the default +# behaviour; however the code below reimplements the default behaviour +# as a starting point for convenient modification. +# +# The hook receives the commit with which the tip of the current +# branch is going to be updated: +commit=$1 + +# It can exit with a non-zero status to refuse the push (when it does +# so, it must not modify the index or the working tree). +die () { + echo >&2 "$*" + exit 1 +} + +# Or it can make any necessary changes to the working tree and to the +# index to bring them to the desired state when the tip of the current +# branch is updated to the new commit, and exit with a zero status. +# +# For example, the hook can simply run git read-tree -u -m HEAD "$1" +# in order to emulate git fetch that is run in the reverse direction +# with git push, as the two-tree form of git read-tree -u -m is +# essentially the same as git switch or git checkout that switches +# branches while keeping the local changes in the working tree that do +# not interfere with the difference between the branches. + +# The below is a more-or-less exact translation to shell of the C code +# for the default behaviour for git's push-to-checkout hook defined in +# the push_to_deploy() function in builtin/receive-pack.c. +# +# Note that the hook will be executed from the repository directory, +# not from the working tree, so if you want to perform operations on +# the working tree, you will have to adapt your code accordingly, e.g. +# by adding "cd .." or using relative paths. + +if ! git update-index -q --ignore-submodules --refresh +then + die "Up-to-date check failed" +fi + +if ! git diff-files --quiet --ignore-submodules -- +then + die "Working directory has unstaged changes" +fi + +# This is a rough translation of: +# +# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX +if git cat-file -e HEAD 2>/dev/null +then + head=HEAD +else + head=$(git hash-object -t tree --stdin &2 + exit 1 +} + +unset GIT_DIR GIT_WORK_TREE +cd "$worktree" && + +if grep -q "^diff --git " "$1" +then + validate_patch "$1" +else + validate_cover_letter "$1" +fi && + +if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL" +then + git config --unset-all sendemail.validateWorktree && + trap 'git worktree remove -ff "$worktree"' EXIT && + validate_series +fi diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/update.sample b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/update.sample new file mode 100755 index 0000000..c4d426b --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --type=bool hooks.allowunannotated) +allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) +denycreatebranch=$(git config --type=bool hooks.denycreatebranch) +allowdeletetag=$(git config --type=bool hooks.allowdeletetag) +allowmodifytag=$(git config --type=bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin &2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/info/attributes b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/info/attributes new file mode 100644 index 0000000..af85fe5 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/info/attributes @@ -0,0 +1,2 @@ + +* -export-subst -export-ignore diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/info/exclude b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/00/bf8f4479225b29048dec9f1d899c242f6bfef9 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/00/bf8f4479225b29048dec9f1d899c242f6bfef9 new file mode 100644 index 0000000..4b0c13f --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/00/bf8f4479225b29048dec9f1d899c242f6bfef9 @@ -0,0 +1,2 @@ +xJAE]W\w&0B0Џf~@{ r*͙x`)P ;t^H5(SsU.Uoa݁3&kU{gp>D626J=HKBC&SuT2K(][LC뇉oc{|ZCHAE+Yny +岕fu*?c`сt \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/0c/81239458d04a1f5d84cacdd3f1af36a082004b b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/0c/81239458d04a1f5d84cacdd3f1af36a082004b new file mode 100644 index 0000000..b5e49b3 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/0c/81239458d04a1f5d84cacdd3f1af36a082004b differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/1e/f503b9a2ff52bdf9f0b2c57ad930ffec668c3b b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/1e/f503b9a2ff52bdf9f0b2c57ad930ffec668c3b new file mode 100644 index 0000000..f8d842d Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/1e/f503b9a2ff52bdf9f0b2c57ad930ffec668c3b differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/2e/5782b6058c535c1b84651ba74ebd1eb05b9652 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/2e/5782b6058c535c1b84651ba74ebd1eb05b9652 new file mode 100644 index 0000000..1b1d37f Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/2e/5782b6058c535c1b84651ba74ebd1eb05b9652 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/3b/b606b1df622d6d2c69fbaefb024c887a204bd8 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/3b/b606b1df622d6d2c69fbaefb024c887a204bd8 new file mode 100644 index 0000000..5fad633 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/3b/b606b1df622d6d2c69fbaefb024c887a204bd8 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/48/3bba883964dc78c043f7d68b592e7a44a3715b b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/48/3bba883964dc78c043f7d68b592e7a44a3715b new file mode 100644 index 0000000..b2504aa Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/48/3bba883964dc78c043f7d68b592e7a44a3715b differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4a/00c79ace52bfc930a7f50699b69e9c50764e5e b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4a/00c79ace52bfc930a7f50699b69e9c50764e5e new file mode 100644 index 0000000..b35ebe6 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4a/00c79ace52bfc930a7f50699b69e9c50764e5e differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4a/2b54ebadf285c9b2c363fd5050235a15fcc3a7 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4a/2b54ebadf285c9b2c363fd5050235a15fcc3a7 new file mode 100644 index 0000000..32997f0 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4a/2b54ebadf285c9b2c363fd5050235a15fcc3a7 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4b/81f25d1d0f5336d8c4af79fe6e7b2c063d9740 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4b/81f25d1d0f5336d8c4af79fe6e7b2c063d9740 new file mode 100644 index 0000000..2bfb2fb --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4b/81f25d1d0f5336d8c4af79fe6e7b2c063d9740 @@ -0,0 +1,2 @@ +x]N0`~֠R@HVJT!8&p?q[ǝo 拓 +Fo>b +\^x k2Ş}(I@~K "(ք|v%H!qWߟ8Z+k,c/#th-'a\ 깮KȤ&q*7c!dZ69lbYeiC.<6kru{?wlҽj \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4d/52cc10d1900a915e17ca7b8096b63ce27451c5 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4d/52cc10d1900a915e17ca7b8096b63ce27451c5 new file mode 100644 index 0000000..e9c1d13 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/4d/52cc10d1900a915e17ca7b8096b63ce27451c5 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/5e/06e242ed8e92221ebef566714201d7f39182d3 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/5e/06e242ed8e92221ebef566714201d7f39182d3 new file mode 100644 index 0000000..ead3c8c Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/5e/06e242ed8e92221ebef566714201d7f39182d3 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/66/5058ee887fd1584b32c20937259d1434bfcee8 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/66/5058ee887fd1584b32c20937259d1434bfcee8 new file mode 100644 index 0000000..36d40e7 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/66/5058ee887fd1584b32c20937259d1434bfcee8 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/6a/66aea5eafe0ca6a688840c47219556c552488e b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/6a/66aea5eafe0ca6a688840c47219556c552488e new file mode 100644 index 0000000..4e22414 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/6a/66aea5eafe0ca6a688840c47219556c552488e differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/76/e920a2feb2e22239934fdfd1d3f1b369b0c375 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/76/e920a2feb2e22239934fdfd1d3f1b369b0c375 new file mode 100644 index 0000000..d9626fb Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/76/e920a2feb2e22239934fdfd1d3f1b369b0c375 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/7d/8bea03a80e6d5575e731a18eb26fedb585df57 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/7d/8bea03a80e6d5575e731a18eb26fedb585df57 new file mode 100644 index 0000000..8d6eb1b Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/7d/8bea03a80e6d5575e731a18eb26fedb585df57 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/8c/d865de788cc2cb12f7f428f679432c2213c224 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/8c/d865de788cc2cb12f7f428f679432c2213c224 new file mode 100644 index 0000000..b555a67 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/8c/d865de788cc2cb12f7f428f679432c2213c224 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/8f/21212a398541f5c51c46a0dbf10b1514a06add b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/8f/21212a398541f5c51c46a0dbf10b1514a06add new file mode 100644 index 0000000..c11ff56 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/8f/21212a398541f5c51c46a0dbf10b1514a06add differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/95/b081d9e79ad6a3195835ec03c44a5dc8a11eee b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/95/b081d9e79ad6a3195835ec03c44a5dc8a11eee new file mode 100644 index 0000000..5901df3 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/95/b081d9e79ad6a3195835ec03c44a5dc8a11eee differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/96/deeaede0a8a9d670aa7ba2b090bcb64a2f2f81 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/96/deeaede0a8a9d670aa7ba2b090bcb64a2f2f81 new file mode 100644 index 0000000..a8e0610 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/96/deeaede0a8a9d670aa7ba2b090bcb64a2f2f81 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/9a/bbc42457fefb0650fe8bd77d9edeeb52d68a0c b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/9a/bbc42457fefb0650fe8bd77d9edeeb52d68a0c new file mode 100644 index 0000000..b4fe9b3 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/9a/bbc42457fefb0650fe8bd77d9edeeb52d68a0c differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/a4/820a23c4ac77c99a50eeb87b372c8410fdc48b b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/a4/820a23c4ac77c99a50eeb87b372c8410fdc48b new file mode 100644 index 0000000..e91a21c Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/a4/820a23c4ac77c99a50eeb87b372c8410fdc48b differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/a7/44da2c6d3edbdb6d02665c4bf4d92f03205d0c b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/a7/44da2c6d3edbdb6d02665c4bf4d92f03205d0c new file mode 100644 index 0000000..0e80ade Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/a7/44da2c6d3edbdb6d02665c4bf4d92f03205d0c differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ad/6d326501029c0a8f72f8054b220a08179fe053 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ad/6d326501029c0a8f72f8054b220a08179fe053 new file mode 100644 index 0000000..57b33ee Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ad/6d326501029c0a8f72f8054b220a08179fe053 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b1/82a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b1/82a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7 new file mode 100644 index 0000000..13a814e --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b1/82a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7 @@ -0,0 +1 @@ +xN0 EY+G6m+$~ji:bݍ}u)_kat-N=G5)Ș&SpBвNI3ƪNcfkxzP0[%Gs,UvNvTC[Sw|Ҳb^fɧW@!%"!c LqO@H[Yj)|^J.{q/MC OG`z+^K|@i5eHgGGB-|m!>rq:S \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b2/68878912197b9b7710495ae1d156006dfd094d b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b2/68878912197b9b7710495ae1d156006dfd094d new file mode 100644 index 0000000..97cdee7 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b2/68878912197b9b7710495ae1d156006dfd094d differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b5/794635d62c9c4323f9145208370cff7fe98c6c b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b5/794635d62c9c4323f9145208370cff7fe98c6c new file mode 100644 index 0000000..93531f7 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b5/794635d62c9c4323f9145208370cff7fe98c6c @@ -0,0 +1 @@ +xmAK0=SB/Kz &M*CjԼdNʾIVΝFn.:*Ud)ѝs祥wMbԻms "^grJk=/55#l.wuaAl5(?Pj5_f<qR1EH,l;Zg^Gm++N4 E7dF6|9'%Gǻ4m6ً | \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b5/d161ad495146ef51512a0b5bb271bbf995498d b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b5/d161ad495146ef51512a0b5bb271bbf995498d new file mode 100644 index 0000000..c4a04b2 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/b5/d161ad495146ef51512a0b5bb271bbf995498d differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/dc/dd8d85fc230e8c577d25c08c91797471be5719 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/dc/dd8d85fc230e8c577d25c08c91797471be5719 new file mode 100644 index 0000000..ec48446 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/dc/dd8d85fc230e8c577d25c08c91797471be5719 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/dd/2deb64493f88f70b4f6710584fb5a8791896ab b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/dd/2deb64493f88f70b4f6710584fb5a8791896ab new file mode 100644 index 0000000..7b4484a Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/dd/2deb64493f88f70b4f6710584fb5a8791896ab differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/e2/0ec3869f4535f5445ff91f0f9e0c759e181e69 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/e2/0ec3869f4535f5445ff91f0f9e0c759e181e69 new file mode 100644 index 0000000..7c21cad Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/e2/0ec3869f4535f5445ff91f0f9e0c759e181e69 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ee/9b5ca5bfe20398de497b3c47907e7b412ccb44 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ee/9b5ca5bfe20398de497b3c47907e7b412ccb44 new file mode 100644 index 0000000..f3a3c7e Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ee/9b5ca5bfe20398de497b3c47907e7b412ccb44 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ef/1f15a5dc05987672f1a7ee5bcfeecd906d1507 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ef/1f15a5dc05987672f1a7ee5bcfeecd906d1507 new file mode 100644 index 0000000..f381f2a Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/ef/1f15a5dc05987672f1a7ee5bcfeecd906d1507 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f0/8b26245c2d1bd474d7a89df0c6964af54ab463 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f0/8b26245c2d1bd474d7a89df0c6964af54ab463 new file mode 100644 index 0000000..5fa1f86 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f0/8b26245c2d1bd474d7a89df0c6964af54ab463 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f4/81953587acbc5d636020e7788af5c2c283016b b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f4/81953587acbc5d636020e7788af5c2c283016b new file mode 100644 index 0000000..eb5ea88 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f4/81953587acbc5d636020e7788af5c2c283016b differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f6/217c96e2edcb1a67bae8f2030925bbffafc5a5 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f6/217c96e2edcb1a67bae8f2030925bbffafc5a5 new file mode 100644 index 0000000..d5c727e Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f6/217c96e2edcb1a67bae8f2030925bbffafc5a5 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f7/ca3cef993c0cc1de0bc6101cdac1fe5c7d3b17 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f7/ca3cef993c0cc1de0bc6101cdac1fe5c7d3b17 new file mode 100644 index 0000000..629131e Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/f7/ca3cef993c0cc1de0bc6101cdac1fe5c7d3b17 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/fb/80ca88fe255dff2a7b1a330e29fa01ff3779b2 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/fb/80ca88fe255dff2a7b1a330e29fa01ff3779b2 new file mode 100644 index 0000000..2e0af6b Binary files /dev/null and b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/objects/fb/80ca88fe255dff2a7b1a330e29fa01ff3779b2 differ diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/refs/tags/v1.2.0 b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/refs/tags/v1.2.0 new file mode 100644 index 0000000..979f2ca --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/refs/tags/v1.2.0 @@ -0,0 +1 @@ +b182a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7 diff --git a/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/shallow b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/shallow new file mode 100644 index 0000000..979f2ca --- /dev/null +++ b/platform/pkg/mod/cache/vcs/41754dd3b2793c060e8e10e2556d901cb8141ec90c971c1cc6402f50aa5ff099/shallow @@ -0,0 +1 @@ +b182a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7 diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714.info b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714.info new file mode 100644 index 0000000..7d2159e --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714.info @@ -0,0 +1 @@ +git3:https://github.com/go-sql-driver/mysql \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714.lock b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714.lock new file mode 100644 index 0000000..e69de29 diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/FETCH_HEAD b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/FETCH_HEAD new file mode 100644 index 0000000..0074d9f --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/FETCH_HEAD @@ -0,0 +1 @@ +a065b60ab6d0c8e15468e7709c7f76acf4431647 tag 'v1.10.0' of https://github.com/go-sql-driver/mysql diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/HEAD b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/config b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/config new file mode 100644 index 0000000..073cd77 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/config @@ -0,0 +1,9 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/go-sql-driver/mysql + fetch = +refs/heads/*:refs/remotes/origin/* diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/description b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/applypatch-msg.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/applypatch-msg.sample new file mode 100755 index 0000000..a5d7b84 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/commit-msg.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/commit-msg.sample new file mode 100755 index 0000000..b58d118 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/fsmonitor-watchman.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000..23e856f --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/fsmonitor-watchman.sample @@ -0,0 +1,174 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 2) and last update token +# formatted as a string and outputs to stdout a new update token and +# all files that have been modified since the update token. Paths must +# be relative to the root of the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $last_update_token) = @ARGV; + +# Uncomment for debugging +# print STDERR "$0 $version $last_update_token\n"; + +# Check the hook interface version +if ($version ne 2) { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree = get_working_dir(); + +my $retry = 1; + +my $json_pkg; +eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; +} or do { + require JSON::PP; + $json_pkg = "JSON::PP"; +}; + +launch_watchman(); + +sub launch_watchman { + my $o = watchman_query(); + if (is_work_tree_watched($o)) { + output_result($o->{clock}, @{$o->{files}}); + } +} + +sub output_result { + my ($clockid, @files) = @_; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # binmode $fh, ":utf8"; + # print $fh "$clockid\n@files\n"; + # close $fh; + + binmode STDOUT, ":utf8"; + print $clockid; + print "\0"; + local $, = "\0"; + print @files; +} + +sub watchman_clock { + my $response = qx/watchman clock "$git_work_tree"/; + die "Failed to get clock id on '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + return $json_pkg->new->utf8->decode($response); +} + +sub watchman_query { + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $last_update_token but not from the .git folder. + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + my $last_update_line = ""; + if (substr($last_update_token, 0, 1) eq "c") { + $last_update_token = "\"$last_update_token\""; + $last_update_line = qq[\n"since": $last_update_token,]; + } + my $query = <<" END"; + ["query", "$git_work_tree", {$last_update_line + "fields": ["name"], + "expression": ["not", ["dirname", ".git"]] + }] + END + + # Uncomment for debugging the watchman query + # open (my $fh, ">", ".git/watchman-query.json"); + # print $fh $query; + # close $fh; + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + # Uncomment for debugging the watch response + # open ($fh, ">", ".git/watchman-response.json"); + # print $fh $response; + # close $fh; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + return $json_pkg->new->utf8->decode($response); +} + +sub is_work_tree_watched { + my ($output) = @_; + my $error = $output->{error}; + if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { + $retry--; + my $response = qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + $output = $json_pkg->new->utf8->decode($response); + $error = $output->{error}; + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # close $fh; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + my $o = watchman_clock(); + $error = $output->{error}; + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + output_result($o->{clock}, ("/")); + $last_update_token = $o->{clock}; + + eval { launch_watchman() }; + return 0; + } + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + return 1; +} + +sub get_working_dir { + my $working_dir; + if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $working_dir = Win32::GetCwd(); + $working_dir =~ tr/\\/\//; + } else { + require Cwd; + $working_dir = Cwd::cwd(); + } + + return $working_dir; +} diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/post-update.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/post-update.sample new file mode 100755 index 0000000..ec17ec1 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-applypatch.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-applypatch.sample new file mode 100755 index 0000000..4142082 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-commit.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-commit.sample new file mode 100755 index 0000000..29ed5ee --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff-index --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-merge-commit.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-merge-commit.sample new file mode 100755 index 0000000..399eab1 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-merge-commit.sample @@ -0,0 +1,13 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git merge" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message to +# stderr if it wants to stop the merge commit. +# +# To enable this hook, rename this file to "pre-merge-commit". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" +: diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-push.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-push.sample new file mode 100755 index 0000000..4ce688d --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-rebase.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-rebase.sample new file mode 100755 index 0000000..6cbef5c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-receive.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-receive.sample new file mode 100755 index 0000000..a1fd29e --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/prepare-commit-msg.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..10fa14c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/push-to-checkout.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/push-to-checkout.sample new file mode 100755 index 0000000..af5a0c0 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/push-to-checkout.sample @@ -0,0 +1,78 @@ +#!/bin/sh + +# An example hook script to update a checked-out tree on a git push. +# +# This hook is invoked by git-receive-pack(1) when it reacts to git +# push and updates reference(s) in its repository, and when the push +# tries to update the branch that is currently checked out and the +# receive.denyCurrentBranch configuration variable is set to +# updateInstead. +# +# By default, such a push is refused if the working tree and the index +# of the remote repository has any difference from the currently +# checked out commit; when both the working tree and the index match +# the current commit, they are updated to match the newly pushed tip +# of the branch. This hook is to be used to override the default +# behaviour; however the code below reimplements the default behaviour +# as a starting point for convenient modification. +# +# The hook receives the commit with which the tip of the current +# branch is going to be updated: +commit=$1 + +# It can exit with a non-zero status to refuse the push (when it does +# so, it must not modify the index or the working tree). +die () { + echo >&2 "$*" + exit 1 +} + +# Or it can make any necessary changes to the working tree and to the +# index to bring them to the desired state when the tip of the current +# branch is updated to the new commit, and exit with a zero status. +# +# For example, the hook can simply run git read-tree -u -m HEAD "$1" +# in order to emulate git fetch that is run in the reverse direction +# with git push, as the two-tree form of git read-tree -u -m is +# essentially the same as git switch or git checkout that switches +# branches while keeping the local changes in the working tree that do +# not interfere with the difference between the branches. + +# The below is a more-or-less exact translation to shell of the C code +# for the default behaviour for git's push-to-checkout hook defined in +# the push_to_deploy() function in builtin/receive-pack.c. +# +# Note that the hook will be executed from the repository directory, +# not from the working tree, so if you want to perform operations on +# the working tree, you will have to adapt your code accordingly, e.g. +# by adding "cd .." or using relative paths. + +if ! git update-index -q --ignore-submodules --refresh +then + die "Up-to-date check failed" +fi + +if ! git diff-files --quiet --ignore-submodules -- +then + die "Working directory has unstaged changes" +fi + +# This is a rough translation of: +# +# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX +if git cat-file -e HEAD 2>/dev/null +then + head=HEAD +else + head=$(git hash-object -t tree --stdin &2 + exit 1 +} + +unset GIT_DIR GIT_WORK_TREE +cd "$worktree" && + +if grep -q "^diff --git " "$1" +then + validate_patch "$1" +else + validate_cover_letter "$1" +fi && + +if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL" +then + git config --unset-all sendemail.validateWorktree && + trap 'git worktree remove -ff "$worktree"' EXIT && + validate_series +fi diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/update.sample b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/update.sample new file mode 100755 index 0000000..c4d426b --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --type=bool hooks.allowunannotated) +allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) +denycreatebranch=$(git config --type=bool hooks.denycreatebranch) +allowdeletetag=$(git config --type=bool hooks.allowdeletetag) +allowmodifytag=$(git config --type=bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin &2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/info/attributes b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/info/attributes new file mode 100644 index 0000000..af85fe5 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/info/attributes @@ -0,0 +1,2 @@ + +* -export-subst -export-ignore diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/info/exclude b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/00/5a61c183f1fbf6134cf211b43fee0b170063a3 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/00/5a61c183f1fbf6134cf211b43fee0b170063a3 new file mode 100644 index 0000000..f19a930 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/00/5a61c183f1fbf6134cf211b43fee0b170063a3 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/02/61903b9df868a569ef1e3d8cb29b193b72312d b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/02/61903b9df868a569ef1e3d8cb29b193b72312d new file mode 100644 index 0000000..6192265 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/02/61903b9df868a569ef1e3d8cb29b193b72312d differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/03/0deaefa0c4ae7c125992d9ffa698edde881c5c b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/03/0deaefa0c4ae7c125992d9ffa698edde881c5c new file mode 100644 index 0000000..63b99b7 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/03/0deaefa0c4ae7c125992d9ffa698edde881c5c differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/07/1b69a9115d25c042b3ff4fcbe177ab19616096 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/07/1b69a9115d25c042b3ff4fcbe177ab19616096 new file mode 100644 index 0000000..e3fe3db Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/07/1b69a9115d25c042b3ff4fcbe177ab19616096 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/0e/bf05c213023a4026bce0a34fca04412734c7bc b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/0e/bf05c213023a4026bce0a34fca04412734c7bc new file mode 100644 index 0000000..6fc7715 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/0e/bf05c213023a4026bce0a34fca04412734c7bc @@ -0,0 +1,2 @@ +x=Mo1_Rn#!DoQ) G쮩׳턀L %̌\+k=>~= X8gGǢIoZ]3`rͳXk!cywOib^0TaD:cERdK2B}?ȌL2&p7pؕ:47b_aYU68OY|]28<(T;v> uv^HpCӃKj7[-.> +>_U䠄Y9v8[թu|G=\ &o[`@ [A~!'t[:7';‚#M 63 xPGB, V'.z}F=y$V@eQB? irAd⦮UUr4֎<F7 K폪wfom֪[b%F#-b~=e bh]6ǁ({ɗ:!ʋW$/6*xF3Zx'i\\ _o٭,#zƺq?7q"@ \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/61/0044fc16ac510e9c0fcfd56fcec013d1807bf7 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/61/0044fc16ac510e9c0fcfd56fcec013d1807bf7 new file mode 100644 index 0000000..8870066 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/61/0044fc16ac510e9c0fcfd56fcec013d1807bf7 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/65/204e2d212113d8e32fd18ee7664ad0be1a0b9b b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/65/204e2d212113d8e32fd18ee7664ad0be1a0b9b new file mode 100644 index 0000000..9ba3465 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/65/204e2d212113d8e32fd18ee7664ad0be1a0b9b differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/6f/0cdf3032357a8150c4f8dc8bbc8279c7cddaed b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/6f/0cdf3032357a8150c4f8dc8bbc8279c7cddaed new file mode 100644 index 0000000..2bb1b27 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/6f/0cdf3032357a8150c4f8dc8bbc8279c7cddaed differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/6f/5c7ebeb7350d1036ef541ff206606e96e9f666 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/6f/5c7ebeb7350d1036ef541ff206606e96e9f666 new file mode 100644 index 0000000..5236f44 --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/6f/5c7ebeb7350d1036ef541ff206606e96e9f666 @@ -0,0 +1,6 @@ +xMKK0]WFrYf +nDu%. 䖙{ӡn;U]SGw& tl|:u&ȌGjEw/=JJd(Pʚqt: 3^~@}t FS0nz{R +\fyf=vR&]4 +MK& +p>+qI~t +'SYo>_ʱi \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/72/8ab9fc7778ee9ad010a9d67033697a4146354b b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/72/8ab9fc7778ee9ad010a9d67033697a4146354b new file mode 100644 index 0000000..e57c5b4 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/72/8ab9fc7778ee9ad010a9d67033697a4146354b differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/82/d8c5989dc90094469059f84f29db2eb45d5f0f b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/82/d8c5989dc90094469059f84f29db2eb45d5f0f new file mode 100644 index 0000000..76e00f9 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/82/d8c5989dc90094469059f84f29db2eb45d5f0f differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/82/dc0f9b6cecf296a886635a2b20b65a67dd3398 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/82/dc0f9b6cecf296a886635a2b20b65a67dd3398 new file mode 100644 index 0000000..e4fa5fb Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/82/dc0f9b6cecf296a886635a2b20b65a67dd3398 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/87/844a9b2ab7d15cc351667cb815fd2059cf4cf5 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/87/844a9b2ab7d15cc351667cb815fd2059cf4cf5 new file mode 100644 index 0000000..6428d8e Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/87/844a9b2ab7d15cc351667cb815fd2059cf4cf5 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8c/502f49e6013bae93152155721d9eb71bec2366 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8c/502f49e6013bae93152155721d9eb71bec2366 new file mode 100644 index 0000000..f6b6f5c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8c/502f49e6013bae93152155721d9eb71bec2366 @@ -0,0 +1,2 @@ +xՒ͎0Oq4R4u.JQE)3ٰt18vN +w&0 %srwҸ/_xn?exQy,> I'ӀJDQ WW,MN^%FQd1r0.61^只htT;߂Е_Q6Kkcv]iɴT6K9c[C@R2 w"JuEUNdžTF]ϮB*&4mϵ4e)UK9crОhUAE*$?جx6:6ZvXY$\sB̑(A!iaff^[~kj\hݙ1Ѐ]Y|'sR ?~xO?}*̆o^bs₨SC!o1%`7Yf# \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8f/9122a0f265286cceebac1cdac9aa164a76a40b b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8f/9122a0f265286cceebac1cdac9aa164a76a40b new file mode 100644 index 0000000..24436bb Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8f/9122a0f265286cceebac1cdac9aa164a76a40b differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8f/e16bcb497b7ce4bfa7deac567276ead4c17f81 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8f/e16bcb497b7ce4bfa7deac567276ead4c17f81 new file mode 100644 index 0000000..a77d381 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/8f/e16bcb497b7ce4bfa7deac567276ead4c17f81 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/a0/65b60ab6d0c8e15468e7709c7f76acf4431647 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/a0/65b60ab6d0c8e15468e7709c7f76acf4431647 new file mode 100644 index 0000000..412d87a Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/a0/65b60ab6d0c8e15468e7709c7f76acf4431647 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/b2/4af9bed6ceccae71368dfa79d3e92c76a7710e b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/b2/4af9bed6ceccae71368dfa79d3e92c76a7710e new file mode 100644 index 0000000..4c2fde7 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/b2/4af9bed6ceccae71368dfa79d3e92c76a7710e differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/b4/87051e2d18c4a177d305174d31eb7a571348d5 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/b4/87051e2d18c4a177d305174d31eb7a571348d5 new file mode 100644 index 0000000..57c1d07 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/b4/87051e2d18c4a177d305174d31eb7a571348d5 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/c4/ec989c618573a00c998a958491ab1fb33b1115 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/c4/ec989c618573a00c998a958491ab1fb33b1115 new file mode 100644 index 0000000..101d0da Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/c4/ec989c618573a00c998a958491ab1fb33b1115 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/c5/613332ef24d31ec2865fbeab5e31174ef79e2d b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/c5/613332ef24d31ec2865fbeab5e31174ef79e2d new file mode 100644 index 0000000..b188caf Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/c5/613332ef24d31ec2865fbeab5e31174ef79e2d differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d0/b21b06c9e325b622ceeff877447e63ea397484 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d0/b21b06c9e325b622ceeff877447e63ea397484 new file mode 100644 index 0000000..74f2838 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d0/b21b06c9e325b622ceeff877447e63ea397484 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d8/63ab810e77a4d0db11e6c72f91e59627355d0f b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d8/63ab810e77a4d0db11e6c72f91e59627355d0f new file mode 100644 index 0000000..2af014b Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d8/63ab810e77a4d0db11e6c72f91e59627355d0f differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d9/771f1ddc4ecbde14a3f986b01f4c4a629b1f74 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d9/771f1ddc4ecbde14a3f986b01f4c4a629b1f74 new file mode 100644 index 0000000..f58726c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/d9/771f1ddc4ecbde14a3f986b01f4c4a629b1f74 @@ -0,0 +1 @@ +xMPKk@y^4ȢJVi-RR53& NMNi=,b:};r+W#h/ >F \ No newline at end of file diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/ee/9d964171ba583274d138656534a380b46fece3 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/ee/9d964171ba583274d138656534a380b46fece3 new file mode 100644 index 0000000..38a4051 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/ee/9d964171ba583274d138656534a380b46fece3 differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f2/aaf35bd5d60e43352b0801306fedc7d605a6be b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f2/aaf35bd5d60e43352b0801306fedc7d605a6be new file mode 100644 index 0000000..1fa73e4 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f2/aaf35bd5d60e43352b0801306fedc7d605a6be differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f8/95e87b3c4d8de8321351553b64aa7bde3d6d3d b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f8/95e87b3c4d8de8321351553b64aa7bde3d6d3d new file mode 100644 index 0000000..7441252 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f8/95e87b3c4d8de8321351553b64aa7bde3d6d3d differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f9/c5cb65cb680d52b260f337a7477816dd4affca b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f9/c5cb65cb680d52b260f337a7477816dd4affca new file mode 100644 index 0000000..d0c89c4 Binary files /dev/null and b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/objects/f9/c5cb65cb680d52b260f337a7477816dd4affca differ diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/refs/tags/v1.10.0 b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/refs/tags/v1.10.0 new file mode 100644 index 0000000..6b4b70c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/refs/tags/v1.10.0 @@ -0,0 +1 @@ +a065b60ab6d0c8e15468e7709c7f76acf4431647 diff --git a/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/shallow b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/shallow new file mode 100644 index 0000000..6b4b70c --- /dev/null +++ b/platform/pkg/mod/cache/vcs/825229b4e0cfd56be471a970fb75ba5dcb56cd0b82738f309ccb84fc69628714/shallow @@ -0,0 +1 @@ +a065b60ab6d0c8e15468e7709c7f76acf4431647 diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/.github/workflows/test.yml b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/.github/workflows/test.yml new file mode 100644 index 0000000..b579463 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Go tests +on: [push, pull_request] +jobs: + test: + name: Go ${{ matrix.go }} + runs-on: ubuntu-latest + strategy: + matrix: + go: [ '1.24', '1.x' ] + steps: + - uses: actions/setup-go@v2 + with: { go-version: "${{ matrix.go }}" } + - uses: actions/checkout@v2 + - run: go test -short ./... + - run: go test -short -tags purego ./... + - run: GOARCH=arm64 go test -c + - run: GOARCH=arm go test -c diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/LICENSE b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/README.md b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/README.md new file mode 100644 index 0000000..dcdd8d8 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/README.md @@ -0,0 +1,16 @@ +# filippo.io/edwards25519 + +``` +import "filippo.io/edwards25519" +``` + +This library implements the edwards25519 elliptic curve, exposing the necessary APIs to build a wide array of higher-level primitives. +Read the docs at [pkg.go.dev/filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519). + +The package tracks the upstream standard library package `crypto/internal/fips140/edwards25519` and extends it with additional functionality. + +The code is originally derived from Adam Langley's internal implementation in the Go standard library, and includes George Tankersley's [performance improvements](https://golang.org/cl/71950). It was then further developed by Henry de Valence for use in ristretto255, and was finally [merged back into the Go standard library](https://golang.org/cl/276272) as of Go 1.17. + +Most users don't need this package, and should instead use `crypto/ed25519` for signatures, `crypto/ecdh` for Diffie-Hellman, or `github.com/gtank/ristretto255` for prime order group logic. However, for anyone currently using a fork of the internal `edwards25519` package or of `github.com/agl/edwards25519`, this package should be a safer, faster, and more powerful alternative. + +Since this package is meant to curb proliferation of edwards25519 implementations in the Go ecosystem, it welcomes requests for new APIs or reviewable performance improvements. diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/doc.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/doc.go new file mode 100644 index 0000000..dd2deb6 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/doc.go @@ -0,0 +1,20 @@ +// Copyright (c) 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package edwards25519 implements group logic for the twisted Edwards curve +// +// -x^2 + y^2 = 1 + -(121665/121666)*x^2*y^2 +// +// This is better known as the Edwards curve equivalent to Curve25519, and is +// the curve used by the Ed25519 signature scheme. +// +// Most users don't need this package, and should instead use crypto/ed25519 for +// signatures, crypto/ecdh for Diffie-Hellman, or github.com/gtank/ristretto255 +// for prime order group logic. +// +// However, developers who do need to interact with low-level edwards25519 +// operations can use this package, which is an extended version of +// crypto/internal/fips140/edwards25519 from the standard library repackaged as +// an importable module. +package edwards25519 diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/edwards25519.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/edwards25519.go new file mode 100644 index 0000000..a744da2 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/edwards25519.go @@ -0,0 +1,427 @@ +// Copyright (c) 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import ( + "errors" + + "filippo.io/edwards25519/field" +) + +// Point types. + +type projP1xP1 struct { + X, Y, Z, T field.Element +} + +type projP2 struct { + X, Y, Z field.Element +} + +// Point represents a point on the edwards25519 curve. +// +// This type works similarly to math/big.Int, and all arguments and receivers +// are allowed to alias. +// +// The zero value is NOT valid, and it may be used only as a receiver. +type Point struct { + // Make the type not comparable (i.e. used with == or as a map key), as + // equivalent points can be represented by different Go values. + _ incomparable + + // The point is internally represented in extended coordinates (X, Y, Z, T) + // where x = X/Z, y = Y/Z, and xy = T/Z per https://eprint.iacr.org/2008/522. + x, y, z, t field.Element +} + +type incomparable [0]func() + +func checkInitialized(points ...*Point) { + for _, p := range points { + if p.x == (field.Element{}) && p.y == (field.Element{}) { + panic("edwards25519: use of uninitialized Point") + } + } +} + +type projCached struct { + YplusX, YminusX, Z, T2d field.Element +} + +type affineCached struct { + YplusX, YminusX, T2d field.Element +} + +// Constructors. + +func (v *projP2) Zero() *projP2 { + v.X.Zero() + v.Y.One() + v.Z.One() + return v +} + +// identity is the point at infinity. +var identity, _ = new(Point).SetBytes([]byte{ + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + +// NewIdentityPoint returns a new Point set to the identity. +func NewIdentityPoint() *Point { + return new(Point).Set(identity) +} + +// generator is the canonical curve basepoint. See TestGenerator for the +// correspondence of this encoding with the values in RFC 8032. +var generator, _ = new(Point).SetBytes([]byte{ + 0x58, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, + 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, + 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, + 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66}) + +// NewGeneratorPoint returns a new Point set to the canonical generator. +func NewGeneratorPoint() *Point { + return new(Point).Set(generator) +} + +func (v *projCached) Zero() *projCached { + v.YplusX.One() + v.YminusX.One() + v.Z.One() + v.T2d.Zero() + return v +} + +func (v *affineCached) Zero() *affineCached { + v.YplusX.One() + v.YminusX.One() + v.T2d.Zero() + return v +} + +// Assignments. + +// Set sets v = u, and returns v. +func (v *Point) Set(u *Point) *Point { + *v = *u + return v +} + +// Encoding. + +// Bytes returns the canonical 32-byte encoding of v, according to RFC 8032, +// Section 5.1.2. +func (v *Point) Bytes() []byte { + // This function is outlined to make the allocations inline in the caller + // rather than happen on the heap. + var buf [32]byte + return v.bytes(&buf) +} + +func (v *Point) bytes(buf *[32]byte) []byte { + checkInitialized(v) + + var zInv, x, y field.Element + zInv.Invert(&v.z) // zInv = 1 / Z + x.Multiply(&v.x, &zInv) // x = X / Z + y.Multiply(&v.y, &zInv) // y = Y / Z + + out := copyFieldElement(buf, &y) + out[31] |= byte(x.IsNegative() << 7) + return out +} + +var feOne = new(field.Element).One() + +// SetBytes sets v = x, where x is a 32-byte encoding of v. If x does not +// represent a valid point on the curve, SetBytes returns nil and an error and +// the receiver is unchanged. Otherwise, SetBytes returns v. +// +// Note that SetBytes accepts all non-canonical encodings of valid points. +// That is, it follows decoding rules that match most implementations in +// the ecosystem rather than RFC 8032. +func (v *Point) SetBytes(x []byte) (*Point, error) { + // Specifically, the non-canonical encodings that are accepted are + // 1) the ones where the field element is not reduced (see the + // (*field.Element).SetBytes docs) and + // 2) the ones where the x-coordinate is zero and the sign bit is set. + // + // Read more at https://hdevalence.ca/blog/2020-10-04-its-25519am, + // specifically the "Canonical A, R" section. + + y, err := new(field.Element).SetBytes(x) + if err != nil { + return nil, errors.New("edwards25519: invalid point encoding length") + } + + // -x² + y² = 1 + dx²y² + // x² + dx²y² = x²(dy² + 1) = y² - 1 + // x² = (y² - 1) / (dy² + 1) + + // u = y² - 1 + y2 := new(field.Element).Square(y) + u := new(field.Element).Subtract(y2, feOne) + + // v = dy² + 1 + vv := new(field.Element).Multiply(y2, d) + vv = vv.Add(vv, feOne) + + // x = +√(u/v) + xx, wasSquare := new(field.Element).SqrtRatio(u, vv) + if wasSquare == 0 { + return nil, errors.New("edwards25519: invalid point encoding") + } + + // Select the negative square root if the sign bit is set. + xxNeg := new(field.Element).Negate(xx) + xx = xx.Select(xxNeg, xx, int(x[31]>>7)) + + v.x.Set(xx) + v.y.Set(y) + v.z.One() + v.t.Multiply(xx, y) // xy = T / Z + + return v, nil +} + +func copyFieldElement(buf *[32]byte, v *field.Element) []byte { + copy(buf[:], v.Bytes()) + return buf[:] +} + +// Conversions. + +func (v *projP2) FromP1xP1(p *projP1xP1) *projP2 { + v.X.Multiply(&p.X, &p.T) + v.Y.Multiply(&p.Y, &p.Z) + v.Z.Multiply(&p.Z, &p.T) + return v +} + +func (v *projP2) FromP3(p *Point) *projP2 { + v.X.Set(&p.x) + v.Y.Set(&p.y) + v.Z.Set(&p.z) + return v +} + +func (v *Point) fromP1xP1(p *projP1xP1) *Point { + v.x.Multiply(&p.X, &p.T) + v.y.Multiply(&p.Y, &p.Z) + v.z.Multiply(&p.Z, &p.T) + v.t.Multiply(&p.X, &p.Y) + return v +} + +func (v *Point) fromP2(p *projP2) *Point { + v.x.Multiply(&p.X, &p.Z) + v.y.Multiply(&p.Y, &p.Z) + v.z.Square(&p.Z) + v.t.Multiply(&p.X, &p.Y) + return v +} + +// d is a constant in the curve equation. +var d, _ = new(field.Element).SetBytes([]byte{ + 0xa3, 0x78, 0x59, 0x13, 0xca, 0x4d, 0xeb, 0x75, + 0xab, 0xd8, 0x41, 0x41, 0x4d, 0x0a, 0x70, 0x00, + 0x98, 0xe8, 0x79, 0x77, 0x79, 0x40, 0xc7, 0x8c, + 0x73, 0xfe, 0x6f, 0x2b, 0xee, 0x6c, 0x03, 0x52}) +var d2 = new(field.Element).Add(d, d) + +func (v *projCached) FromP3(p *Point) *projCached { + v.YplusX.Add(&p.y, &p.x) + v.YminusX.Subtract(&p.y, &p.x) + v.Z.Set(&p.z) + v.T2d.Multiply(&p.t, d2) + return v +} + +func (v *affineCached) FromP3(p *Point) *affineCached { + v.YplusX.Add(&p.y, &p.x) + v.YminusX.Subtract(&p.y, &p.x) + v.T2d.Multiply(&p.t, d2) + + var invZ field.Element + invZ.Invert(&p.z) + v.YplusX.Multiply(&v.YplusX, &invZ) + v.YminusX.Multiply(&v.YminusX, &invZ) + v.T2d.Multiply(&v.T2d, &invZ) + return v +} + +// (Re)addition and subtraction. + +// Add sets v = p + q, and returns v. +func (v *Point) Add(p, q *Point) *Point { + checkInitialized(p, q) + qCached := new(projCached).FromP3(q) + result := new(projP1xP1).Add(p, qCached) + return v.fromP1xP1(result) +} + +// Subtract sets v = p - q, and returns v. +func (v *Point) Subtract(p, q *Point) *Point { + checkInitialized(p, q) + qCached := new(projCached).FromP3(q) + result := new(projP1xP1).Sub(p, qCached) + return v.fromP1xP1(result) +} + +func (v *projP1xP1) Add(p *Point, q *projCached) *projP1xP1 { + var YplusX, YminusX, PP, MM, TT2d, ZZ2 field.Element + + YplusX.Add(&p.y, &p.x) + YminusX.Subtract(&p.y, &p.x) + + PP.Multiply(&YplusX, &q.YplusX) + MM.Multiply(&YminusX, &q.YminusX) + TT2d.Multiply(&p.t, &q.T2d) + ZZ2.Multiply(&p.z, &q.Z) + + ZZ2.Add(&ZZ2, &ZZ2) + + v.X.Subtract(&PP, &MM) + v.Y.Add(&PP, &MM) + v.Z.Add(&ZZ2, &TT2d) + v.T.Subtract(&ZZ2, &TT2d) + return v +} + +func (v *projP1xP1) Sub(p *Point, q *projCached) *projP1xP1 { + var YplusX, YminusX, PP, MM, TT2d, ZZ2 field.Element + + YplusX.Add(&p.y, &p.x) + YminusX.Subtract(&p.y, &p.x) + + PP.Multiply(&YplusX, &q.YminusX) // flipped sign + MM.Multiply(&YminusX, &q.YplusX) // flipped sign + TT2d.Multiply(&p.t, &q.T2d) + ZZ2.Multiply(&p.z, &q.Z) + + ZZ2.Add(&ZZ2, &ZZ2) + + v.X.Subtract(&PP, &MM) + v.Y.Add(&PP, &MM) + v.Z.Subtract(&ZZ2, &TT2d) // flipped sign + v.T.Add(&ZZ2, &TT2d) // flipped sign + return v +} + +func (v *projP1xP1) AddAffine(p *Point, q *affineCached) *projP1xP1 { + var YplusX, YminusX, PP, MM, TT2d, Z2 field.Element + + YplusX.Add(&p.y, &p.x) + YminusX.Subtract(&p.y, &p.x) + + PP.Multiply(&YplusX, &q.YplusX) + MM.Multiply(&YminusX, &q.YminusX) + TT2d.Multiply(&p.t, &q.T2d) + + Z2.Add(&p.z, &p.z) + + v.X.Subtract(&PP, &MM) + v.Y.Add(&PP, &MM) + v.Z.Add(&Z2, &TT2d) + v.T.Subtract(&Z2, &TT2d) + return v +} + +func (v *projP1xP1) SubAffine(p *Point, q *affineCached) *projP1xP1 { + var YplusX, YminusX, PP, MM, TT2d, Z2 field.Element + + YplusX.Add(&p.y, &p.x) + YminusX.Subtract(&p.y, &p.x) + + PP.Multiply(&YplusX, &q.YminusX) // flipped sign + MM.Multiply(&YminusX, &q.YplusX) // flipped sign + TT2d.Multiply(&p.t, &q.T2d) + + Z2.Add(&p.z, &p.z) + + v.X.Subtract(&PP, &MM) + v.Y.Add(&PP, &MM) + v.Z.Subtract(&Z2, &TT2d) // flipped sign + v.T.Add(&Z2, &TT2d) // flipped sign + return v +} + +// Doubling. + +func (v *projP1xP1) Double(p *projP2) *projP1xP1 { + var XX, YY, ZZ2, XplusYsq field.Element + + XX.Square(&p.X) + YY.Square(&p.Y) + ZZ2.Square(&p.Z) + ZZ2.Add(&ZZ2, &ZZ2) + XplusYsq.Add(&p.X, &p.Y) + XplusYsq.Square(&XplusYsq) + + v.Y.Add(&YY, &XX) + v.Z.Subtract(&YY, &XX) + + v.X.Subtract(&XplusYsq, &v.Y) + v.T.Subtract(&ZZ2, &v.Z) + return v +} + +// Negation. + +// Negate sets v = -p, and returns v. +func (v *Point) Negate(p *Point) *Point { + checkInitialized(p) + v.x.Negate(&p.x) + v.y.Set(&p.y) + v.z.Set(&p.z) + v.t.Negate(&p.t) + return v +} + +// Equal returns 1 if v is equivalent to u, and 0 otherwise. +func (v *Point) Equal(u *Point) int { + checkInitialized(v, u) + + var t1, t2, t3, t4 field.Element + t1.Multiply(&v.x, &u.z) + t2.Multiply(&u.x, &v.z) + t3.Multiply(&v.y, &u.z) + t4.Multiply(&u.y, &v.z) + + return t1.Equal(&t2) & t3.Equal(&t4) +} + +// Constant-time operations + +// Select sets v to a if cond == 1 and to b if cond == 0. +func (v *projCached) Select(a, b *projCached, cond int) *projCached { + v.YplusX.Select(&a.YplusX, &b.YplusX, cond) + v.YminusX.Select(&a.YminusX, &b.YminusX, cond) + v.Z.Select(&a.Z, &b.Z, cond) + v.T2d.Select(&a.T2d, &b.T2d, cond) + return v +} + +// Select sets v to a if cond == 1 and to b if cond == 0. +func (v *affineCached) Select(a, b *affineCached, cond int) *affineCached { + v.YplusX.Select(&a.YplusX, &b.YplusX, cond) + v.YminusX.Select(&a.YminusX, &b.YminusX, cond) + v.T2d.Select(&a.T2d, &b.T2d, cond) + return v +} + +// CondNeg negates v if cond == 1 and leaves it unchanged if cond == 0. +func (v *projCached) CondNeg(cond int) *projCached { + v.YplusX.Swap(&v.YminusX, cond) + v.T2d.Select(new(field.Element).Negate(&v.T2d), &v.T2d, cond) + return v +} + +// CondNeg negates v if cond == 1 and leaves it unchanged if cond == 0. +func (v *affineCached) CondNeg(cond int) *affineCached { + v.YplusX.Swap(&v.YminusX, cond) + v.T2d.Select(new(field.Element).Negate(&v.T2d), &v.T2d, cond) + return v +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/edwards25519_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/edwards25519_test.go new file mode 100644 index 0000000..95b081d --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/edwards25519_test.go @@ -0,0 +1,314 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import ( + "encoding/hex" + "reflect" + "testing" + + "filippo.io/edwards25519/field" +) + +var B = NewGeneratorPoint() +var I = NewIdentityPoint() + +func checkOnCurve(t *testing.T, points ...*Point) { + t.Helper() + for i, p := range points { + if p.z.Equal(new(field.Element)) == 1 { + t.Errorf("point %d has Z == 0 (degenerate projective point)", i) + } + var XX, YY, ZZ, ZZZZ field.Element + XX.Square(&p.x) + YY.Square(&p.y) + ZZ.Square(&p.z) + ZZZZ.Square(&ZZ) + // -x² + y² = 1 + dx²y² + // -(X/Z)² + (Y/Z)² = 1 + d(X/Z)²(Y/Z)² + // (-X² + Y²)/Z² = 1 + (dX²Y²)/Z⁴ + // (-X² + Y²)*Z² = Z⁴ + dX²Y² + var lhs, rhs field.Element + lhs.Subtract(&YY, &XX).Multiply(&lhs, &ZZ) + rhs.Multiply(d, &XX).Multiply(&rhs, &YY).Add(&rhs, &ZZZZ) + if lhs.Equal(&rhs) != 1 { + t.Errorf("X, Y, and Z do not specify a point on the curve\nX = %v\nY = %v\nZ = %v", p.x, p.y, p.z) + } + // xy = T/Z + lhs.Multiply(&p.x, &p.y) + rhs.Multiply(&p.z, &p.t) + if lhs.Equal(&rhs) != 1 { + t.Errorf("point %d is not valid\nX = %v\nY = %v\nZ = %v", i, p.x, p.y, p.z) + } + } +} + +func TestGenerator(t *testing.T) { + // These are the coordinates of B from RFC 8032, Section 5.1, converted to + // little endian hex. + x := "1ad5258f602d56c9b2a7259560c72c695cdcd6fd31e2a4c0fe536ecdd3366921" + y := "5866666666666666666666666666666666666666666666666666666666666666" + if got := hex.EncodeToString(B.x.Bytes()); got != x { + t.Errorf("wrong B.x: got %s, expected %s", got, x) + } + if got := hex.EncodeToString(B.y.Bytes()); got != y { + t.Errorf("wrong B.y: got %s, expected %s", got, y) + } + if B.z.Equal(feOne) != 1 { + t.Errorf("wrong B.z: got %v, expected 1", B.z) + } + // Check that t is correct. + checkOnCurve(t, B) +} + +func TestAddSubNegOnBasePoint(t *testing.T) { + checkLhs, checkRhs := &Point{}, &Point{} + + checkLhs.Add(B, B) + tmpP2 := new(projP2).FromP3(B) + tmpP1xP1 := new(projP1xP1).Double(tmpP2) + checkRhs.fromP1xP1(tmpP1xP1) + if checkLhs.Equal(checkRhs) != 1 { + t.Error("B + B != [2]B") + } + checkOnCurve(t, checkLhs, checkRhs) + + checkLhs.Subtract(B, B) + Bneg := new(Point).Negate(B) + checkRhs.Add(B, Bneg) + if checkLhs.Equal(checkRhs) != 1 { + t.Error("B - B != B + (-B)") + } + if I.Equal(checkLhs) != 1 { + t.Error("B - B != 0") + } + if I.Equal(checkRhs) != 1 { + t.Error("B + (-B) != 0") + } + checkOnCurve(t, checkLhs, checkRhs, Bneg) +} + +func TestComparable(t *testing.T) { + if reflect.TypeOf(Point{}).Comparable() { + t.Error("Point is unexpectedly comparable") + } +} + +func TestInvalidEncodings(t *testing.T) { + // An invalid point, that also happens to have y > p. + invalid := "efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + p := NewGeneratorPoint() + if out, err := p.SetBytes(decodeHex(invalid)); err == nil { + t.Error("expected error for invalid point") + } else if out != nil { + t.Error("SetBytes did not return nil on an invalid encoding") + } else if p.Equal(B) != 1 { + t.Error("the Point was modified while decoding an invalid encoding") + } + checkOnCurve(t, p) +} + +func TestNonCanonicalPoints(t *testing.T) { + type test struct { + name string + encoding, canonical string + } + tests := []test{ + // Points with x = 0 and the sign bit set. With x = 0 the curve equation + // gives y² = 1, so y = ±1. 1 has two valid encodings. + { + "y=1,sign-", + "0100000000000000000000000000000000000000000000000000000000000080", + "0100000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+1,sign-", + "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0100000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p-1,sign-", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + }, + + // Non-canonical y encodings with values 2²⁵⁵-19 (p) to 2²⁵⁵-1 (p+18). + { + "y=p,sign+", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0000000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p,sign-", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0000000000000000000000000000000000000000000000000000000000000080", + }, + { + "y=p+1,sign+", + "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0100000000000000000000000000000000000000000000000000000000000000", + }, + // "y=p+1,sign-" is already tested above. + // p+2 is not a valid y-coordinate. + { + "y=p+3,sign+", + "f0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0300000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+3,sign-", + "f0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0300000000000000000000000000000000000000000000000000000000000080", + }, + { + "y=p+4,sign+", + "f1ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0400000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+4,sign-", + "f1ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0400000000000000000000000000000000000000000000000000000000000080", + }, + { + "y=p+5,sign+", + "f2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0500000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+5,sign-", + "f2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0500000000000000000000000000000000000000000000000000000000000080", + }, + { + "y=p+6,sign+", + "f3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0600000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+6,sign-", + "f3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0600000000000000000000000000000000000000000000000000000000000080", + }, + // p+7 is not a valid y-coordinate. + // p+8 is not a valid y-coordinate. + { + "y=p+9,sign+", + "f6ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0900000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+9,sign-", + "f6ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0900000000000000000000000000000000000000000000000000000000000080", + }, + { + "y=p+10,sign+", + "f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0a00000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+10,sign-", + "f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0a00000000000000000000000000000000000000000000000000000000000080", + }, + // p+11 is not a valid y-coordinate. + // p+12 is not a valid y-coordinate. + // p+13 is not a valid y-coordinate. + { + "y=p+14,sign+", + "fbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0e00000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+14,sign-", + "fbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0e00000000000000000000000000000000000000000000000000000000000080", + }, + { + "y=p+15,sign+", + "fcffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0f00000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+15,sign-", + "fcffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0f00000000000000000000000000000000000000000000000000000000000080", + }, + { + "y=p+16,sign+", + "fdffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "1000000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+16,sign-", + "fdffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "1000000000000000000000000000000000000000000000000000000000000080", + }, + // p+17 is not a valid y-coordinate. + { + "y=p+18,sign+", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "1200000000000000000000000000000000000000000000000000000000000000", + }, + { + "y=p+18,sign-", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "1200000000000000000000000000000000000000000000000000000000000080", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p1, err := new(Point).SetBytes(decodeHex(tt.encoding)) + if err != nil { + t.Fatalf("error decoding non-canonical point: %v", err) + } + p2, err := new(Point).SetBytes(decodeHex(tt.canonical)) + if err != nil { + t.Fatalf("error decoding canonical point: %v", err) + } + if p1.Equal(p2) != 1 { + t.Errorf("equivalent points are not equal: %v, %v", p1, p2) + } + if encoding := hex.EncodeToString(p1.Bytes()); encoding != tt.canonical { + t.Errorf("re-encoding does not match canonical; got %q, expected %q", encoding, tt.canonical) + } + checkOnCurve(t, p1, p2) + }) + } +} + +var testAllocationsSink byte + +func TestAllocations(t *testing.T) { + if allocs := testing.AllocsPerRun(100, func() { + p := NewIdentityPoint() + p.Add(p, NewGeneratorPoint()) + s := NewScalar() + testAllocationsSink ^= s.Bytes()[0] + testAllocationsSink ^= p.Bytes()[0] + }); allocs > 0 { + t.Errorf("expected zero allocations, got %0.1v", allocs) + } +} + +func decodeHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + +func BenchmarkEncodingDecoding(b *testing.B) { + p := new(Point).Set(dalekScalarBasepoint) + for i := 0; i < b.N; i++ { + buf := p.Bytes() + _, err := p.SetBytes(buf) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/extra.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/extra.go new file mode 100644 index 0000000..ee9b5ca --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/extra.go @@ -0,0 +1,401 @@ +// Copyright (c) 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +// This file contains additional functionality that is not included in the +// upstream crypto/internal/edwards25519 package. + +import ( + "errors" + "slices" + + "filippo.io/edwards25519/field" +) + +// ExtendedCoordinates returns v in extended coordinates (X:Y:Z:T) where +// x = X/Z, y = Y/Z, and xy = T/Z as in https://eprint.iacr.org/2008/522. +func (v *Point) ExtendedCoordinates() (X, Y, Z, T *field.Element) { + // This function is outlined to make the allocations inline in the caller + // rather than happen on the heap. Don't change the style without making + // sure it doesn't increase the inliner cost. + var e [4]field.Element + X, Y, Z, T = v.extendedCoordinates(&e) + return +} + +func (v *Point) extendedCoordinates(e *[4]field.Element) (X, Y, Z, T *field.Element) { + checkInitialized(v) + X = e[0].Set(&v.x) + Y = e[1].Set(&v.y) + Z = e[2].Set(&v.z) + T = e[3].Set(&v.t) + return +} + +// SetExtendedCoordinates sets v = (X:Y:Z:T) in extended coordinates where +// x = X/Z, y = Y/Z, and xy = T/Z as in https://eprint.iacr.org/2008/522. +// +// If the coordinates are invalid or don't represent a valid point on the curve, +// SetExtendedCoordinates returns nil and an error and the receiver is +// unchanged. Otherwise, SetExtendedCoordinates returns v. +func (v *Point) SetExtendedCoordinates(X, Y, Z, T *field.Element) (*Point, error) { + if !isOnCurve(X, Y, Z, T) { + return nil, errors.New("edwards25519: invalid point coordinates") + } + v.x.Set(X) + v.y.Set(Y) + v.z.Set(Z) + v.t.Set(T) + return v, nil +} + +func isOnCurve(X, Y, Z, T *field.Element) bool { + var lhs, rhs field.Element + XX := new(field.Element).Square(X) + YY := new(field.Element).Square(Y) + ZZ := new(field.Element).Square(Z) + TT := new(field.Element).Square(T) + // -x² + y² = 1 + dx²y² + // -(X/Z)² + (Y/Z)² = 1 + d(T/Z)² + // -X² + Y² = Z² + dT² + lhs.Subtract(YY, XX) + rhs.Multiply(d, TT).Add(&rhs, ZZ) + if lhs.Equal(&rhs) != 1 { + return false + } + // xy = T/Z + // XY/Z² = T/Z + // XY = TZ + lhs.Multiply(X, Y) + rhs.Multiply(T, Z) + return lhs.Equal(&rhs) == 1 +} + +// BytesMontgomery converts v to a point on the birationally-equivalent +// Curve25519 Montgomery curve, and returns its canonical 32 bytes encoding +// according to RFC 7748. +// +// Note that BytesMontgomery only encodes the u-coordinate, so v and -v encode +// to the same value. If v is the identity point, BytesMontgomery returns 32 +// zero bytes, analogously to the X25519 function. +// +// The lack of an inverse operation (such as SetMontgomeryBytes) is deliberate: +// while every valid edwards25519 point has a unique u-coordinate Montgomery +// encoding, X25519 accepts inputs on the quadratic twist, which don't correspond +// to any edwards25519 point, and every other X25519 input corresponds to two +// edwards25519 points. +func (v *Point) BytesMontgomery() []byte { + // This function is outlined to make the allocations inline in the caller + // rather than happen on the heap. + var buf [32]byte + return v.bytesMontgomery(&buf) +} + +func (v *Point) bytesMontgomery(buf *[32]byte) []byte { + checkInitialized(v) + + // RFC 7748, Section 4.1 provides the bilinear map to calculate the + // Montgomery u-coordinate + // + // u = (1 + y) / (1 - y) + // + // where y = Y / Z and therefore + // + // u = (Z + Y) / (Z - Y) + + var n, r, u field.Element + + n.Add(&v.z, &v.y) // n = Z + Y + r.Invert(r.Subtract(&v.z, &v.y)) // r = 1 / (Z - Y) + u.Multiply(&n, &r) // u = n * r + + return copyFieldElement(buf, &u) +} + +// MultByCofactor sets v = 8 * p, and returns v. +func (v *Point) MultByCofactor(p *Point) *Point { + checkInitialized(p) + result := projP1xP1{} + pp := (&projP2{}).FromP3(p) + result.Double(pp) + pp.FromP1xP1(&result) + result.Double(pp) + pp.FromP1xP1(&result) + result.Double(pp) + return v.fromP1xP1(&result) +} + +// Given k > 0, set s = s**(2*k). +func (s *Scalar) pow2k(k int) { + for i := 0; i < k; i++ { + s.Multiply(s, s) + } +} + +// Invert sets s to the inverse of a nonzero scalar v, and returns s. +// +// If t is zero, Invert returns zero. +func (s *Scalar) Invert(t *Scalar) *Scalar { + // Uses a hardcoded sliding window of width 4. + var table [8]Scalar + var tt Scalar + tt.Multiply(t, t) + table[0] = *t + for i := 0; i < 7; i++ { + table[i+1].Multiply(&table[i], &tt) + } + // Now table = [t**1, t**3, t**5, t**7, t**9, t**11, t**13, t**15] + // so t**k = t[k/2] for odd k + + // To compute the sliding window digits, use the following Sage script: + + // sage: import itertools + // sage: def sliding_window(w,k): + // ....: digits = [] + // ....: while k > 0: + // ....: if k % 2 == 1: + // ....: kmod = k % (2**w) + // ....: digits.append(kmod) + // ....: k = k - kmod + // ....: else: + // ....: digits.append(0) + // ....: k = k // 2 + // ....: return digits + + // Now we can compute s roughly as follows: + + // sage: s = 1 + // sage: for coeff in reversed(sliding_window(4,l-2)): + // ....: s = s*s + // ....: if coeff > 0 : + // ....: s = s*t**coeff + + // This works on one bit at a time, with many runs of zeros. + // The digits can be collapsed into [(count, coeff)] as follows: + + // sage: [(len(list(group)),d) for d,group in itertools.groupby(sliding_window(4,l-2))] + + // Entries of the form (k, 0) turn into pow2k(k) + // Entries of the form (1, coeff) turn into a squaring and then a table lookup. + // We can fold the squaring into the previous pow2k(k) as pow2k(k+1). + + *s = table[1/2] + s.pow2k(127 + 1) + s.Multiply(s, &table[1/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[9/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[11/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[13/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[15/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[7/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[15/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[5/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[1/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[15/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[15/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[7/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[3/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[11/2]) + s.pow2k(5 + 1) + s.Multiply(s, &table[11/2]) + s.pow2k(9 + 1) + s.Multiply(s, &table[9/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[3/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[3/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[3/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[9/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[7/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[3/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[13/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[7/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[9/2]) + s.pow2k(3 + 1) + s.Multiply(s, &table[15/2]) + s.pow2k(4 + 1) + s.Multiply(s, &table[11/2]) + + return s +} + +// MultiScalarMult sets v = sum(scalars[i] * points[i]), and returns v. +// +// Execution time depends only on the lengths of the two slices, which must match. +func (v *Point) MultiScalarMult(scalars []*Scalar, points []*Point) *Point { + if len(scalars) != len(points) { + panic("edwards25519: called MultiScalarMult with different size inputs") + } + checkInitialized(points...) + + // Proceed as in the single-base case, but share doublings + // between each point in the multiscalar equation. + + // Build lookup tables for each point + tables := make([]projLookupTable, 0, 2) // avoid allocation for small sizes + tables = slices.Grow(tables, len(points))[:len(points)] + for i := range tables { + tables[i].FromP3(points[i]) + } + // Compute signed radix-16 digits for each scalar + digits := make([][64]int8, 0, 2) // avoid allocation for small sizes + digits = slices.Grow(digits, len(scalars))[:len(scalars)] + for i := range digits { + digits[i] = scalars[i].signedRadix16() + } + + // Unwrap first loop iteration to save computing 16*identity + multiple := &projCached{} + tmp1 := &projP1xP1{} + tmp2 := &projP2{} + // Lookup-and-add the appropriate multiple of each input point + v.Set(NewIdentityPoint()) + for j := range tables { + tables[j].SelectInto(multiple, digits[j][63]) + tmp1.Add(v, multiple) // tmp1 = v + x_(j,63)*Q in P1xP1 coords + v.fromP1xP1(tmp1) // update v + } + tmp2.FromP3(v) // set up tmp2 = v in P2 coords for next iteration + for i := 62; i >= 0; i-- { + tmp1.Double(tmp2) // tmp1 = 2*(prev) in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 2*(prev) in P2 coords + tmp1.Double(tmp2) // tmp1 = 4*(prev) in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 4*(prev) in P2 coords + tmp1.Double(tmp2) // tmp1 = 8*(prev) in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 8*(prev) in P2 coords + tmp1.Double(tmp2) // tmp1 = 16*(prev) in P1xP1 coords + v.fromP1xP1(tmp1) // v = 16*(prev) in P3 coords + // Lookup-and-add the appropriate multiple of each input point + for j := range tables { + tables[j].SelectInto(multiple, digits[j][i]) + tmp1.Add(v, multiple) // tmp1 = v + x_(j,i)*Q in P1xP1 coords + v.fromP1xP1(tmp1) // update v + } + tmp2.FromP3(v) // set up tmp2 = v in P2 coords for next iteration + } + return v +} + +// VarTimeMultiScalarMult sets v = sum(scalars[i] * points[i]), and returns v. +// +// Execution time depends on the inputs. +func (v *Point) VarTimeMultiScalarMult(scalars []*Scalar, points []*Point) *Point { + if len(scalars) != len(points) { + panic("edwards25519: called VarTimeMultiScalarMult with different size inputs") + } + checkInitialized(points...) + + // Generalize double-base NAF computation to arbitrary sizes. + // Here all the points are dynamic, so we only use the smaller + // tables. + + // Build lookup tables for each point + tables := make([]nafLookupTable5, len(points)) + for i := range tables { + tables[i].FromP3(points[i]) + } + // Compute a NAF for each scalar + nafs := make([][256]int8, len(scalars)) + for i := range nafs { + nafs[i] = scalars[i].nonAdjacentForm(5) + } + + multiple := &projCached{} + tmp1 := &projP1xP1{} + tmp2 := &projP2{} + tmp2.Zero() + + // Move from high to low bits, doubling the accumulator + // at each iteration and checking whether there is a nonzero + // coefficient to look up a multiple of. + // + // Skip trying to find the first nonzero coefficent, because + // searching might be more work than a few extra doublings. + for i := 255; i >= 0; i-- { + tmp1.Double(tmp2) + + for j := range nafs { + if nafs[j][i] > 0 { + v.fromP1xP1(tmp1) + tables[j].SelectInto(multiple, nafs[j][i]) + tmp1.Add(v, multiple) + } else if nafs[j][i] < 0 { + v.fromP1xP1(tmp1) + tables[j].SelectInto(multiple, -nafs[j][i]) + tmp1.Sub(v, multiple) + } + } + + tmp2.FromP1xP1(tmp1) + } + + v.fromP2(tmp2) + return v +} + +// Select sets v to a if cond == 1 and to b if cond == 0. +func (v *Point) Select(a, b *Point, cond int) *Point { + checkInitialized(a, b) + v.x.Select(&a.x, &b.x, cond) + v.y.Select(&a.y, &b.y, cond) + v.z.Select(&a.z, &b.z, cond) + v.t.Select(&a.t, &b.t, cond) + return v +} + +// Double sets v = p + p, and returns v. +func (v *Point) Double(p *Point) *Point { + checkInitialized(p) + + pp := new(projP2).FromP3(p) + p1 := new(projP1xP1).Double(pp) + return v.fromP1xP1(p1) +} + +func (v *Point) addCached(p *Point, qCached *projCached) *Point { + result := new(projP1xP1).Add(p, qCached) + return v.fromP1xP1(result) +} + +// ScalarMultSlow sets v = x * q, and returns v. It doesn't precompute a large +// table, so it is considerably slower, but requires less memory. +// +// The scalar multiplication is done in constant time. +func (v *Point) ScalarMultSlow(x *Scalar, q *Point) *Point { + checkInitialized(q) + + s := x.Bytes() + qCached := new(projCached).FromP3(q) + v.Set(NewIdentityPoint()) + t := new(Point) + + for i := 255; i >= 0; i-- { + v.Double(v) + t.addCached(v, qCached) + cond := (s[i/8] >> (i % 8)) & 1 + v.Select(t, v, int(cond)) + } + + return v +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/extra_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/extra_test.go new file mode 100644 index 0000000..96deeae --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/extra_test.go @@ -0,0 +1,301 @@ +// Copyright (c) 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import ( + "crypto/rand" + "encoding/hex" + "testing" + "testing/quick" +) + +// TestBytesMontgomery tests the SetBytesWithClamping+BytesMontgomery path +// equivalence to curve25519.X25519 for basepoint scalar multiplications. +// +// Note that you can't actually implement X25519 with this package because +// there is no SetBytesMontgomery, and it would not be possible to implement +// it properly: points on the twist would get rejected, and the Scalar returned +// by SetBytesWithClamping does not preserve its cofactor-clearing properties. +// +// Disabled to avoid the golang.org/x/crypto module dependency. +/* func TestBytesMontgomery(t *testing.T) { + f := func(scalar [32]byte) bool { + s := NewScalar().SetBytesWithClamping(scalar[:]) + p := (&Point{}).ScalarBaseMult(s) + got := p.BytesMontgomery() + want, _ := curve25519.X25519(scalar[:], curve25519.Basepoint) + return bytes.Equal(got, want) + } + if err := quick.Check(f, nil); err != nil { + t.Error(err) + } +} */ + +func TestBytesMontgomerySodium(t *testing.T) { + // Generated with libsodium.js 1.0.18 + // crypto_sign_keypair().publicKey + publicKey := "3bf918ffc2c955dc895bf145f566fb96623c1cadbe040091175764b5fde322c0" + p, err := (&Point{}).SetBytes(decodeHex(publicKey)) + if err != nil { + t.Fatal(err) + } + // crypto_sign_ed25519_pk_to_curve25519(publicKey) + want := "efc6c9d0738e9ea18d738ad4a2653631558931b0f1fde4dd58c436d19686dc28" + if got := hex.EncodeToString(p.BytesMontgomery()); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestBytesMontgomeryInfinity(t *testing.T) { + p := NewIdentityPoint() + want := "0000000000000000000000000000000000000000000000000000000000000000" + if got := hex.EncodeToString(p.BytesMontgomery()); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMultByCofactor(t *testing.T) { + lowOrderBytes := "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85" + lowOrder, err := (&Point{}).SetBytes(decodeHex(lowOrderBytes)) + if err != nil { + t.Fatal(err) + } + + if p := (&Point{}).MultByCofactor(lowOrder); p.Equal(NewIdentityPoint()) != 1 { + t.Errorf("expected low order point * cofactor to be the identity") + } + + f := func(scalar [64]byte) bool { + s, _ := NewScalar().SetUniformBytes(scalar[:]) + p := (&Point{}).ScalarBaseMult(s) + p8 := (&Point{}).MultByCofactor(p) + checkOnCurve(t, p8) + + // 8 * p == (8 * s) * B + reprEight := [32]byte{8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + scEight, _ := (&Scalar{}).SetCanonicalBytes(reprEight[:]) + s.Multiply(s, scEight) + pp := (&Point{}).ScalarBaseMult(s) + if p8.Equal(pp) != 1 { + return false + } + + // 8 * p == 8 * (lowOrder + p) + pp.Add(p, lowOrder) + pp.MultByCofactor(pp) + if p8.Equal(pp) != 1 { + return false + } + + // 8 * p == p + p + p + p + p + p + p + p + pp.Set(NewIdentityPoint()) + for i := 0; i < 8; i++ { + pp.Add(pp, p) + } + return p8.Equal(pp) == 1 + } + if err := quick.Check(f, nil); err != nil { + t.Error(err) + } +} + +func TestScalarInvert(t *testing.T) { + invertWorks := func(xInv Scalar, x notZeroScalar) bool { + xInv.Invert((*Scalar)(&x)) + var check Scalar + check.Multiply((*Scalar)(&x), &xInv) + + return check.Equal(scOne) == 1 && isReduced(xInv.Bytes()) + } + + if err := quick.Check(invertWorks, quickCheckConfig(32)); err != nil { + t.Error(err) + } + + randomScalar := *dalekScalar + randomInverse := NewScalar().Invert(&randomScalar) + var check Scalar + check.Multiply(&randomScalar, randomInverse) + + if check.Equal(scOne) == 0 || !isReduced(randomInverse.Bytes()) { + t.Error("inversion did not work") + } + + zero := NewScalar() + if xx := NewScalar().Invert(zero); xx.Equal(zero) != 1 { + t.Errorf("inverting zero did not return zero") + } +} + +func TestMultiScalarMultMatchesBaseMult(t *testing.T) { + multiScalarMultMatchesBaseMult := func(x, y, z Scalar) bool { + var p, q1, q2, q3, check Point + + p.MultiScalarMult([]*Scalar{&x, &y, &z}, []*Point{B, B, B}) + + q1.ScalarBaseMult(&x) + q2.ScalarBaseMult(&y) + q3.ScalarBaseMult(&z) + check.Add(&q1, &q2).Add(&check, &q3) + + checkOnCurve(t, &p, &check, &q1, &q2, &q3) + return p.Equal(&check) == 1 + } + + if err := quick.Check(multiScalarMultMatchesBaseMult, quickCheckConfig(32)); err != nil { + t.Error(err) + } +} + +func TestMultiScalarMultZeroReceiver(t *testing.T) { + // A zero-value (uninitialized) receiver should be handled correctly, + // producing a valid point on the curve. + var p Point + p.MultiScalarMult([]*Scalar{dalekScalar}, []*Point{B}) + + var check Point + check.ScalarBaseMult(dalekScalar) + + checkOnCurve(t, &p, &check) + if p.Equal(&check) != 1 { + t.Error("MultiScalarMult with zero-value receiver did not match ScalarBaseMult") + } +} + +func TestMultiScalarMultReceiverAliasing(t *testing.T) { + // The receiver v aliasing one of the input points should produce + // the correct result. + p := NewGeneratorPoint() + p.MultiScalarMult([]*Scalar{dalekScalar}, []*Point{p}) + + var check Point + check.ScalarBaseMult(dalekScalar) + + checkOnCurve(t, p, &check) + if p.Equal(&check) != 1 { + t.Error("MultiScalarMult with aliased receiver did not match ScalarBaseMult") + } +} + +func TestVarTimeMultiScalarMultMatchesBaseMult(t *testing.T) { + varTimeMultiScalarMultMatchesBaseMult := func(x, y, z Scalar) bool { + var p, q1, q2, q3, check Point + + p.VarTimeMultiScalarMult([]*Scalar{&x, &y, &z}, []*Point{B, B, B}) + + q1.ScalarBaseMult(&x) + q2.ScalarBaseMult(&y) + q3.ScalarBaseMult(&z) + check.Add(&q1, &q2).Add(&check, &q3) + + checkOnCurve(t, &p, &check, &q1, &q2, &q3) + return p.Equal(&check) == 1 + } + + if err := quick.Check(varTimeMultiScalarMultMatchesBaseMult, quickCheckConfig(32)); err != nil { + t.Error(err) + } +} + +func TestMultiScalarMult2NoAllocs(t *testing.T) { + p := NewIdentityPoint() + if allocs := testing.AllocsPerRun(100, func() { + p.MultiScalarMult([]*Scalar{dalekScalar, dalekScalar}, []*Point{B, B}) + }); allocs != 0 { + t.Errorf("MultiScalarMult allocated %v times, expected 0", allocs) + } +} + +func TestScalarMultSlowMatchesMult(t *testing.T) { + scalarMultSlowMatchesMult := func(x, y Scalar) bool { + p := NewGeneratorPoint() + p.ScalarMultSlow(&x, p) + p.ScalarMultSlow(&y, p) + + q := NewGeneratorPoint() + q.ScalarMult(&x, B) + q.ScalarMult(&y, q) + + checkOnCurve(t, p, q) + return p.Equal(q) == 1 + } + + if err := quick.Check(scalarMultSlowMatchesMult, quickCheckConfig(32)); err != nil { + t.Error(err) + } +} + +func BenchmarkScalarMultSlow(b *testing.B) { + var p Point + x := dalekScalar + + for i := 0; i < b.N; i++ { + p.ScalarMultSlow(x, B) + } +} + +func BenchmarkMultiScalarMultSize8(t *testing.B) { + var p Point + x := dalekScalar + + for i := 0; i < t.N; i++ { + p.MultiScalarMult([]*Scalar{x, x, x, x, x, x, x, x}, + []*Point{B, B, B, B, B, B, B, B}) + } +} + +func BenchmarkScalarAddition(b *testing.B) { + var rnd [128]byte + rand.Read(rnd[:]) + s1, _ := (&Scalar{}).SetUniformBytes(rnd[0:64]) + s2, _ := (&Scalar{}).SetUniformBytes(rnd[64:128]) + t := &Scalar{} + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + t.Add(s1, s2) + } +} + +func BenchmarkScalarMultiplication(b *testing.B) { + var rnd [128]byte + rand.Read(rnd[:]) + s1, _ := (&Scalar{}).SetUniformBytes(rnd[0:64]) + s2, _ := (&Scalar{}).SetUniformBytes(rnd[64:128]) + t := &Scalar{} + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + t.Multiply(s1, s2) + } +} + +func BenchmarkScalarInversion(b *testing.B) { + var rnd [64]byte + rand.Read(rnd[:]) + s1, _ := (&Scalar{}).SetUniformBytes(rnd[0:64]) + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + s1.Invert(s1) + } +} + +func BenchmarkBytesMontgomery(b *testing.B) { + publicKey := "3bf918ffc2c955dc895bf145f566fb96623c1cadbe040091175764b5fde322c0" + p, err := (&Point{}).SetBytes(decodeHex(publicKey)) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = p.BytesMontgomery() + } +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe.go new file mode 100644 index 0000000..4d52cc1 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe.go @@ -0,0 +1,420 @@ +// Copyright (c) 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package field implements fast arithmetic modulo 2^255-19. +package field + +import ( + "crypto/subtle" + "encoding/binary" + "errors" + "math/bits" +) + +// Element represents an element of the field GF(2^255-19). Note that this +// is not a cryptographically secure group, and should only be used to interact +// with edwards25519.Point coordinates. +// +// This type works similarly to math/big.Int, and all arguments and receivers +// are allowed to alias. +// +// The zero value is a valid zero element. +type Element struct { + // An element t represents the integer + // t.l0 + t.l1*2^51 + t.l2*2^102 + t.l3*2^153 + t.l4*2^204 + // + // Between operations, all limbs are expected to be lower than 2^52. + l0 uint64 + l1 uint64 + l2 uint64 + l3 uint64 + l4 uint64 +} + +const maskLow51Bits uint64 = (1 << 51) - 1 + +var feZero = &Element{0, 0, 0, 0, 0} + +// Zero sets v = 0, and returns v. +func (v *Element) Zero() *Element { + *v = *feZero + return v +} + +var feOne = &Element{1, 0, 0, 0, 0} + +// One sets v = 1, and returns v. +func (v *Element) One() *Element { + *v = *feOne + return v +} + +// reduce reduces v modulo 2^255 - 19 and returns it. +func (v *Element) reduce() *Element { + v.carryPropagate() + + // After the light reduction we now have a field element representation + // v < 2^255 + 2^13 * 19, but need v < 2^255 - 19. + + // If v >= 2^255 - 19, then v + 19 >= 2^255, which would overflow 2^255 - 1, + // generating a carry. That is, c will be 0 if v < 2^255 - 19, and 1 otherwise. + c := (v.l0 + 19) >> 51 + c = (v.l1 + c) >> 51 + c = (v.l2 + c) >> 51 + c = (v.l3 + c) >> 51 + c = (v.l4 + c) >> 51 + + // If v < 2^255 - 19 and c = 0, this will be a no-op. Otherwise, it's + // effectively applying the reduction identity to the carry. + v.l0 += 19 * c + + v.l1 += v.l0 >> 51 + v.l0 = v.l0 & maskLow51Bits + v.l2 += v.l1 >> 51 + v.l1 = v.l1 & maskLow51Bits + v.l3 += v.l2 >> 51 + v.l2 = v.l2 & maskLow51Bits + v.l4 += v.l3 >> 51 + v.l3 = v.l3 & maskLow51Bits + // no additional carry + v.l4 = v.l4 & maskLow51Bits + + return v +} + +// Add sets v = a + b, and returns v. +func (v *Element) Add(a, b *Element) *Element { + v.l0 = a.l0 + b.l0 + v.l1 = a.l1 + b.l1 + v.l2 = a.l2 + b.l2 + v.l3 = a.l3 + b.l3 + v.l4 = a.l4 + b.l4 + return v.carryPropagate() +} + +// Subtract sets v = a - b, and returns v. +func (v *Element) Subtract(a, b *Element) *Element { + // We first add 2 * p, to guarantee the subtraction won't underflow, and + // then subtract b (which can be up to 2^255 + 2^13 * 19). + v.l0 = (a.l0 + 0xFFFFFFFFFFFDA) - b.l0 + v.l1 = (a.l1 + 0xFFFFFFFFFFFFE) - b.l1 + v.l2 = (a.l2 + 0xFFFFFFFFFFFFE) - b.l2 + v.l3 = (a.l3 + 0xFFFFFFFFFFFFE) - b.l3 + v.l4 = (a.l4 + 0xFFFFFFFFFFFFE) - b.l4 + return v.carryPropagate() +} + +// Negate sets v = -a, and returns v. +func (v *Element) Negate(a *Element) *Element { + return v.Subtract(feZero, a) +} + +// Invert sets v = 1/z mod p, and returns v. +// +// If z == 0, Invert returns v = 0. +func (v *Element) Invert(z *Element) *Element { + // Inversion is implemented as exponentiation with exponent p − 2. It uses the + // same sequence of 255 squarings and 11 multiplications as [Curve25519]. + var z2, z9, z11, z2_5_0, z2_10_0, z2_20_0, z2_50_0, z2_100_0, t Element + + z2.Square(z) // 2 + t.Square(&z2) // 4 + t.Square(&t) // 8 + z9.Multiply(&t, z) // 9 + z11.Multiply(&z9, &z2) // 11 + t.Square(&z11) // 22 + z2_5_0.Multiply(&t, &z9) // 31 = 2^5 - 2^0 + + t.Square(&z2_5_0) // 2^6 - 2^1 + for i := 0; i < 4; i++ { + t.Square(&t) // 2^10 - 2^5 + } + z2_10_0.Multiply(&t, &z2_5_0) // 2^10 - 2^0 + + t.Square(&z2_10_0) // 2^11 - 2^1 + for i := 0; i < 9; i++ { + t.Square(&t) // 2^20 - 2^10 + } + z2_20_0.Multiply(&t, &z2_10_0) // 2^20 - 2^0 + + t.Square(&z2_20_0) // 2^21 - 2^1 + for i := 0; i < 19; i++ { + t.Square(&t) // 2^40 - 2^20 + } + t.Multiply(&t, &z2_20_0) // 2^40 - 2^0 + + t.Square(&t) // 2^41 - 2^1 + for i := 0; i < 9; i++ { + t.Square(&t) // 2^50 - 2^10 + } + z2_50_0.Multiply(&t, &z2_10_0) // 2^50 - 2^0 + + t.Square(&z2_50_0) // 2^51 - 2^1 + for i := 0; i < 49; i++ { + t.Square(&t) // 2^100 - 2^50 + } + z2_100_0.Multiply(&t, &z2_50_0) // 2^100 - 2^0 + + t.Square(&z2_100_0) // 2^101 - 2^1 + for i := 0; i < 99; i++ { + t.Square(&t) // 2^200 - 2^100 + } + t.Multiply(&t, &z2_100_0) // 2^200 - 2^0 + + t.Square(&t) // 2^201 - 2^1 + for i := 0; i < 49; i++ { + t.Square(&t) // 2^250 - 2^50 + } + t.Multiply(&t, &z2_50_0) // 2^250 - 2^0 + + t.Square(&t) // 2^251 - 2^1 + t.Square(&t) // 2^252 - 2^2 + t.Square(&t) // 2^253 - 2^3 + t.Square(&t) // 2^254 - 2^4 + t.Square(&t) // 2^255 - 2^5 + + return v.Multiply(&t, &z11) // 2^255 - 21 +} + +// Set sets v = a, and returns v. +func (v *Element) Set(a *Element) *Element { + *v = *a + return v +} + +// SetBytes sets v to x, where x is a 32-byte little-endian encoding. If x is +// not of the right length, SetBytes returns nil and an error, and the +// receiver is unchanged. +// +// Consistent with RFC 7748, the most significant bit (the high bit of the +// last byte) is ignored, and non-canonical values (2^255-19 through 2^255-1) +// are accepted. Note that this is laxer than specified by RFC 8032, but +// consistent with most Ed25519 implementations. +func (v *Element) SetBytes(x []byte) (*Element, error) { + if len(x) != 32 { + return nil, errors.New("edwards25519: invalid field element input size") + } + + // Bits 0:51 (bytes 0:8, bits 0:64, shift 0, mask 51). + v.l0 = binary.LittleEndian.Uint64(x[0:8]) + v.l0 &= maskLow51Bits + // Bits 51:102 (bytes 6:14, bits 48:112, shift 3, mask 51). + v.l1 = binary.LittleEndian.Uint64(x[6:14]) >> 3 + v.l1 &= maskLow51Bits + // Bits 102:153 (bytes 12:20, bits 96:160, shift 6, mask 51). + v.l2 = binary.LittleEndian.Uint64(x[12:20]) >> 6 + v.l2 &= maskLow51Bits + // Bits 153:204 (bytes 19:27, bits 152:216, shift 1, mask 51). + v.l3 = binary.LittleEndian.Uint64(x[19:27]) >> 1 + v.l3 &= maskLow51Bits + // Bits 204:255 (bytes 24:32, bits 192:256, shift 12, mask 51). + // Note: not bytes 25:33, shift 4, to avoid overread. + v.l4 = binary.LittleEndian.Uint64(x[24:32]) >> 12 + v.l4 &= maskLow51Bits + + return v, nil +} + +// Bytes returns the canonical 32-byte little-endian encoding of v. +func (v *Element) Bytes() []byte { + // This function is outlined to make the allocations inline in the caller + // rather than happen on the heap. + var out [32]byte + return v.bytes(&out) +} + +func (v *Element) bytes(out *[32]byte) []byte { + t := *v + t.reduce() + + // Pack five 51-bit limbs into four 64-bit words: + // + // 255 204 153 102 51 0 + // ├──l4──┼──l3──┼──l2──┼──l1──┼──l0──┤ + // ├───u3───┼───u2───┼───u1───┼───u0───┤ + // 256 192 128 64 0 + + u0 := t.l1<<51 | t.l0 + u1 := t.l2<<(102-64) | t.l1>>(64-51) + u2 := t.l3<<(153-128) | t.l2>>(128-102) + u3 := t.l4<<(204-192) | t.l3>>(192-153) + + binary.LittleEndian.PutUint64(out[0*8:], u0) + binary.LittleEndian.PutUint64(out[1*8:], u1) + binary.LittleEndian.PutUint64(out[2*8:], u2) + binary.LittleEndian.PutUint64(out[3*8:], u3) + + return out[:] +} + +// Equal returns 1 if v and u are equal, and 0 otherwise. +func (v *Element) Equal(u *Element) int { + sa, sv := u.Bytes(), v.Bytes() + return subtle.ConstantTimeCompare(sa, sv) +} + +// mask64Bits returns 0xffffffff if cond is 1, and 0 otherwise. +func mask64Bits(cond int) uint64 { return ^(uint64(cond) - 1) } + +// Select sets v to a if cond == 1, and to b if cond == 0. +func (v *Element) Select(a, b *Element, cond int) *Element { + m := mask64Bits(cond) + v.l0 = (m & a.l0) | (^m & b.l0) + v.l1 = (m & a.l1) | (^m & b.l1) + v.l2 = (m & a.l2) | (^m & b.l2) + v.l3 = (m & a.l3) | (^m & b.l3) + v.l4 = (m & a.l4) | (^m & b.l4) + return v +} + +// Swap swaps v and u if cond == 1 or leaves them unchanged if cond == 0, and returns v. +func (v *Element) Swap(u *Element, cond int) { + m := mask64Bits(cond) + t := m & (v.l0 ^ u.l0) + v.l0 ^= t + u.l0 ^= t + t = m & (v.l1 ^ u.l1) + v.l1 ^= t + u.l1 ^= t + t = m & (v.l2 ^ u.l2) + v.l2 ^= t + u.l2 ^= t + t = m & (v.l3 ^ u.l3) + v.l3 ^= t + u.l3 ^= t + t = m & (v.l4 ^ u.l4) + v.l4 ^= t + u.l4 ^= t +} + +// IsNegative returns 1 if v is negative, and 0 otherwise. +func (v *Element) IsNegative() int { + return int(v.Bytes()[0] & 1) +} + +// Absolute sets v to |u|, and returns v. +func (v *Element) Absolute(u *Element) *Element { + return v.Select(new(Element).Negate(u), u, u.IsNegative()) +} + +// Multiply sets v = x * y, and returns v. +func (v *Element) Multiply(x, y *Element) *Element { + feMul(v, x, y) + return v +} + +// Square sets v = x * x, and returns v. +func (v *Element) Square(x *Element) *Element { + feSquare(v, x) + return v +} + +// Mult32 sets v = x * y, and returns v. +func (v *Element) Mult32(x *Element, y uint32) *Element { + x0lo, x0hi := mul51(x.l0, y) + x1lo, x1hi := mul51(x.l1, y) + x2lo, x2hi := mul51(x.l2, y) + x3lo, x3hi := mul51(x.l3, y) + x4lo, x4hi := mul51(x.l4, y) + v.l0 = x0lo + 19*x4hi // carried over per the reduction identity + v.l1 = x1lo + x0hi + v.l2 = x2lo + x1hi + v.l3 = x3lo + x2hi + v.l4 = x4lo + x3hi + // The hi portions are going to be only 32 bits, plus any previous excess, + // so we can skip the carry propagation. + return v +} + +// mul51 returns lo + hi * 2⁵¹ = a * b. +func mul51(a uint64, b uint32) (lo uint64, hi uint64) { + mh, ml := bits.Mul64(a, uint64(b)) + lo = ml & maskLow51Bits + hi = (mh << 13) | (ml >> 51) + return +} + +// Pow22523 set v = x^((p-5)/8), and returns v. (p-5)/8 is 2^252-3. +func (v *Element) Pow22523(x *Element) *Element { + var t0, t1, t2 Element + + t0.Square(x) // x^2 + t1.Square(&t0) // x^4 + t1.Square(&t1) // x^8 + t1.Multiply(x, &t1) // x^9 + t0.Multiply(&t0, &t1) // x^11 + t0.Square(&t0) // x^22 + t0.Multiply(&t1, &t0) // x^31 + t1.Square(&t0) // x^62 + for i := 1; i < 5; i++ { // x^992 + t1.Square(&t1) + } + t0.Multiply(&t1, &t0) // x^1023 -> 1023 = 2^10 - 1 + t1.Square(&t0) // 2^11 - 2 + for i := 1; i < 10; i++ { // 2^20 - 2^10 + t1.Square(&t1) + } + t1.Multiply(&t1, &t0) // 2^20 - 1 + t2.Square(&t1) // 2^21 - 2 + for i := 1; i < 20; i++ { // 2^40 - 2^20 + t2.Square(&t2) + } + t1.Multiply(&t2, &t1) // 2^40 - 1 + t1.Square(&t1) // 2^41 - 2 + for i := 1; i < 10; i++ { // 2^50 - 2^10 + t1.Square(&t1) + } + t0.Multiply(&t1, &t0) // 2^50 - 1 + t1.Square(&t0) // 2^51 - 2 + for i := 1; i < 50; i++ { // 2^100 - 2^50 + t1.Square(&t1) + } + t1.Multiply(&t1, &t0) // 2^100 - 1 + t2.Square(&t1) // 2^101 - 2 + for i := 1; i < 100; i++ { // 2^200 - 2^100 + t2.Square(&t2) + } + t1.Multiply(&t2, &t1) // 2^200 - 1 + t1.Square(&t1) // 2^201 - 2 + for i := 1; i < 50; i++ { // 2^250 - 2^50 + t1.Square(&t1) + } + t0.Multiply(&t1, &t0) // 2^250 - 1 + t0.Square(&t0) // 2^251 - 2 + t0.Square(&t0) // 2^252 - 4 + return v.Multiply(&t0, x) // 2^252 - 3 -> x^(2^252-3) +} + +// sqrtM1 is 2^((p-1)/4), which squared is equal to -1 by Euler's Criterion. +var sqrtM1 = &Element{1718705420411056, 234908883556509, + 2233514472574048, 2117202627021982, 765476049583133} + +// SqrtRatio sets r to the non-negative square root of the ratio of u and v. +// +// If u/v is square, SqrtRatio returns r and 1. If u/v is not square, SqrtRatio +// sets r according to Section 4.3 of draft-irtf-cfrg-ristretto255-decaf448-00, +// and returns r and 0. +func (r *Element) SqrtRatio(u, v *Element) (R *Element, wasSquare int) { + t0 := new(Element) + + // r = (u * v3) * (u * v7)^((p-5)/8) + v2 := new(Element).Square(v) + uv3 := new(Element).Multiply(u, t0.Multiply(v2, v)) + uv7 := new(Element).Multiply(uv3, t0.Square(v2)) + rr := new(Element).Multiply(uv3, t0.Pow22523(uv7)) + + check := new(Element).Multiply(v, t0.Square(rr)) // check = v * r^2 + + uNeg := new(Element).Negate(u) + correctSignSqrt := check.Equal(u) + flippedSignSqrt := check.Equal(uNeg) + flippedSignSqrtI := check.Equal(t0.Multiply(uNeg, sqrtM1)) + + rPrime := new(Element).Multiply(rr, sqrtM1) // r_prime = SQRT_M1 * r + // r = CT_SELECT(r_prime IF flipped_sign_sqrt | flipped_sign_sqrt_i ELSE r) + rr.Select(rPrime, rr, flippedSignSqrt|flippedSignSqrtI) + + r.Absolute(rr) // Choose the nonnegative square root. + return r, correctSignSqrt | flippedSignSqrt +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_alias_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_alias_test.go new file mode 100644 index 0000000..0c81239 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_alias_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package field + +import ( + "testing" + "testing/quick" +) + +func checkAliasingOneArg(f func(v, x *Element) *Element) func(v, x Element) bool { + return func(v, x Element) bool { + x1, v1 := x, x + + // Calculate a reference f(x) without aliasing. + if out := f(&v, &x); out != &v && isInBounds(out) { + return false + } + + // Test aliasing the argument and the receiver. + if out := f(&v1, &v1); out != &v1 || v1 != v { + return false + } + + // Ensure the arguments was not modified. + return x == x1 + } +} + +func checkAliasingTwoArgs(f func(v, x, y *Element) *Element) func(v, x, y Element) bool { + return func(v, x, y Element) bool { + x1, y1, v1 := x, y, Element{} + + // Calculate a reference f(x, y) without aliasing. + if out := f(&v, &x, &y); out != &v && isInBounds(out) { + return false + } + + // Test aliasing the first argument and the receiver. + v1 = x + if out := f(&v1, &v1, &y); out != &v1 || v1 != v { + return false + } + // Test aliasing the second argument and the receiver. + v1 = y + if out := f(&v1, &x, &v1); out != &v1 || v1 != v { + return false + } + + // Calculate a reference f(x, x) without aliasing. + if out := f(&v, &x, &x); out != &v { + return false + } + + // Test aliasing the first argument and the receiver. + v1 = x + if out := f(&v1, &v1, &x); out != &v1 || v1 != v { + return false + } + // Test aliasing the second argument and the receiver. + v1 = x + if out := f(&v1, &x, &v1); out != &v1 || v1 != v { + return false + } + // Test aliasing both arguments and the receiver. + v1 = x + if out := f(&v1, &v1, &v1); out != &v1 || v1 != v { + return false + } + + // Ensure the arguments were not modified. + return x == x1 && y == y1 + } +} + +// TestAliasing checks that receivers and arguments can alias each other without +// leading to incorrect results. That is, it ensures that it's safe to write +// +// v.Invert(v) +// +// or +// +// v.Add(v, v) +// +// without any of the inputs getting clobbered by the output being written. +func TestAliasing(t *testing.T) { + type target struct { + name string + oneArgF func(v, x *Element) *Element + twoArgsF func(v, x, y *Element) *Element + } + for _, tt := range []target{ + {name: "Absolute", oneArgF: (*Element).Absolute}, + {name: "Invert", oneArgF: (*Element).Invert}, + {name: "Negate", oneArgF: (*Element).Negate}, + {name: "Set", oneArgF: (*Element).Set}, + {name: "Square", oneArgF: (*Element).Square}, + {name: "Pow22523", oneArgF: (*Element).Pow22523}, + { + name: "Mult32", + oneArgF: func(v, x *Element) *Element { + return v.Mult32(x, 0xffffffff) + }, + }, + {name: "Multiply", twoArgsF: (*Element).Multiply}, + {name: "Add", twoArgsF: (*Element).Add}, + {name: "Subtract", twoArgsF: (*Element).Subtract}, + { + name: "SqrtRatio", + twoArgsF: func(v, x, y *Element) *Element { + r, _ := v.SqrtRatio(x, y) + return r + }, + }, + { + name: "Select0", + twoArgsF: func(v, x, y *Element) *Element { + return v.Select(x, y, 0) + }, + }, + { + name: "Select1", + twoArgsF: func(v, x, y *Element) *Element { + return v.Select(x, y, 1) + }, + }, + } { + var err error + switch { + case tt.oneArgF != nil: + err = quick.Check(checkAliasingOneArg(tt.oneArgF), quickCheckConfig(256)) + case tt.twoArgsF != nil: + err = quick.Check(checkAliasingTwoArgs(tt.twoArgsF), quickCheckConfig(256)) + } + if err != nil { + t.Errorf("%v: %v", tt.name, err) + } + } +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64.go new file mode 100644 index 0000000..00bf8f4 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64.go @@ -0,0 +1,15 @@ +// Code generated by command: go run fe_amd64_asm.go -out ../fe_amd64.s -stubs ../fe_amd64.go -pkg field. DO NOT EDIT. + +//go:build !purego + +package field + +// feMul sets out = a * b. It works like feMulGeneric. +// +//go:noescape +func feMul(out *Element, a *Element, b *Element) + +// feSquare sets out = a * a. It works like feSquareGeneric. +// +//go:noescape +func feSquare(out *Element, a *Element) diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64.s b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64.s new file mode 100644 index 0000000..5e06e24 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64.s @@ -0,0 +1,398 @@ +// Code generated by command: go run fe_amd64_asm.go -out ../fe_amd64.s -stubs ../fe_amd64.go -pkg field. DO NOT EDIT. + +//go:build !purego + +#include "textflag.h" + +// func feMul(out *Element, a *Element, b *Element) +TEXT ·feMul(SB), NOSPLIT, $0-24 + MOVQ a+8(FP), CX + MOVQ b+16(FP), BX + + // r0 = a0×b0 + MOVQ (CX), AX + MULQ (BX) + MOVQ AX, DI + MOVQ DX, SI + + // r0 += 19×a1×b4 + MOVQ 8(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 32(BX) + ADDQ AX, DI + ADCQ DX, SI + + // r0 += 19×a2×b3 + MOVQ 16(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 24(BX) + ADDQ AX, DI + ADCQ DX, SI + + // r0 += 19×a3×b2 + MOVQ 24(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 16(BX) + ADDQ AX, DI + ADCQ DX, SI + + // r0 += 19×a4×b1 + MOVQ 32(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 8(BX) + ADDQ AX, DI + ADCQ DX, SI + + // r1 = a0×b1 + MOVQ (CX), AX + MULQ 8(BX) + MOVQ AX, R9 + MOVQ DX, R8 + + // r1 += a1×b0 + MOVQ 8(CX), AX + MULQ (BX) + ADDQ AX, R9 + ADCQ DX, R8 + + // r1 += 19×a2×b4 + MOVQ 16(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 32(BX) + ADDQ AX, R9 + ADCQ DX, R8 + + // r1 += 19×a3×b3 + MOVQ 24(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 24(BX) + ADDQ AX, R9 + ADCQ DX, R8 + + // r1 += 19×a4×b2 + MOVQ 32(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 16(BX) + ADDQ AX, R9 + ADCQ DX, R8 + + // r2 = a0×b2 + MOVQ (CX), AX + MULQ 16(BX) + MOVQ AX, R11 + MOVQ DX, R10 + + // r2 += a1×b1 + MOVQ 8(CX), AX + MULQ 8(BX) + ADDQ AX, R11 + ADCQ DX, R10 + + // r2 += a2×b0 + MOVQ 16(CX), AX + MULQ (BX) + ADDQ AX, R11 + ADCQ DX, R10 + + // r2 += 19×a3×b4 + MOVQ 24(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 32(BX) + ADDQ AX, R11 + ADCQ DX, R10 + + // r2 += 19×a4×b3 + MOVQ 32(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 24(BX) + ADDQ AX, R11 + ADCQ DX, R10 + + // r3 = a0×b3 + MOVQ (CX), AX + MULQ 24(BX) + MOVQ AX, R13 + MOVQ DX, R12 + + // r3 += a1×b2 + MOVQ 8(CX), AX + MULQ 16(BX) + ADDQ AX, R13 + ADCQ DX, R12 + + // r3 += a2×b1 + MOVQ 16(CX), AX + MULQ 8(BX) + ADDQ AX, R13 + ADCQ DX, R12 + + // r3 += a3×b0 + MOVQ 24(CX), AX + MULQ (BX) + ADDQ AX, R13 + ADCQ DX, R12 + + // r3 += 19×a4×b4 + MOVQ 32(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 32(BX) + ADDQ AX, R13 + ADCQ DX, R12 + + // r4 = a0×b4 + MOVQ (CX), AX + MULQ 32(BX) + MOVQ AX, R15 + MOVQ DX, R14 + + // r4 += a1×b3 + MOVQ 8(CX), AX + MULQ 24(BX) + ADDQ AX, R15 + ADCQ DX, R14 + + // r4 += a2×b2 + MOVQ 16(CX), AX + MULQ 16(BX) + ADDQ AX, R15 + ADCQ DX, R14 + + // r4 += a3×b1 + MOVQ 24(CX), AX + MULQ 8(BX) + ADDQ AX, R15 + ADCQ DX, R14 + + // r4 += a4×b0 + MOVQ 32(CX), AX + MULQ (BX) + ADDQ AX, R15 + ADCQ DX, R14 + + // First reduction chain + MOVQ $0x0007ffffffffffff, AX + SHLQ $0x0d, DI, SI + SHLQ $0x0d, R9, R8 + SHLQ $0x0d, R11, R10 + SHLQ $0x0d, R13, R12 + SHLQ $0x0d, R15, R14 + ANDQ AX, DI + IMUL3Q $0x13, R14, R14 + ADDQ R14, DI + ANDQ AX, R9 + ADDQ SI, R9 + ANDQ AX, R11 + ADDQ R8, R11 + ANDQ AX, R13 + ADDQ R10, R13 + ANDQ AX, R15 + ADDQ R12, R15 + + // Second reduction chain (carryPropagate) + MOVQ DI, SI + SHRQ $0x33, SI + MOVQ R9, R8 + SHRQ $0x33, R8 + MOVQ R11, R10 + SHRQ $0x33, R10 + MOVQ R13, R12 + SHRQ $0x33, R12 + MOVQ R15, R14 + SHRQ $0x33, R14 + ANDQ AX, DI + IMUL3Q $0x13, R14, R14 + ADDQ R14, DI + ANDQ AX, R9 + ADDQ SI, R9 + ANDQ AX, R11 + ADDQ R8, R11 + ANDQ AX, R13 + ADDQ R10, R13 + ANDQ AX, R15 + ADDQ R12, R15 + + // Store output + MOVQ out+0(FP), AX + MOVQ DI, (AX) + MOVQ R9, 8(AX) + MOVQ R11, 16(AX) + MOVQ R13, 24(AX) + MOVQ R15, 32(AX) + RET + +// func feSquare(out *Element, a *Element) +TEXT ·feSquare(SB), NOSPLIT, $0-16 + MOVQ a+8(FP), CX + + // r0 = l0×l0 + MOVQ (CX), AX + MULQ (CX) + MOVQ AX, SI + MOVQ DX, BX + + // r0 += 38×l1×l4 + MOVQ 8(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + SHLQ $0x01, AX + MULQ 32(CX) + ADDQ AX, SI + ADCQ DX, BX + + // r0 += 38×l2×l3 + MOVQ 16(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + SHLQ $0x01, AX + MULQ 24(CX) + ADDQ AX, SI + ADCQ DX, BX + + // r1 = 2×l0×l1 + MOVQ (CX), AX + SHLQ $0x01, AX + MULQ 8(CX) + MOVQ AX, R8 + MOVQ DX, DI + + // r1 += 38×l2×l4 + MOVQ 16(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + SHLQ $0x01, AX + MULQ 32(CX) + ADDQ AX, R8 + ADCQ DX, DI + + // r1 += 19×l3×l3 + MOVQ 24(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 24(CX) + ADDQ AX, R8 + ADCQ DX, DI + + // r2 = 2×l0×l2 + MOVQ (CX), AX + SHLQ $0x01, AX + MULQ 16(CX) + MOVQ AX, R10 + MOVQ DX, R9 + + // r2 += l1×l1 + MOVQ 8(CX), AX + MULQ 8(CX) + ADDQ AX, R10 + ADCQ DX, R9 + + // r2 += 38×l3×l4 + MOVQ 24(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + SHLQ $0x01, AX + MULQ 32(CX) + ADDQ AX, R10 + ADCQ DX, R9 + + // r3 = 2×l0×l3 + MOVQ (CX), AX + SHLQ $0x01, AX + MULQ 24(CX) + MOVQ AX, R12 + MOVQ DX, R11 + + // r3 += 2×l1×l2 + MOVQ 8(CX), AX + SHLQ $0x01, AX + MULQ 16(CX) + ADDQ AX, R12 + ADCQ DX, R11 + + // r3 += 19×l4×l4 + MOVQ 32(CX), DX + LEAQ (DX)(DX*8), AX + LEAQ (DX)(AX*2), AX + MULQ 32(CX) + ADDQ AX, R12 + ADCQ DX, R11 + + // r4 = 2×l0×l4 + MOVQ (CX), AX + SHLQ $0x01, AX + MULQ 32(CX) + MOVQ AX, R14 + MOVQ DX, R13 + + // r4 += 2×l1×l3 + MOVQ 8(CX), AX + SHLQ $0x01, AX + MULQ 24(CX) + ADDQ AX, R14 + ADCQ DX, R13 + + // r4 += l2×l2 + MOVQ 16(CX), AX + MULQ 16(CX) + ADDQ AX, R14 + ADCQ DX, R13 + + // First reduction chain + MOVQ $0x0007ffffffffffff, AX + SHLQ $0x0d, SI, BX + SHLQ $0x0d, R8, DI + SHLQ $0x0d, R10, R9 + SHLQ $0x0d, R12, R11 + SHLQ $0x0d, R14, R13 + ANDQ AX, SI + IMUL3Q $0x13, R13, R13 + ADDQ R13, SI + ANDQ AX, R8 + ADDQ BX, R8 + ANDQ AX, R10 + ADDQ DI, R10 + ANDQ AX, R12 + ADDQ R9, R12 + ANDQ AX, R14 + ADDQ R11, R14 + + // Second reduction chain (carryPropagate) + MOVQ SI, BX + SHRQ $0x33, BX + MOVQ R8, DI + SHRQ $0x33, DI + MOVQ R10, R9 + SHRQ $0x33, R9 + MOVQ R12, R11 + SHRQ $0x33, R11 + MOVQ R14, R13 + SHRQ $0x33, R13 + ANDQ AX, SI + IMUL3Q $0x13, R13, R13 + ADDQ R13, SI + ANDQ AX, R8 + ADDQ BX, R8 + ANDQ AX, R10 + ADDQ DI, R10 + ANDQ AX, R12 + ADDQ R9, R12 + ANDQ AX, R14 + ADDQ R11, R14 + + // Store output + MOVQ out+0(FP), AX + MOVQ SI, (AX) + MOVQ R8, 8(AX) + MOVQ R10, 16(AX) + MOVQ R12, 24(AX) + MOVQ R14, 32(AX) + RET diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64_noasm.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64_noasm.go new file mode 100644 index 0000000..4b81f25 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_amd64_noasm.go @@ -0,0 +1,11 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !amd64 || purego + +package field + +func feMul(v, x, y *Element) { feMulGeneric(v, x, y) } + +func feSquare(v, x *Element) { feSquareGeneric(v, x) } diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_bench_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_bench_test.go new file mode 100644 index 0000000..fb80ca8 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_bench_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package field + +import "testing" + +func BenchmarkAdd(b *testing.B) { + x := new(Element).One() + y := new(Element).Add(x, x) + b.ResetTimer() + for i := 0; i < b.N; i++ { + x.Add(x, y) + } +} + +func BenchmarkMultiply(b *testing.B) { + x := new(Element).One() + y := new(Element).Add(x, x) + b.ResetTimer() + for i := 0; i < b.N; i++ { + x.Multiply(x, y) + } +} + +func BenchmarkSquare(b *testing.B) { + x := new(Element).Add(feOne, feOne) + b.ResetTimer() + for i := 0; i < b.N; i++ { + x.Square(x) + } +} + +func BenchmarkInvert(b *testing.B) { + x := new(Element).Add(feOne, feOne) + b.ResetTimer() + for i := 0; i < b.N; i++ { + x.Invert(x) + } +} + +func BenchmarkMult32(b *testing.B) { + x := new(Element).One() + b.ResetTimer() + for i := 0; i < b.N; i++ { + x.Mult32(x, 0xaa42aa42) + } +} + +func BenchmarkBytes(b *testing.B) { + x := new(Element).One() + b.ResetTimer() + for i := 0; i < b.N; i++ { + x.Bytes() + } +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_extra.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_extra.go new file mode 100644 index 0000000..1ef503b --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_extra.go @@ -0,0 +1,50 @@ +// Copyright (c) 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package field + +import "errors" + +// This file contains additional functionality that is not included in the +// upstream crypto/ed25519/edwards25519/field package. + +// SetWideBytes sets v to x, where x is a 64-byte little-endian encoding, which +// is reduced modulo the field order. If x is not of the right length, +// SetWideBytes returns nil and an error, and the receiver is unchanged. +// +// SetWideBytes is not necessary to select a uniformly distributed value, and is +// only provided for compatibility: SetBytes can be used instead as the chance +// of bias is less than 2⁻²⁵⁰. +func (v *Element) SetWideBytes(x []byte) (*Element, error) { + if len(x) != 64 { + return nil, errors.New("edwards25519: invalid SetWideBytes input size") + } + + // Split the 64 bytes into two elements, and extract the most significant + // bit of each, which is ignored by SetBytes. + lo, _ := new(Element).SetBytes(x[:32]) + loMSB := uint64(x[31] >> 7) + hi, _ := new(Element).SetBytes(x[32:]) + hiMSB := uint64(x[63] >> 7) + + // The output we want is + // + // v = lo + loMSB * 2²⁵⁵ + hi * 2²⁵⁶ + hiMSB * 2⁵¹¹ + // + // which applying the reduction identity comes out to + // + // v = lo + loMSB * 19 + hi * 2 * 19 + hiMSB * 2 * 19² + // + // l0 will be the sum of a 52 bits value (lo.l0), plus a 5 bits value + // (loMSB * 19), a 6 bits value (hi.l0 * 2 * 19), and a 10 bits value + // (hiMSB * 2 * 19²), so it fits in a uint64. + + v.l0 = lo.l0 + loMSB*19 + hi.l0*2*19 + hiMSB*2*19*19 + v.l1 = lo.l1 + hi.l1*2*19 + v.l2 = lo.l2 + hi.l2*2*19 + v.l3 = lo.l3 + hi.l3*2*19 + v.l4 = lo.l4 + hi.l4*2*19 + + return v.carryPropagate(), nil +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_extra_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_extra_test.go new file mode 100644 index 0000000..7d8bea0 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_extra_test.go @@ -0,0 +1,37 @@ +// Copyright (c) 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package field + +import ( + "math/big" + "testing" + "testing/quick" +) + +var bigP = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 255), big.NewInt(19)) + +func TestSetWideBytes(t *testing.T) { + f1 := func(in [64]byte, fe Element) bool { + fe1 := new(Element).Set(&fe) + + if out, err := fe.SetWideBytes([]byte{42}); err == nil || out != nil || + fe.Equal(fe1) != 1 { + return false + } + + if out, err := fe.SetWideBytes(in[:]); err != nil || out != &fe { + return false + } + + b := new(big.Int).SetBytes(swapEndianness(in[:])) + fe1.fromBig(b.Mod(b, bigP)) + + return fe.Equal(fe1) == 1 && isInBounds(&fe) && isInBounds(fe1) + } + if err := quick.Check(f1, nil); err != nil { + t.Error(err) + } + +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_generic.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_generic.go new file mode 100644 index 0000000..ef1f15a --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_generic.go @@ -0,0 +1,272 @@ +// Copyright (c) 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package field + +import "math/bits" + +// uint128 holds a 128-bit number as two 64-bit limbs, for use with the +// bits.Mul64 and bits.Add64 intrinsics. +type uint128 struct { + lo, hi uint64 +} + +// mul returns a * b. +func mul(a, b uint64) uint128 { + hi, lo := bits.Mul64(a, b) + return uint128{lo, hi} +} + +// addMul returns v + a * b. +func addMul(v uint128, a, b uint64) uint128 { + hi, lo := bits.Mul64(a, b) + lo, c := bits.Add64(lo, v.lo, 0) + hi, _ = bits.Add64(hi, v.hi, c) + return uint128{lo, hi} +} + +// mul19 returns v * 19. +func mul19(v uint64) uint64 { + // Using this approach seems to yield better optimizations than *19. + return v + (v+v<<3)<<1 +} + +// addMul19 returns v + 19 * a * b, where a and b are at most 52 bits. +func addMul19(v uint128, a, b uint64) uint128 { + hi, lo := bits.Mul64(mul19(a), b) + lo, c := bits.Add64(lo, v.lo, 0) + hi, _ = bits.Add64(hi, v.hi, c) + return uint128{lo, hi} +} + +// addMul38 returns v + 38 * a * b, where a and b are at most 52 bits. +func addMul38(v uint128, a, b uint64) uint128 { + hi, lo := bits.Mul64(mul19(a), b*2) + lo, c := bits.Add64(lo, v.lo, 0) + hi, _ = bits.Add64(hi, v.hi, c) + return uint128{lo, hi} +} + +// shiftRightBy51 returns a >> 51. a is assumed to be at most 115 bits. +func shiftRightBy51(a uint128) uint64 { + return (a.hi << (64 - 51)) | (a.lo >> 51) +} + +func feMulGeneric(v, a, b *Element) { + a0 := a.l0 + a1 := a.l1 + a2 := a.l2 + a3 := a.l3 + a4 := a.l4 + + b0 := b.l0 + b1 := b.l1 + b2 := b.l2 + b3 := b.l3 + b4 := b.l4 + + // Limb multiplication works like pen-and-paper columnar multiplication, but + // with 51-bit limbs instead of digits. + // + // a4 a3 a2 a1 a0 x + // b4 b3 b2 b1 b0 = + // ------------------------ + // a4b0 a3b0 a2b0 a1b0 a0b0 + + // a4b1 a3b1 a2b1 a1b1 a0b1 + + // a4b2 a3b2 a2b2 a1b2 a0b2 + + // a4b3 a3b3 a2b3 a1b3 a0b3 + + // a4b4 a3b4 a2b4 a1b4 a0b4 = + // ---------------------------------------------- + // r8 r7 r6 r5 r4 r3 r2 r1 r0 + // + // We can then use the reduction identity (a * 2²⁵⁵ + b = a * 19 + b) to + // reduce the limbs that would overflow 255 bits. r5 * 2²⁵⁵ becomes 19 * r5, + // r6 * 2³⁰⁶ becomes 19 * r6 * 2⁵¹, etc. + // + // Reduction can be carried out simultaneously to multiplication. For + // example, we do not compute r5: whenever the result of a multiplication + // belongs to r5, like a1b4, we multiply it by 19 and add the result to r0. + // + // a4b0 a3b0 a2b0 a1b0 a0b0 + + // a3b1 a2b1 a1b1 a0b1 19×a4b1 + + // a2b2 a1b2 a0b2 19×a4b2 19×a3b2 + + // a1b3 a0b3 19×a4b3 19×a3b3 19×a2b3 + + // a0b4 19×a4b4 19×a3b4 19×a2b4 19×a1b4 = + // -------------------------------------- + // r4 r3 r2 r1 r0 + // + // Finally we add up the columns into wide, overlapping limbs. + + // r0 = a0×b0 + 19×(a1×b4 + a2×b3 + a3×b2 + a4×b1) + r0 := mul(a0, b0) + r0 = addMul19(r0, a1, b4) + r0 = addMul19(r0, a2, b3) + r0 = addMul19(r0, a3, b2) + r0 = addMul19(r0, a4, b1) + + // r1 = a0×b1 + a1×b0 + 19×(a2×b4 + a3×b3 + a4×b2) + r1 := mul(a0, b1) + r1 = addMul(r1, a1, b0) + r1 = addMul19(r1, a2, b4) + r1 = addMul19(r1, a3, b3) + r1 = addMul19(r1, a4, b2) + + // r2 = a0×b2 + a1×b1 + a2×b0 + 19×(a3×b4 + a4×b3) + r2 := mul(a0, b2) + r2 = addMul(r2, a1, b1) + r2 = addMul(r2, a2, b0) + r2 = addMul19(r2, a3, b4) + r2 = addMul19(r2, a4, b3) + + // r3 = a0×b3 + a1×b2 + a2×b1 + a3×b0 + 19×a4×b4 + r3 := mul(a0, b3) + r3 = addMul(r3, a1, b2) + r3 = addMul(r3, a2, b1) + r3 = addMul(r3, a3, b0) + r3 = addMul19(r3, a4, b4) + + // r4 = a0×b4 + a1×b3 + a2×b2 + a3×b1 + a4×b0 + r4 := mul(a0, b4) + r4 = addMul(r4, a1, b3) + r4 = addMul(r4, a2, b2) + r4 = addMul(r4, a3, b1) + r4 = addMul(r4, a4, b0) + + // After the multiplication, we need to reduce (carry) the five coefficients + // to obtain a result with limbs that are at most slightly larger than 2⁵¹, + // to respect the Element invariant. + // + // Overall, the reduction works the same as carryPropagate, except with + // wider inputs: we take the carry for each coefficient by shifting it right + // by 51, and add it to the limb above it. The top carry is multiplied by 19 + // according to the reduction identity and added to the lowest limb. + // + // The largest coefficient (r0) will be at most 111 bits, which guarantees + // that all carries are at most 111 - 51 = 60 bits, which fits in a uint64. + // + // r0 = a0×b0 + 19×(a1×b4 + a2×b3 + a3×b2 + a4×b1) + // r0 < 2⁵²×2⁵² + 19×(2⁵²×2⁵² + 2⁵²×2⁵² + 2⁵²×2⁵² + 2⁵²×2⁵²) + // r0 < (1 + 19 × 4) × 2⁵² × 2⁵² + // r0 < 2⁷ × 2⁵² × 2⁵² + // r0 < 2¹¹¹ + // + // Moreover, the top coefficient (r4) is at most 107 bits, so c4 is at most + // 56 bits, and c4 * 19 is at most 61 bits, which again fits in a uint64 and + // allows us to easily apply the reduction identity. + // + // r4 = a0×b4 + a1×b3 + a2×b2 + a3×b1 + a4×b0 + // r4 < 5 × 2⁵² × 2⁵² + // r4 < 2¹⁰⁷ + // + + c0 := shiftRightBy51(r0) + c1 := shiftRightBy51(r1) + c2 := shiftRightBy51(r2) + c3 := shiftRightBy51(r3) + c4 := shiftRightBy51(r4) + + rr0 := r0.lo&maskLow51Bits + mul19(c4) + rr1 := r1.lo&maskLow51Bits + c0 + rr2 := r2.lo&maskLow51Bits + c1 + rr3 := r3.lo&maskLow51Bits + c2 + rr4 := r4.lo&maskLow51Bits + c3 + + // Now all coefficients fit into 64-bit registers but are still too large to + // be passed around as an Element. We therefore do one last carry chain, + // where the carries will be small enough to fit in the wiggle room above 2⁵¹. + + v.l0 = rr0&maskLow51Bits + mul19(rr4>>51) + v.l1 = rr1&maskLow51Bits + rr0>>51 + v.l2 = rr2&maskLow51Bits + rr1>>51 + v.l3 = rr3&maskLow51Bits + rr2>>51 + v.l4 = rr4&maskLow51Bits + rr3>>51 +} + +func feSquareGeneric(v, a *Element) { + l0 := a.l0 + l1 := a.l1 + l2 := a.l2 + l3 := a.l3 + l4 := a.l4 + + // Squaring works precisely like multiplication above, but thanks to its + // symmetry we get to group a few terms together. + // + // l4 l3 l2 l1 l0 x + // l4 l3 l2 l1 l0 = + // ------------------------ + // l4l0 l3l0 l2l0 l1l0 l0l0 + + // l4l1 l3l1 l2l1 l1l1 l0l1 + + // l4l2 l3l2 l2l2 l1l2 l0l2 + + // l4l3 l3l3 l2l3 l1l3 l0l3 + + // l4l4 l3l4 l2l4 l1l4 l0l4 = + // ---------------------------------------------- + // r8 r7 r6 r5 r4 r3 r2 r1 r0 + // + // l4l0 l3l0 l2l0 l1l0 l0l0 + + // l3l1 l2l1 l1l1 l0l1 19×l4l1 + + // l2l2 l1l2 l0l2 19×l4l2 19×l3l2 + + // l1l3 l0l3 19×l4l3 19×l3l3 19×l2l3 + + // l0l4 19×l4l4 19×l3l4 19×l2l4 19×l1l4 = + // -------------------------------------- + // r4 r3 r2 r1 r0 + + // r0 = l0×l0 + 19×(l1×l4 + l2×l3 + l3×l2 + l4×l1) = l0×l0 + 19×2×(l1×l4 + l2×l3) + r0 := mul(l0, l0) + r0 = addMul38(r0, l1, l4) + r0 = addMul38(r0, l2, l3) + + // r1 = l0×l1 + l1×l0 + 19×(l2×l4 + l3×l3 + l4×l2) = 2×l0×l1 + 19×2×l2×l4 + 19×l3×l3 + r1 := mul(l0*2, l1) + r1 = addMul38(r1, l2, l4) + r1 = addMul19(r1, l3, l3) + + // r2 = l0×l2 + l1×l1 + l2×l0 + 19×(l3×l4 + l4×l3) = 2×l0×l2 + l1×l1 + 19×2×l3×l4 + r2 := mul(l0*2, l2) + r2 = addMul(r2, l1, l1) + r2 = addMul38(r2, l3, l4) + + // r3 = l0×l3 + l1×l2 + l2×l1 + l3×l0 + 19×l4×l4 = 2×l0×l3 + 2×l1×l2 + 19×l4×l4 + r3 := mul(l0*2, l3) + r3 = addMul(r3, l1*2, l2) + r3 = addMul19(r3, l4, l4) + + // r4 = l0×l4 + l1×l3 + l2×l2 + l3×l1 + l4×l0 = 2×l0×l4 + 2×l1×l3 + l2×l2 + r4 := mul(l0*2, l4) + r4 = addMul(r4, l1*2, l3) + r4 = addMul(r4, l2, l2) + + c0 := shiftRightBy51(r0) + c1 := shiftRightBy51(r1) + c2 := shiftRightBy51(r2) + c3 := shiftRightBy51(r3) + c4 := shiftRightBy51(r4) + + rr0 := r0.lo&maskLow51Bits + mul19(c4) + rr1 := r1.lo&maskLow51Bits + c0 + rr2 := r2.lo&maskLow51Bits + c1 + rr3 := r3.lo&maskLow51Bits + c2 + rr4 := r4.lo&maskLow51Bits + c3 + + v.l0 = rr0&maskLow51Bits + mul19(rr4>>51) + v.l1 = rr1&maskLow51Bits + rr0>>51 + v.l2 = rr2&maskLow51Bits + rr1>>51 + v.l3 = rr3&maskLow51Bits + rr2>>51 + v.l4 = rr4&maskLow51Bits + rr3>>51 +} + +// carryPropagate brings the limbs below 52 bits by applying the reduction +// identity (a * 2²⁵⁵ + b = a * 19 + b) to the l4 carry. +func (v *Element) carryPropagate() *Element { + // (l4>>51) is at most 64 - 51 = 13 bits, so (l4>>51)*19 is at most 18 bits, and + // the final l0 will be at most 52 bits. Similarly for the rest. + l0 := v.l0 + v.l0 = v.l0&maskLow51Bits + mul19(v.l4>>51) + v.l4 = v.l4&maskLow51Bits + v.l3>>51 + v.l3 = v.l3&maskLow51Bits + v.l2>>51 + v.l2 = v.l2&maskLow51Bits + v.l1>>51 + v.l1 = v.l1&maskLow51Bits + l0>>51 + + return v +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_test.go new file mode 100644 index 0000000..b268878 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/field/fe_test.go @@ -0,0 +1,542 @@ +// Copyright (c) 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package field + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "io" + "math/big" + "math/bits" + mathrand "math/rand" + "reflect" + "testing" + "testing/quick" +) + +func (v Element) String() string { + return hex.EncodeToString(v.Bytes()) +} + +// quickCheckConfig returns a quick.Config that scales the max count by the +// given factor if the -short flag is not set. +func quickCheckConfig(slowScale int) *quick.Config { + cfg := new(quick.Config) + if !testing.Short() { + cfg.MaxCountScale = float64(slowScale) + } + return cfg +} + +func generateFieldElement(rand *mathrand.Rand) Element { + const maskLow52Bits = (1 << 52) - 1 + return Element{ + rand.Uint64() & maskLow52Bits, + rand.Uint64() & maskLow52Bits, + rand.Uint64() & maskLow52Bits, + rand.Uint64() & maskLow52Bits, + rand.Uint64() & maskLow52Bits, + } +} + +// weirdLimbs can be combined to generate a range of edge-case field elements. +// 0 and -1 are intentionally more weighted, as they combine well. +var ( + weirdLimbs51 = []uint64{ + 0, 0, 0, 0, + 1, + 19 - 1, + 19, + 0x2aaaaaaaaaaaa, + 0x5555555555555, + (1 << 51) - 20, + (1 << 51) - 19, + (1 << 51) - 1, (1 << 51) - 1, + (1 << 51) - 1, (1 << 51) - 1, + } + weirdLimbs52 = []uint64{ + 0, 0, 0, 0, 0, 0, + 1, + 19 - 1, + 19, + 0x2aaaaaaaaaaaa, + 0x5555555555555, + (1 << 51) - 20, + (1 << 51) - 19, + (1 << 51) - 1, (1 << 51) - 1, + (1 << 51) - 1, (1 << 51) - 1, + (1 << 51) - 1, (1 << 51) - 1, + 1 << 51, + (1 << 51) + 1, + (1 << 52) - 19, + (1 << 52) - 1, + } +) + +func generateWeirdFieldElement(rand *mathrand.Rand) Element { + return Element{ + weirdLimbs52[rand.Intn(len(weirdLimbs52))], + weirdLimbs51[rand.Intn(len(weirdLimbs51))], + weirdLimbs51[rand.Intn(len(weirdLimbs51))], + weirdLimbs51[rand.Intn(len(weirdLimbs51))], + weirdLimbs51[rand.Intn(len(weirdLimbs51))], + } +} + +func (Element) Generate(rand *mathrand.Rand, size int) reflect.Value { + if rand.Intn(2) == 0 { + return reflect.ValueOf(generateWeirdFieldElement(rand)) + } + return reflect.ValueOf(generateFieldElement(rand)) +} + +// isInBounds returns whether the element is within the expected bit size bounds +// after a light reduction. +func isInBounds(x *Element) bool { + return bits.Len64(x.l0) <= 52 && + bits.Len64(x.l1) <= 52 && + bits.Len64(x.l2) <= 52 && + bits.Len64(x.l3) <= 52 && + bits.Len64(x.l4) <= 52 +} + +func TestMultiplyDistributesOverAdd(t *testing.T) { + multiplyDistributesOverAdd := func(x, y, z Element) bool { + // Compute t1 = (x+y)*z + t1 := new(Element) + t1.Add(&x, &y) + t1.Multiply(t1, &z) + + // Compute t2 = x*z + y*z + t2 := new(Element) + t3 := new(Element) + t2.Multiply(&x, &z) + t3.Multiply(&y, &z) + t2.Add(t2, t3) + + return t1.Equal(t2) == 1 && isInBounds(t1) && isInBounds(t2) + } + + if err := quick.Check(multiplyDistributesOverAdd, quickCheckConfig(1024)); err != nil { + t.Error(err) + } +} + +func TestMul64to128(t *testing.T) { + a := uint64(5) + b := uint64(5) + r := mul(a, b) + if r.lo != 0x19 || r.hi != 0 { + t.Errorf("lo-range wide mult failed, got %d + %d*(2**64)", r.lo, r.hi) + } + + a = uint64(18014398509481983) // 2^54 - 1 + b = uint64(18014398509481983) // 2^54 - 1 + r = mul(a, b) + if r.lo != 0xff80000000000001 || r.hi != 0xfffffffffff { + t.Errorf("hi-range wide mult failed, got %d + %d*(2**64)", r.lo, r.hi) + } + + a = uint64(1125899906842661) + b = uint64(2097155) + r = mul(a, b) + r = addMul(r, a, b) + r = addMul(r, a, b) + r = addMul(r, a, b) + r = addMul(r, a, b) + if r.lo != 16888498990613035 || r.hi != 640 { + t.Errorf("wrong answer: %d + %d*(2**64)", r.lo, r.hi) + } +} + +func TestSetBytesRoundTrip(t *testing.T) { + f1 := func(in [32]byte, fe Element) bool { + fe.SetBytes(in[:]) + + // Mask the most significant bit as it's ignored by SetBytes. (Now + // instead of earlier so we check the masking in SetBytes is working.) + in[len(in)-1] &= (1 << 7) - 1 + + return bytes.Equal(in[:], fe.Bytes()) && isInBounds(&fe) + } + if err := quick.Check(f1, nil); err != nil { + t.Errorf("failed bytes->FE->bytes round-trip: %v", err) + } + + f2 := func(fe, r Element) bool { + r.SetBytes(fe.Bytes()) + + // Intentionally not using Equal not to go through Bytes again. + // Calling reduce because both Generate and SetBytes can produce + // non-canonical representations. + fe.reduce() + r.reduce() + return fe == r + } + if err := quick.Check(f2, nil); err != nil { + t.Errorf("failed FE->bytes->FE round-trip: %v", err) + } + + // Check some fixed vectors from dalek + type feRTTest struct { + fe Element + b []byte + } + var tests = []feRTTest{ + { + fe: Element{358744748052810, 1691584618240980, 977650209285361, 1429865912637724, 560044844278676}, + b: []byte{74, 209, 69, 197, 70, 70, 161, 222, 56, 226, 229, 19, 112, 60, 25, 92, 187, 74, 222, 56, 50, 153, 51, 233, 40, 74, 57, 6, 160, 185, 213, 31}, + }, + { + fe: Element{84926274344903, 473620666599931, 365590438845504, 1028470286882429, 2146499180330972}, + b: []byte{199, 23, 106, 112, 61, 77, 216, 79, 186, 60, 11, 118, 13, 16, 103, 15, 42, 32, 83, 250, 44, 57, 204, 198, 78, 199, 253, 119, 146, 172, 3, 122}, + }, + } + + for _, tt := range tests { + b := tt.fe.Bytes() + fe, _ := new(Element).SetBytes(tt.b) + if !bytes.Equal(b, tt.b) || fe.Equal(&tt.fe) != 1 { + t.Errorf("Failed fixed roundtrip: %v", tt) + } + } +} + +func swapEndianness(buf []byte) []byte { + for i := 0; i < len(buf)/2; i++ { + buf[i], buf[len(buf)-i-1] = buf[len(buf)-i-1], buf[i] + } + return buf +} + +func TestBytesBigEquivalence(t *testing.T) { + f1 := func(in [32]byte, fe, fe1 Element) bool { + fe.SetBytes(in[:]) + + in[len(in)-1] &= (1 << 7) - 1 // mask the most significant bit + b := new(big.Int).SetBytes(swapEndianness(in[:])) + fe1.fromBig(b) + + if fe != fe1 { + return false + } + + buf := make([]byte, 32) + buf = swapEndianness(fe1.toBig().FillBytes(buf)) + + return bytes.Equal(fe.Bytes(), buf) && isInBounds(&fe) && isInBounds(&fe1) + } + if err := quick.Check(f1, nil); err != nil { + t.Error(err) + } +} + +// fromBig sets v = n, and returns v. The bit length of n must not exceed 256. +func (v *Element) fromBig(n *big.Int) *Element { + if n.BitLen() > 32*8 { + panic("edwards25519: invalid field element input size") + } + + buf := make([]byte, 0, 32) + for _, word := range n.Bits() { + for i := 0; i < bits.UintSize; i += 8 { + if len(buf) >= cap(buf) { + break + } + buf = append(buf, byte(word)) + word >>= 8 + } + } + + v.SetBytes(buf[:32]) + return v +} + +func (v *Element) fromDecimal(s string) *Element { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + panic("not a valid decimal: " + s) + } + return v.fromBig(n) +} + +// toBig returns v as a big.Int. +func (v *Element) toBig() *big.Int { + buf := v.Bytes() + + words := make([]big.Word, 32*8/bits.UintSize) + for n := range words { + for i := 0; i < bits.UintSize; i += 8 { + if len(buf) == 0 { + break + } + words[n] |= big.Word(buf[0]) << big.Word(i) + buf = buf[1:] + } + } + + return new(big.Int).SetBits(words) +} + +func TestDecimalConstants(t *testing.T) { + sqrtM1String := "19681161376707505956807079304988542015446066515923890162744021073123829784752" + if exp := new(Element).fromDecimal(sqrtM1String); sqrtM1.Equal(exp) != 1 { + t.Errorf("sqrtM1 is %v, expected %v", sqrtM1, exp) + } + // d is in the parent package, and we don't want to expose d or fromDecimal. + // dString := "37095705934669439343138083508754565189542113879843219016388785533085940283555" + // if exp := new(Element).fromDecimal(dString); d.Equal(exp) != 1 { + // t.Errorf("d is %v, expected %v", d, exp) + // } +} + +func TestSetBytesRoundTripEdgeCases(t *testing.T) { + // TODO: values close to 0, close to 2^255-19, between 2^255-19 and 2^255-1, + // and between 2^255 and 2^256-1. Test both the documented SetBytes + // behavior, and that Bytes reduces them. +} + +// Tests self-consistency between Multiply and Square. +func TestConsistency(t *testing.T) { + var x Element + var x2, x2sq Element + + x = Element{1, 1, 1, 1, 1} + x2.Multiply(&x, &x) + x2sq.Square(&x) + + if x2 != x2sq { + t.Fatalf("all ones failed\nmul: %x\nsqr: %x\n", x2, x2sq) + } + + var bytes [32]byte + + _, err := io.ReadFull(rand.Reader, bytes[:]) + if err != nil { + t.Fatal(err) + } + x.SetBytes(bytes[:]) + + x2.Multiply(&x, &x) + x2sq.Square(&x) + + if x2 != x2sq { + t.Fatalf("all ones failed\nmul: %x\nsqr: %x\n", x2, x2sq) + } +} + +func TestEqual(t *testing.T) { + x := Element{1, 1, 1, 1, 1} + y := Element{5, 4, 3, 2, 1} + + eq := x.Equal(&x) + if eq != 1 { + t.Errorf("wrong about equality") + } + + eq = x.Equal(&y) + if eq != 0 { + t.Errorf("wrong about inequality") + } +} + +func TestInvert(t *testing.T) { + x := Element{1, 1, 1, 1, 1} + one := Element{1, 0, 0, 0, 0} + var xinv, r Element + + xinv.Invert(&x) + r.Multiply(&x, &xinv) + r.reduce() + + if one != r { + t.Errorf("inversion identity failed, got: %x", r) + } + + var bytes [32]byte + + _, err := io.ReadFull(rand.Reader, bytes[:]) + if err != nil { + t.Fatal(err) + } + x.SetBytes(bytes[:]) + + xinv.Invert(&x) + r.Multiply(&x, &xinv) + r.reduce() + + if one != r { + t.Errorf("random inversion identity failed, got: %x for field element %x", r, x) + } + + zero := Element{} + x.Set(&zero) + if xx := xinv.Invert(&x); xx != &xinv { + t.Errorf("inverting zero did not return the receiver") + } else if xinv.Equal(&zero) != 1 { + t.Errorf("inverting zero did not return zero") + } +} + +func TestSelectSwap(t *testing.T) { + a := Element{358744748052810, 1691584618240980, 977650209285361, 1429865912637724, 560044844278676} + b := Element{84926274344903, 473620666599931, 365590438845504, 1028470286882429, 2146499180330972} + + var c, d Element + + c.Select(&a, &b, 1) + d.Select(&a, &b, 0) + + if c.Equal(&a) != 1 || d.Equal(&b) != 1 { + t.Errorf("Select failed") + } + + c.Swap(&d, 0) + + if c.Equal(&a) != 1 || d.Equal(&b) != 1 { + t.Errorf("Swap failed") + } + + c.Swap(&d, 1) + + if c.Equal(&b) != 1 || d.Equal(&a) != 1 { + t.Errorf("Swap failed") + } +} + +func TestMult32(t *testing.T) { + mult32EquivalentToMul := func(x Element, y uint32) bool { + t1 := new(Element) + for i := 0; i < 100; i++ { + t1.Mult32(&x, y) + } + + ty := new(Element) + ty.l0 = uint64(y) + + t2 := new(Element) + for i := 0; i < 100; i++ { + t2.Multiply(&x, ty) + } + + return t1.Equal(t2) == 1 && isInBounds(t1) && isInBounds(t2) + } + + if err := quick.Check(mult32EquivalentToMul, quickCheckConfig(1024)); err != nil { + t.Error(err) + } +} + +func TestSqrtRatio(t *testing.T) { + // From draft-irtf-cfrg-ristretto255-decaf448-00, Appendix A.4. + type test struct { + u, v []byte + wasSquare int + r []byte + } + var tests = []test{ + // If u is 0, the function is defined to return (0, TRUE), even if v + // is zero. Note that where used in this package, the denominator v + // is never zero. + { + decodeHex("0000000000000000000000000000000000000000000000000000000000000000"), + decodeHex("0000000000000000000000000000000000000000000000000000000000000000"), + 1, decodeHex("0000000000000000000000000000000000000000000000000000000000000000"), + }, + // 0/1 == 0² + { + decodeHex("0000000000000000000000000000000000000000000000000000000000000000"), + decodeHex("0100000000000000000000000000000000000000000000000000000000000000"), + 1, decodeHex("0000000000000000000000000000000000000000000000000000000000000000"), + }, + // If u is non-zero and v is zero, defined to return (0, FALSE). + { + decodeHex("0100000000000000000000000000000000000000000000000000000000000000"), + decodeHex("0000000000000000000000000000000000000000000000000000000000000000"), + 0, decodeHex("0000000000000000000000000000000000000000000000000000000000000000"), + }, + // 2/1 is not square in this field. + { + decodeHex("0200000000000000000000000000000000000000000000000000000000000000"), + decodeHex("0100000000000000000000000000000000000000000000000000000000000000"), + 0, decodeHex("3c5ff1b5d8e4113b871bd052f9e7bcd0582804c266ffb2d4f4203eb07fdb7c54"), + }, + // 4/1 == 2² + { + decodeHex("0400000000000000000000000000000000000000000000000000000000000000"), + decodeHex("0100000000000000000000000000000000000000000000000000000000000000"), + 1, decodeHex("0200000000000000000000000000000000000000000000000000000000000000"), + }, + // 1/4 == (2⁻¹)² == (2^(p-2))² per Euler's theorem + { + decodeHex("0100000000000000000000000000000000000000000000000000000000000000"), + decodeHex("0400000000000000000000000000000000000000000000000000000000000000"), + 1, decodeHex("f6ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff3f"), + }, + } + + for i, tt := range tests { + u, _ := new(Element).SetBytes(tt.u) + v, _ := new(Element).SetBytes(tt.v) + want, _ := new(Element).SetBytes(tt.r) + got, wasSquare := new(Element).SqrtRatio(u, v) + if got.Equal(want) == 0 || wasSquare != tt.wasSquare { + t.Errorf("%d: got (%v, %v), want (%v, %v)", i, got, wasSquare, want, tt.wasSquare) + } + } +} + +func TestFeSquare(t *testing.T) { + asmLikeGeneric := func(a Element) bool { + t1 := a + t2 := a + + feSquareGeneric(&t1, &t1) + feSquare(&t2, &t2) + + if t1 != t2 { + t.Logf("got: %#v,\nexpected: %#v", t1, t2) + } + + return t1 == t2 && isInBounds(&t2) + } + + if err := quick.Check(asmLikeGeneric, quickCheckConfig(1024)); err != nil { + t.Error(err) + } +} + +func TestFeMul(t *testing.T) { + asmLikeGeneric := func(a, b Element) bool { + a1 := a + a2 := a + b1 := b + b2 := b + + feMulGeneric(&a1, &a1, &b1) + feMul(&a2, &a2, &b2) + + if a1 != a2 || b1 != b2 { + t.Logf("got: %#v,\nexpected: %#v", a1, a2) + t.Logf("got: %#v,\nexpected: %#v", b1, b2) + } + + return a1 == a2 && isInBounds(&a2) && + b1 == b2 && isInBounds(&b2) + } + + if err := quick.Check(asmLikeGeneric, quickCheckConfig(1024)); err != nil { + t.Error(err) + } +} + +func decodeHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/go.mod b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/go.mod new file mode 100644 index 0000000..f481953 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/go.mod @@ -0,0 +1,3 @@ +module filippo.io/edwards25519 + +go 1.24.0 diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/pull.sh b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/pull.sh new file mode 100644 index 0000000..f6217c9 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/pull.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +TAG="$1" +TMPDIR="$(mktemp -d)" + +cleanup() { + rm -rf "$TMPDIR" +} +trap cleanup EXIT + +command -v git >/dev/null +command -v git-filter-repo >/dev/null + +if [ -d "$HOME/go/.git" ]; then + REFERENCE=(--reference "$HOME/go" --dissociate) +else + REFERENCE=() +fi + +git -c advice.detachedHead=false clone --no-checkout "${REFERENCE[@]}" \ + -b "$TAG" https://go.googlesource.com/go.git "$TMPDIR" + +# Simplify the history graph by removing the dev.boringcrypto branches, whose +# merges end up empty after grafting anyway. This also fixes a weird quirk +# (maybe a git-filter-repo bug?) where only one file from an old path, +# src/crypto/ed25519/internal/edwards25519/const.go, would still exist in the +# filtered repo. +git -C "$TMPDIR" replace --graft f771edd7f9 99f1bf54eb +git -C "$TMPDIR" replace --graft 109c13b64f c2f96e686f +git -C "$TMPDIR" replace --graft aa4da4f189 912f075047 + +git -C "$TMPDIR" filter-repo --force \ + --paths-from-file /dev/stdin \ + --prune-empty always \ + --prune-degenerate always \ + --tag-callback 'tag.skip()' <<'EOF' +src/crypto/internal/fips140/edwards25519 +src/crypto/internal/edwards25519 +src/crypto/ed25519/internal/edwards25519 +EOF + +git fetch "$TMPDIR" +git update-ref "refs/heads/upstream/$TAG" FETCH_HEAD + +echo +echo "Fetched upstream history up to $TAG. Merge with:" +echo -e "\tgit merge --no-ff --no-commit --allow-unrelated-histories upstream/$TAG" diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar.go new file mode 100644 index 0000000..f08b262 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar.go @@ -0,0 +1,352 @@ +// Copyright (c) 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import ( + "encoding/binary" + "errors" + "math/bits" +) + +// A Scalar is an integer modulo +// +// l = 2^252 + 27742317777372353535851937790883648493 +// +// which is the prime order of the edwards25519 group. +// +// This type works similarly to math/big.Int, and all arguments and +// receivers are allowed to alias. +// +// The zero value is a valid zero element. +type Scalar struct { + // s is the scalar in the Montgomery domain, in the format of the + // fiat-crypto implementation. + s fiatScalarMontgomeryDomainFieldElement +} + +// The field implementation in scalar_fiat.go is generated by the fiat-crypto +// project (https://github.com/mit-plv/fiat-crypto) at version v0.0.9 (23d2dbc) +// from a formally verified model. +// +// fiat-crypto code comes under the following license. +// +// Copyright (c) 2015-2020 The fiat-crypto Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// THIS SOFTWARE IS PROVIDED BY the fiat-crypto authors "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Berkeley Software Design, +// Inc. BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +// LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// + +// NewScalar returns a new zero Scalar. +func NewScalar() *Scalar { + return &Scalar{} +} + +// MultiplyAdd sets s = x * y + z mod l, and returns s. It is equivalent to +// using Multiply and then Add. +func (s *Scalar) MultiplyAdd(x, y, z *Scalar) *Scalar { + // Make a copy of z in case it aliases s. + zCopy := new(Scalar).Set(z) + return s.Multiply(x, y).Add(s, zCopy) +} + +// Add sets s = x + y mod l, and returns s. +func (s *Scalar) Add(x, y *Scalar) *Scalar { + // s = 1 * x + y mod l + fiatScalarAdd(&s.s, &x.s, &y.s) + return s +} + +// Subtract sets s = x - y mod l, and returns s. +func (s *Scalar) Subtract(x, y *Scalar) *Scalar { + // s = -1 * y + x mod l + fiatScalarSub(&s.s, &x.s, &y.s) + return s +} + +// Negate sets s = -x mod l, and returns s. +func (s *Scalar) Negate(x *Scalar) *Scalar { + // s = -1 * x + 0 mod l + fiatScalarOpp(&s.s, &x.s) + return s +} + +// Multiply sets s = x * y mod l, and returns s. +func (s *Scalar) Multiply(x, y *Scalar) *Scalar { + // s = x * y + 0 mod l + fiatScalarMul(&s.s, &x.s, &y.s) + return s +} + +// Set sets s = x, and returns s. +func (s *Scalar) Set(x *Scalar) *Scalar { + *s = *x + return s +} + +// SetUniformBytes sets s = x mod l, where x is a 64-byte little-endian integer. +// If x is not of the right length, SetUniformBytes returns nil and an error, +// and the receiver is unchanged. +// +// SetUniformBytes can be used to set s to a uniformly distributed value given +// 64 uniformly distributed random bytes. +func (s *Scalar) SetUniformBytes(x []byte) (*Scalar, error) { + if len(x) != 64 { + return nil, errors.New("edwards25519: invalid SetUniformBytes input length") + } + + // We have a value x of 512 bits, but our fiatScalarFromBytes function + // expects an input lower than l, which is a little over 252 bits. + // + // Instead of writing a reduction function that operates on wider inputs, we + // can interpret x as the sum of three shorter values a, b, and c. + // + // x = a + b * 2^168 + c * 2^336 mod l + // + // We then precompute 2^168 and 2^336 modulo l, and perform the reduction + // with two multiplications and two additions. + + s.setShortBytes(x[:21]) + t := new(Scalar).setShortBytes(x[21:42]) + s.Add(s, t.Multiply(t, scalarTwo168)) + t.setShortBytes(x[42:]) + s.Add(s, t.Multiply(t, scalarTwo336)) + + return s, nil +} + +// scalarTwo168 and scalarTwo336 are 2^168 and 2^336 modulo l, encoded as a +// fiatScalarMontgomeryDomainFieldElement, which is a little-endian 4-limb value +// in the 2^256 Montgomery domain. +var scalarTwo168 = &Scalar{s: [4]uint64{0x5b8ab432eac74798, 0x38afddd6de59d5d7, + 0xa2c131b399411b7c, 0x6329a7ed9ce5a30}} +var scalarTwo336 = &Scalar{s: [4]uint64{0xbd3d108e2b35ecc5, 0x5c3a3718bdf9c90b, + 0x63aa97a331b4f2ee, 0x3d217f5be65cb5c}} + +// setShortBytes sets s = x mod l, where x is a little-endian integer shorter +// than 32 bytes. +func (s *Scalar) setShortBytes(x []byte) *Scalar { + if len(x) >= 32 { + panic("edwards25519: internal error: setShortBytes called with a long string") + } + var buf [32]byte + copy(buf[:], x) + fiatScalarFromBytes((*[4]uint64)(&s.s), &buf) + fiatScalarToMontgomery(&s.s, (*fiatScalarNonMontgomeryDomainFieldElement)(&s.s)) + return s +} + +// SetCanonicalBytes sets s = x, where x is a 32-byte little-endian encoding of +// s, and returns s. If x is not a canonical encoding of s, SetCanonicalBytes +// returns nil and an error, and the receiver is unchanged. +func (s *Scalar) SetCanonicalBytes(x []byte) (*Scalar, error) { + if len(x) != 32 { + return nil, errors.New("invalid scalar length") + } + if !isReduced(x) { + return nil, errors.New("invalid scalar encoding") + } + + fiatScalarFromBytes((*[4]uint64)(&s.s), (*[32]byte)(x)) + fiatScalarToMontgomery(&s.s, (*fiatScalarNonMontgomeryDomainFieldElement)(&s.s)) + + return s, nil +} + +// scalarMinusOneBytes is l - 1 in little endian. +var scalarMinusOneBytes = [32]byte{236, 211, 245, 92, 26, 99, 18, 88, 214, 156, 247, 162, 222, 249, 222, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16} + +// isReduced returns whether the given scalar in 32-byte little endian encoded +// form is reduced modulo l. +func isReduced(s []byte) bool { + if len(s) != 32 { + return false + } + + s0 := binary.LittleEndian.Uint64(s[:8]) + s1 := binary.LittleEndian.Uint64(s[8:16]) + s2 := binary.LittleEndian.Uint64(s[16:24]) + s3 := binary.LittleEndian.Uint64(s[24:]) + + l0 := binary.LittleEndian.Uint64(scalarMinusOneBytes[:8]) + l1 := binary.LittleEndian.Uint64(scalarMinusOneBytes[8:16]) + l2 := binary.LittleEndian.Uint64(scalarMinusOneBytes[16:24]) + l3 := binary.LittleEndian.Uint64(scalarMinusOneBytes[24:]) + + // Do a constant time subtraction chain scalarMinusOneBytes - s. If there is + // a borrow at the end, then s > scalarMinusOneBytes. + _, b := bits.Sub64(l0, s0, 0) + _, b = bits.Sub64(l1, s1, b) + _, b = bits.Sub64(l2, s2, b) + _, b = bits.Sub64(l3, s3, b) + return b == 0 +} + +// SetBytesWithClamping applies the buffer pruning described in RFC 8032, +// Section 5.1.5 (also known as clamping) and sets s to the result. The input +// must be 32 bytes, and it is not modified. If x is not of the right length, +// SetBytesWithClamping returns nil and an error, and the receiver is unchanged. +// +// Note that since Scalar values are always reduced modulo the prime order of +// the curve, the resulting value will not preserve any of the cofactor-clearing +// properties that clamping is meant to provide. It will however work as +// expected as long as it is applied to points on the prime order subgroup, like +// in Ed25519. In fact, it is lost to history why RFC 8032 adopted the +// irrelevant RFC 7748 clamping, but it is now required for compatibility. +func (s *Scalar) SetBytesWithClamping(x []byte) (*Scalar, error) { + // The description above omits the purpose of the high bits of the clamping + // for brevity, but those are also lost to reductions, and are also + // irrelevant to edwards25519 as they protect against a specific + // implementation bug that was once observed in a generic Montgomery ladder. + if len(x) != 32 { + return nil, errors.New("edwards25519: invalid SetBytesWithClamping input length") + } + + // We need to use the wide reduction from SetUniformBytes, since clamping + // sets the 2^254 bit, making the value higher than the order. + var wideBytes [64]byte + copy(wideBytes[:], x[:]) + wideBytes[0] &= 248 + wideBytes[31] &= 63 + wideBytes[31] |= 64 + return s.SetUniformBytes(wideBytes[:]) +} + +// Bytes returns the canonical 32-byte little-endian encoding of s. +func (s *Scalar) Bytes() []byte { + // This function is outlined to make the allocations inline in the caller + // rather than happen on the heap. + var encoded [32]byte + return s.bytes(&encoded) +} + +func (s *Scalar) bytes(out *[32]byte) []byte { + var ss fiatScalarNonMontgomeryDomainFieldElement + fiatScalarFromMontgomery(&ss, &s.s) + fiatScalarToBytes(out, (*[4]uint64)(&ss)) + return out[:] +} + +// Equal returns 1 if s and t are equal, and 0 otherwise. +func (s *Scalar) Equal(t *Scalar) int { + var diff fiatScalarMontgomeryDomainFieldElement + fiatScalarSub(&diff, &s.s, &t.s) + var nonzero uint64 + fiatScalarNonzero(&nonzero, (*[4]uint64)(&diff)) + nonzero |= nonzero >> 32 + nonzero |= nonzero >> 16 + nonzero |= nonzero >> 8 + nonzero |= nonzero >> 4 + nonzero |= nonzero >> 2 + nonzero |= nonzero >> 1 + return int(^nonzero) & 1 +} + +// nonAdjacentForm computes a width-w non-adjacent form for this scalar. +// +// w must be between 2 and 8, or nonAdjacentForm will panic. +func (s *Scalar) nonAdjacentForm(w uint) [256]int8 { + // This implementation is adapted from the one + // in curve25519-dalek and is documented there: + // https://github.com/dalek-cryptography/curve25519-dalek/blob/f630041af28e9a405255f98a8a93adca18e4315b/src/scalar.rs#L800-L871 + b := s.Bytes() + if b[31] > 127 { + panic("scalar has high bit set illegally") + } + if w < 2 { + panic("w must be at least 2 by the definition of NAF") + } else if w > 8 { + panic("NAF digits must fit in int8") + } + + var naf [256]int8 + var digits [5]uint64 + + for i := 0; i < 4; i++ { + digits[i] = binary.LittleEndian.Uint64(b[i*8:]) + } + + width := uint64(1 << w) + windowMask := uint64(width - 1) + + pos := uint(0) + carry := uint64(0) + for pos < 256 { + indexU64 := pos / 64 + indexBit := pos % 64 + var bitBuf uint64 + if indexBit < 64-w { + // This window's bits are contained in a single u64 + bitBuf = digits[indexU64] >> indexBit + } else { + // Combine the current 64 bits with bits from the next 64 + bitBuf = (digits[indexU64] >> indexBit) | (digits[1+indexU64] << (64 - indexBit)) + } + + // Add carry into the current window + window := carry + (bitBuf & windowMask) + + if window&1 == 0 { + // If the window value is even, preserve the carry and continue. + // Why is the carry preserved? + // If carry == 0 and window & 1 == 0, + // then the next carry should be 0 + // If carry == 1 and window & 1 == 0, + // then bit_buf & 1 == 1 so the next carry should be 1 + pos += 1 + continue + } + + if window < width/2 { + carry = 0 + naf[pos] = int8(window) + } else { + carry = 1 + naf[pos] = int8(window) - int8(width) + } + + pos += w + } + return naf +} + +func (s *Scalar) signedRadix16() [64]int8 { + b := s.Bytes() + if b[31] > 127 { + panic("scalar has high bit set illegally") + } + + var digits [64]int8 + + // Compute unsigned radix-16 digits: + for i := 0; i < 32; i++ { + digits[2*i] = int8(b[i] & 15) + digits[2*i+1] = int8((b[i] >> 4) & 15) + } + + // Recenter coefficients: + for i := 0; i < 63; i++ { + carry := (digits[i] + 8) >> 4 + digits[i] -= carry << 4 + digits[i+1] += carry + } + + return digits +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_alias_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_alias_test.go new file mode 100644 index 0000000..8cd865d --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_alias_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import ( + "testing" + "testing/quick" +) + +func TestScalarAliasing(t *testing.T) { + checkAliasingOneArg := func(f func(v, x *Scalar) *Scalar, v, x Scalar) bool { + x1, v1 := x, x + + // Calculate a reference f(x) without aliasing. + if out := f(&v, &x); out != &v || !isReduced(out.Bytes()) { + return false + } + + // Test aliasing the argument and the receiver. + if out := f(&v1, &v1); out != &v1 || v1 != v || !isReduced(out.Bytes()) { + return false + } + + // Ensure the arguments was not modified. + return x == x1 + } + + checkAliasingTwoArgs := func(f func(v, x, y *Scalar) *Scalar, v, x, y Scalar) bool { + x1, y1, v1 := x, y, Scalar{} + + // Calculate a reference f(x, y) without aliasing. + if out := f(&v, &x, &y); out != &v || !isReduced(out.Bytes()) { + return false + } + + // Test aliasing the first argument and the receiver. + v1 = x + if out := f(&v1, &v1, &y); out != &v1 || v1 != v || !isReduced(out.Bytes()) { + return false + } + // Test aliasing the second argument and the receiver. + v1 = y + if out := f(&v1, &x, &v1); out != &v1 || v1 != v || !isReduced(out.Bytes()) { + return false + } + + // Calculate a reference f(x, x) without aliasing. + if out := f(&v, &x, &x); out != &v || !isReduced(out.Bytes()) { + return false + } + + // Test aliasing the first argument and the receiver. + v1 = x + if out := f(&v1, &v1, &x); out != &v1 || v1 != v || !isReduced(out.Bytes()) { + return false + } + // Test aliasing the second argument and the receiver. + v1 = x + if out := f(&v1, &x, &v1); out != &v1 || v1 != v || !isReduced(out.Bytes()) { + return false + } + // Test aliasing both arguments and the receiver. + v1 = x + if out := f(&v1, &v1, &v1); out != &v1 || v1 != v || !isReduced(out.Bytes()) { + return false + } + + // Ensure the arguments were not modified. + return x == x1 && y == y1 + } + + for name, f := range map[string]any{ + "Negate": func(v, x Scalar) bool { + return checkAliasingOneArg((*Scalar).Negate, v, x) + }, + "Invert": func(v, x Scalar) bool { + return checkAliasingOneArg((*Scalar).Invert, v, x) + }, + "Multiply": func(v, x, y Scalar) bool { + return checkAliasingTwoArgs((*Scalar).Multiply, v, x, y) + }, + "Add": func(v, x, y Scalar) bool { + return checkAliasingTwoArgs((*Scalar).Add, v, x, y) + }, + "Subtract": func(v, x, y Scalar) bool { + return checkAliasingTwoArgs((*Scalar).Subtract, v, x, y) + }, + "MultiplyAdd1": func(v, x, y, fixed Scalar) bool { + return checkAliasingTwoArgs(func(v, x, y *Scalar) *Scalar { + return v.MultiplyAdd(&fixed, x, y) + }, v, x, y) + }, + "MultiplyAdd2": func(v, x, y, fixed Scalar) bool { + return checkAliasingTwoArgs(func(v, x, y *Scalar) *Scalar { + return v.MultiplyAdd(x, &fixed, y) + }, v, x, y) + }, + "MultiplyAdd3": func(v, x, y, fixed Scalar) bool { + return checkAliasingTwoArgs(func(v, x, y *Scalar) *Scalar { + return v.MultiplyAdd(x, y, &fixed) + }, v, x, y) + }, + } { + err := quick.Check(f, quickCheckConfig(32)) + if err != nil { + t.Errorf("%v: %v", name, err) + } + } +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_fiat.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_fiat.go new file mode 100644 index 0000000..2e5782b --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_fiat.go @@ -0,0 +1,1147 @@ +// Code generated by Fiat Cryptography. DO NOT EDIT. +// +// Autogenerated: word_by_word_montgomery --lang Go --cmovznz-by-mul --relax-primitive-carry-to-bitwidth 32,64 --public-function-case camelCase --public-type-case camelCase --private-function-case camelCase --private-type-case camelCase --doc-text-before-function-name '' --doc-newline-before-package-declaration --doc-prepend-header 'Code generated by Fiat Cryptography. DO NOT EDIT.' --package-name edwards25519 Scalar 64 '2^252 + 27742317777372353535851937790883648493' mul add sub opp nonzero from_montgomery to_montgomery to_bytes from_bytes +// +// curve description: Scalar +// +// machine_wordsize = 64 (from "64") +// +// requested operations: mul, add, sub, opp, nonzero, from_montgomery, to_montgomery, to_bytes, from_bytes +// +// m = 0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed (from "2^252 + 27742317777372353535851937790883648493") +// +// +// +// NOTE: In addition to the bounds specified above each function, all +// +// functions synthesized for this Montgomery arithmetic require the +// +// input to be strictly less than the prime modulus (m), and also +// +// require the input to be in the unique saturated representation. +// +// All functions also ensure that these two properties are true of +// +// return values. +// +// +// +// Computed values: +// +// eval z = z[0] + (z[1] << 64) + (z[2] << 128) + (z[3] << 192) +// +// bytes_eval z = z[0] + (z[1] << 8) + (z[2] << 16) + (z[3] << 24) + (z[4] << 32) + (z[5] << 40) + (z[6] << 48) + (z[7] << 56) + (z[8] << 64) + (z[9] << 72) + (z[10] << 80) + (z[11] << 88) + (z[12] << 96) + (z[13] << 104) + (z[14] << 112) + (z[15] << 120) + (z[16] << 128) + (z[17] << 136) + (z[18] << 144) + (z[19] << 152) + (z[20] << 160) + (z[21] << 168) + (z[22] << 176) + (z[23] << 184) + (z[24] << 192) + (z[25] << 200) + (z[26] << 208) + (z[27] << 216) + (z[28] << 224) + (z[29] << 232) + (z[30] << 240) + (z[31] << 248) +// +// twos_complement_eval z = let x1 := z[0] + (z[1] << 64) + (z[2] << 128) + (z[3] << 192) in +// +// if x1 & (2^256-1) < 2^255 then x1 & (2^256-1) else (x1 & (2^256-1)) - 2^256 + +package edwards25519 + +import "math/bits" + +type fiatScalarUint1 uint64 // We use uint64 instead of a more narrow type for performance reasons; see https://github.com/mit-plv/fiat-crypto/pull/1006#issuecomment-892625927 +type fiatScalarInt1 int64 // We use uint64 instead of a more narrow type for performance reasons; see https://github.com/mit-plv/fiat-crypto/pull/1006#issuecomment-892625927 + +// The type fiatScalarMontgomeryDomainFieldElement is a field element in the Montgomery domain. +// +// Bounds: [[0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff]] +type fiatScalarMontgomeryDomainFieldElement [4]uint64 + +// The type fiatScalarNonMontgomeryDomainFieldElement is a field element NOT in the Montgomery domain. +// +// Bounds: [[0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff]] +type fiatScalarNonMontgomeryDomainFieldElement [4]uint64 + +// fiatScalarCmovznzU64 is a single-word conditional move. +// +// Postconditions: +// +// out1 = (if arg1 = 0 then arg2 else arg3) +// +// Input Bounds: +// +// arg1: [0x0 ~> 0x1] +// arg2: [0x0 ~> 0xffffffffffffffff] +// arg3: [0x0 ~> 0xffffffffffffffff] +// +// Output Bounds: +// +// out1: [0x0 ~> 0xffffffffffffffff] +func fiatScalarCmovznzU64(out1 *uint64, arg1 fiatScalarUint1, arg2 uint64, arg3 uint64) { + x1 := (uint64(arg1) * 0xffffffffffffffff) + x2 := ((x1 & arg3) | ((^x1) & arg2)) + *out1 = x2 +} + +// fiatScalarMul multiplies two field elements in the Montgomery domain. +// +// Preconditions: +// +// 0 ≤ eval arg1 < m +// 0 ≤ eval arg2 < m +// +// Postconditions: +// +// eval (from_montgomery out1) mod m = (eval (from_montgomery arg1) * eval (from_montgomery arg2)) mod m +// 0 ≤ eval out1 < m +func fiatScalarMul(out1 *fiatScalarMontgomeryDomainFieldElement, arg1 *fiatScalarMontgomeryDomainFieldElement, arg2 *fiatScalarMontgomeryDomainFieldElement) { + x1 := arg1[1] + x2 := arg1[2] + x3 := arg1[3] + x4 := arg1[0] + var x5 uint64 + var x6 uint64 + x6, x5 = bits.Mul64(x4, arg2[3]) + var x7 uint64 + var x8 uint64 + x8, x7 = bits.Mul64(x4, arg2[2]) + var x9 uint64 + var x10 uint64 + x10, x9 = bits.Mul64(x4, arg2[1]) + var x11 uint64 + var x12 uint64 + x12, x11 = bits.Mul64(x4, arg2[0]) + var x13 uint64 + var x14 uint64 + x13, x14 = bits.Add64(x12, x9, uint64(0x0)) + var x15 uint64 + var x16 uint64 + x15, x16 = bits.Add64(x10, x7, uint64(fiatScalarUint1(x14))) + var x17 uint64 + var x18 uint64 + x17, x18 = bits.Add64(x8, x5, uint64(fiatScalarUint1(x16))) + x19 := (uint64(fiatScalarUint1(x18)) + x6) + var x20 uint64 + _, x20 = bits.Mul64(x11, 0xd2b51da312547e1b) + var x22 uint64 + var x23 uint64 + x23, x22 = bits.Mul64(x20, 0x1000000000000000) + var x24 uint64 + var x25 uint64 + x25, x24 = bits.Mul64(x20, 0x14def9dea2f79cd6) + var x26 uint64 + var x27 uint64 + x27, x26 = bits.Mul64(x20, 0x5812631a5cf5d3ed) + var x28 uint64 + var x29 uint64 + x28, x29 = bits.Add64(x27, x24, uint64(0x0)) + x30 := (uint64(fiatScalarUint1(x29)) + x25) + var x32 uint64 + _, x32 = bits.Add64(x11, x26, uint64(0x0)) + var x33 uint64 + var x34 uint64 + x33, x34 = bits.Add64(x13, x28, uint64(fiatScalarUint1(x32))) + var x35 uint64 + var x36 uint64 + x35, x36 = bits.Add64(x15, x30, uint64(fiatScalarUint1(x34))) + var x37 uint64 + var x38 uint64 + x37, x38 = bits.Add64(x17, x22, uint64(fiatScalarUint1(x36))) + var x39 uint64 + var x40 uint64 + x39, x40 = bits.Add64(x19, x23, uint64(fiatScalarUint1(x38))) + var x41 uint64 + var x42 uint64 + x42, x41 = bits.Mul64(x1, arg2[3]) + var x43 uint64 + var x44 uint64 + x44, x43 = bits.Mul64(x1, arg2[2]) + var x45 uint64 + var x46 uint64 + x46, x45 = bits.Mul64(x1, arg2[1]) + var x47 uint64 + var x48 uint64 + x48, x47 = bits.Mul64(x1, arg2[0]) + var x49 uint64 + var x50 uint64 + x49, x50 = bits.Add64(x48, x45, uint64(0x0)) + var x51 uint64 + var x52 uint64 + x51, x52 = bits.Add64(x46, x43, uint64(fiatScalarUint1(x50))) + var x53 uint64 + var x54 uint64 + x53, x54 = bits.Add64(x44, x41, uint64(fiatScalarUint1(x52))) + x55 := (uint64(fiatScalarUint1(x54)) + x42) + var x56 uint64 + var x57 uint64 + x56, x57 = bits.Add64(x33, x47, uint64(0x0)) + var x58 uint64 + var x59 uint64 + x58, x59 = bits.Add64(x35, x49, uint64(fiatScalarUint1(x57))) + var x60 uint64 + var x61 uint64 + x60, x61 = bits.Add64(x37, x51, uint64(fiatScalarUint1(x59))) + var x62 uint64 + var x63 uint64 + x62, x63 = bits.Add64(x39, x53, uint64(fiatScalarUint1(x61))) + var x64 uint64 + var x65 uint64 + x64, x65 = bits.Add64(uint64(fiatScalarUint1(x40)), x55, uint64(fiatScalarUint1(x63))) + var x66 uint64 + _, x66 = bits.Mul64(x56, 0xd2b51da312547e1b) + var x68 uint64 + var x69 uint64 + x69, x68 = bits.Mul64(x66, 0x1000000000000000) + var x70 uint64 + var x71 uint64 + x71, x70 = bits.Mul64(x66, 0x14def9dea2f79cd6) + var x72 uint64 + var x73 uint64 + x73, x72 = bits.Mul64(x66, 0x5812631a5cf5d3ed) + var x74 uint64 + var x75 uint64 + x74, x75 = bits.Add64(x73, x70, uint64(0x0)) + x76 := (uint64(fiatScalarUint1(x75)) + x71) + var x78 uint64 + _, x78 = bits.Add64(x56, x72, uint64(0x0)) + var x79 uint64 + var x80 uint64 + x79, x80 = bits.Add64(x58, x74, uint64(fiatScalarUint1(x78))) + var x81 uint64 + var x82 uint64 + x81, x82 = bits.Add64(x60, x76, uint64(fiatScalarUint1(x80))) + var x83 uint64 + var x84 uint64 + x83, x84 = bits.Add64(x62, x68, uint64(fiatScalarUint1(x82))) + var x85 uint64 + var x86 uint64 + x85, x86 = bits.Add64(x64, x69, uint64(fiatScalarUint1(x84))) + x87 := (uint64(fiatScalarUint1(x86)) + uint64(fiatScalarUint1(x65))) + var x88 uint64 + var x89 uint64 + x89, x88 = bits.Mul64(x2, arg2[3]) + var x90 uint64 + var x91 uint64 + x91, x90 = bits.Mul64(x2, arg2[2]) + var x92 uint64 + var x93 uint64 + x93, x92 = bits.Mul64(x2, arg2[1]) + var x94 uint64 + var x95 uint64 + x95, x94 = bits.Mul64(x2, arg2[0]) + var x96 uint64 + var x97 uint64 + x96, x97 = bits.Add64(x95, x92, uint64(0x0)) + var x98 uint64 + var x99 uint64 + x98, x99 = bits.Add64(x93, x90, uint64(fiatScalarUint1(x97))) + var x100 uint64 + var x101 uint64 + x100, x101 = bits.Add64(x91, x88, uint64(fiatScalarUint1(x99))) + x102 := (uint64(fiatScalarUint1(x101)) + x89) + var x103 uint64 + var x104 uint64 + x103, x104 = bits.Add64(x79, x94, uint64(0x0)) + var x105 uint64 + var x106 uint64 + x105, x106 = bits.Add64(x81, x96, uint64(fiatScalarUint1(x104))) + var x107 uint64 + var x108 uint64 + x107, x108 = bits.Add64(x83, x98, uint64(fiatScalarUint1(x106))) + var x109 uint64 + var x110 uint64 + x109, x110 = bits.Add64(x85, x100, uint64(fiatScalarUint1(x108))) + var x111 uint64 + var x112 uint64 + x111, x112 = bits.Add64(x87, x102, uint64(fiatScalarUint1(x110))) + var x113 uint64 + _, x113 = bits.Mul64(x103, 0xd2b51da312547e1b) + var x115 uint64 + var x116 uint64 + x116, x115 = bits.Mul64(x113, 0x1000000000000000) + var x117 uint64 + var x118 uint64 + x118, x117 = bits.Mul64(x113, 0x14def9dea2f79cd6) + var x119 uint64 + var x120 uint64 + x120, x119 = bits.Mul64(x113, 0x5812631a5cf5d3ed) + var x121 uint64 + var x122 uint64 + x121, x122 = bits.Add64(x120, x117, uint64(0x0)) + x123 := (uint64(fiatScalarUint1(x122)) + x118) + var x125 uint64 + _, x125 = bits.Add64(x103, x119, uint64(0x0)) + var x126 uint64 + var x127 uint64 + x126, x127 = bits.Add64(x105, x121, uint64(fiatScalarUint1(x125))) + var x128 uint64 + var x129 uint64 + x128, x129 = bits.Add64(x107, x123, uint64(fiatScalarUint1(x127))) + var x130 uint64 + var x131 uint64 + x130, x131 = bits.Add64(x109, x115, uint64(fiatScalarUint1(x129))) + var x132 uint64 + var x133 uint64 + x132, x133 = bits.Add64(x111, x116, uint64(fiatScalarUint1(x131))) + x134 := (uint64(fiatScalarUint1(x133)) + uint64(fiatScalarUint1(x112))) + var x135 uint64 + var x136 uint64 + x136, x135 = bits.Mul64(x3, arg2[3]) + var x137 uint64 + var x138 uint64 + x138, x137 = bits.Mul64(x3, arg2[2]) + var x139 uint64 + var x140 uint64 + x140, x139 = bits.Mul64(x3, arg2[1]) + var x141 uint64 + var x142 uint64 + x142, x141 = bits.Mul64(x3, arg2[0]) + var x143 uint64 + var x144 uint64 + x143, x144 = bits.Add64(x142, x139, uint64(0x0)) + var x145 uint64 + var x146 uint64 + x145, x146 = bits.Add64(x140, x137, uint64(fiatScalarUint1(x144))) + var x147 uint64 + var x148 uint64 + x147, x148 = bits.Add64(x138, x135, uint64(fiatScalarUint1(x146))) + x149 := (uint64(fiatScalarUint1(x148)) + x136) + var x150 uint64 + var x151 uint64 + x150, x151 = bits.Add64(x126, x141, uint64(0x0)) + var x152 uint64 + var x153 uint64 + x152, x153 = bits.Add64(x128, x143, uint64(fiatScalarUint1(x151))) + var x154 uint64 + var x155 uint64 + x154, x155 = bits.Add64(x130, x145, uint64(fiatScalarUint1(x153))) + var x156 uint64 + var x157 uint64 + x156, x157 = bits.Add64(x132, x147, uint64(fiatScalarUint1(x155))) + var x158 uint64 + var x159 uint64 + x158, x159 = bits.Add64(x134, x149, uint64(fiatScalarUint1(x157))) + var x160 uint64 + _, x160 = bits.Mul64(x150, 0xd2b51da312547e1b) + var x162 uint64 + var x163 uint64 + x163, x162 = bits.Mul64(x160, 0x1000000000000000) + var x164 uint64 + var x165 uint64 + x165, x164 = bits.Mul64(x160, 0x14def9dea2f79cd6) + var x166 uint64 + var x167 uint64 + x167, x166 = bits.Mul64(x160, 0x5812631a5cf5d3ed) + var x168 uint64 + var x169 uint64 + x168, x169 = bits.Add64(x167, x164, uint64(0x0)) + x170 := (uint64(fiatScalarUint1(x169)) + x165) + var x172 uint64 + _, x172 = bits.Add64(x150, x166, uint64(0x0)) + var x173 uint64 + var x174 uint64 + x173, x174 = bits.Add64(x152, x168, uint64(fiatScalarUint1(x172))) + var x175 uint64 + var x176 uint64 + x175, x176 = bits.Add64(x154, x170, uint64(fiatScalarUint1(x174))) + var x177 uint64 + var x178 uint64 + x177, x178 = bits.Add64(x156, x162, uint64(fiatScalarUint1(x176))) + var x179 uint64 + var x180 uint64 + x179, x180 = bits.Add64(x158, x163, uint64(fiatScalarUint1(x178))) + x181 := (uint64(fiatScalarUint1(x180)) + uint64(fiatScalarUint1(x159))) + var x182 uint64 + var x183 uint64 + x182, x183 = bits.Sub64(x173, 0x5812631a5cf5d3ed, uint64(0x0)) + var x184 uint64 + var x185 uint64 + x184, x185 = bits.Sub64(x175, 0x14def9dea2f79cd6, uint64(fiatScalarUint1(x183))) + var x186 uint64 + var x187 uint64 + x186, x187 = bits.Sub64(x177, uint64(0x0), uint64(fiatScalarUint1(x185))) + var x188 uint64 + var x189 uint64 + x188, x189 = bits.Sub64(x179, 0x1000000000000000, uint64(fiatScalarUint1(x187))) + var x191 uint64 + _, x191 = bits.Sub64(x181, uint64(0x0), uint64(fiatScalarUint1(x189))) + var x192 uint64 + fiatScalarCmovznzU64(&x192, fiatScalarUint1(x191), x182, x173) + var x193 uint64 + fiatScalarCmovznzU64(&x193, fiatScalarUint1(x191), x184, x175) + var x194 uint64 + fiatScalarCmovznzU64(&x194, fiatScalarUint1(x191), x186, x177) + var x195 uint64 + fiatScalarCmovznzU64(&x195, fiatScalarUint1(x191), x188, x179) + out1[0] = x192 + out1[1] = x193 + out1[2] = x194 + out1[3] = x195 +} + +// fiatScalarAdd adds two field elements in the Montgomery domain. +// +// Preconditions: +// +// 0 ≤ eval arg1 < m +// 0 ≤ eval arg2 < m +// +// Postconditions: +// +// eval (from_montgomery out1) mod m = (eval (from_montgomery arg1) + eval (from_montgomery arg2)) mod m +// 0 ≤ eval out1 < m +func fiatScalarAdd(out1 *fiatScalarMontgomeryDomainFieldElement, arg1 *fiatScalarMontgomeryDomainFieldElement, arg2 *fiatScalarMontgomeryDomainFieldElement) { + var x1 uint64 + var x2 uint64 + x1, x2 = bits.Add64(arg1[0], arg2[0], uint64(0x0)) + var x3 uint64 + var x4 uint64 + x3, x4 = bits.Add64(arg1[1], arg2[1], uint64(fiatScalarUint1(x2))) + var x5 uint64 + var x6 uint64 + x5, x6 = bits.Add64(arg1[2], arg2[2], uint64(fiatScalarUint1(x4))) + var x7 uint64 + var x8 uint64 + x7, x8 = bits.Add64(arg1[3], arg2[3], uint64(fiatScalarUint1(x6))) + var x9 uint64 + var x10 uint64 + x9, x10 = bits.Sub64(x1, 0x5812631a5cf5d3ed, uint64(0x0)) + var x11 uint64 + var x12 uint64 + x11, x12 = bits.Sub64(x3, 0x14def9dea2f79cd6, uint64(fiatScalarUint1(x10))) + var x13 uint64 + var x14 uint64 + x13, x14 = bits.Sub64(x5, uint64(0x0), uint64(fiatScalarUint1(x12))) + var x15 uint64 + var x16 uint64 + x15, x16 = bits.Sub64(x7, 0x1000000000000000, uint64(fiatScalarUint1(x14))) + var x18 uint64 + _, x18 = bits.Sub64(uint64(fiatScalarUint1(x8)), uint64(0x0), uint64(fiatScalarUint1(x16))) + var x19 uint64 + fiatScalarCmovznzU64(&x19, fiatScalarUint1(x18), x9, x1) + var x20 uint64 + fiatScalarCmovznzU64(&x20, fiatScalarUint1(x18), x11, x3) + var x21 uint64 + fiatScalarCmovznzU64(&x21, fiatScalarUint1(x18), x13, x5) + var x22 uint64 + fiatScalarCmovznzU64(&x22, fiatScalarUint1(x18), x15, x7) + out1[0] = x19 + out1[1] = x20 + out1[2] = x21 + out1[3] = x22 +} + +// fiatScalarSub subtracts two field elements in the Montgomery domain. +// +// Preconditions: +// +// 0 ≤ eval arg1 < m +// 0 ≤ eval arg2 < m +// +// Postconditions: +// +// eval (from_montgomery out1) mod m = (eval (from_montgomery arg1) - eval (from_montgomery arg2)) mod m +// 0 ≤ eval out1 < m +func fiatScalarSub(out1 *fiatScalarMontgomeryDomainFieldElement, arg1 *fiatScalarMontgomeryDomainFieldElement, arg2 *fiatScalarMontgomeryDomainFieldElement) { + var x1 uint64 + var x2 uint64 + x1, x2 = bits.Sub64(arg1[0], arg2[0], uint64(0x0)) + var x3 uint64 + var x4 uint64 + x3, x4 = bits.Sub64(arg1[1], arg2[1], uint64(fiatScalarUint1(x2))) + var x5 uint64 + var x6 uint64 + x5, x6 = bits.Sub64(arg1[2], arg2[2], uint64(fiatScalarUint1(x4))) + var x7 uint64 + var x8 uint64 + x7, x8 = bits.Sub64(arg1[3], arg2[3], uint64(fiatScalarUint1(x6))) + var x9 uint64 + fiatScalarCmovznzU64(&x9, fiatScalarUint1(x8), uint64(0x0), 0xffffffffffffffff) + var x10 uint64 + var x11 uint64 + x10, x11 = bits.Add64(x1, (x9 & 0x5812631a5cf5d3ed), uint64(0x0)) + var x12 uint64 + var x13 uint64 + x12, x13 = bits.Add64(x3, (x9 & 0x14def9dea2f79cd6), uint64(fiatScalarUint1(x11))) + var x14 uint64 + var x15 uint64 + x14, x15 = bits.Add64(x5, uint64(0x0), uint64(fiatScalarUint1(x13))) + var x16 uint64 + x16, _ = bits.Add64(x7, (x9 & 0x1000000000000000), uint64(fiatScalarUint1(x15))) + out1[0] = x10 + out1[1] = x12 + out1[2] = x14 + out1[3] = x16 +} + +// fiatScalarOpp negates a field element in the Montgomery domain. +// +// Preconditions: +// +// 0 ≤ eval arg1 < m +// +// Postconditions: +// +// eval (from_montgomery out1) mod m = -eval (from_montgomery arg1) mod m +// 0 ≤ eval out1 < m +func fiatScalarOpp(out1 *fiatScalarMontgomeryDomainFieldElement, arg1 *fiatScalarMontgomeryDomainFieldElement) { + var x1 uint64 + var x2 uint64 + x1, x2 = bits.Sub64(uint64(0x0), arg1[0], uint64(0x0)) + var x3 uint64 + var x4 uint64 + x3, x4 = bits.Sub64(uint64(0x0), arg1[1], uint64(fiatScalarUint1(x2))) + var x5 uint64 + var x6 uint64 + x5, x6 = bits.Sub64(uint64(0x0), arg1[2], uint64(fiatScalarUint1(x4))) + var x7 uint64 + var x8 uint64 + x7, x8 = bits.Sub64(uint64(0x0), arg1[3], uint64(fiatScalarUint1(x6))) + var x9 uint64 + fiatScalarCmovznzU64(&x9, fiatScalarUint1(x8), uint64(0x0), 0xffffffffffffffff) + var x10 uint64 + var x11 uint64 + x10, x11 = bits.Add64(x1, (x9 & 0x5812631a5cf5d3ed), uint64(0x0)) + var x12 uint64 + var x13 uint64 + x12, x13 = bits.Add64(x3, (x9 & 0x14def9dea2f79cd6), uint64(fiatScalarUint1(x11))) + var x14 uint64 + var x15 uint64 + x14, x15 = bits.Add64(x5, uint64(0x0), uint64(fiatScalarUint1(x13))) + var x16 uint64 + x16, _ = bits.Add64(x7, (x9 & 0x1000000000000000), uint64(fiatScalarUint1(x15))) + out1[0] = x10 + out1[1] = x12 + out1[2] = x14 + out1[3] = x16 +} + +// fiatScalarNonzero outputs a single non-zero word if the input is non-zero and zero otherwise. +// +// Preconditions: +// +// 0 ≤ eval arg1 < m +// +// Postconditions: +// +// out1 = 0 ↔ eval (from_montgomery arg1) mod m = 0 +// +// Input Bounds: +// +// arg1: [[0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff]] +// +// Output Bounds: +// +// out1: [0x0 ~> 0xffffffffffffffff] +func fiatScalarNonzero(out1 *uint64, arg1 *[4]uint64) { + x1 := (arg1[0] | (arg1[1] | (arg1[2] | arg1[3]))) + *out1 = x1 +} + +// fiatScalarFromMontgomery translates a field element out of the Montgomery domain. +// +// Preconditions: +// +// 0 ≤ eval arg1 < m +// +// Postconditions: +// +// eval out1 mod m = (eval arg1 * ((2^64)⁻¹ mod m)^4) mod m +// 0 ≤ eval out1 < m +func fiatScalarFromMontgomery(out1 *fiatScalarNonMontgomeryDomainFieldElement, arg1 *fiatScalarMontgomeryDomainFieldElement) { + x1 := arg1[0] + var x2 uint64 + _, x2 = bits.Mul64(x1, 0xd2b51da312547e1b) + var x4 uint64 + var x5 uint64 + x5, x4 = bits.Mul64(x2, 0x1000000000000000) + var x6 uint64 + var x7 uint64 + x7, x6 = bits.Mul64(x2, 0x14def9dea2f79cd6) + var x8 uint64 + var x9 uint64 + x9, x8 = bits.Mul64(x2, 0x5812631a5cf5d3ed) + var x10 uint64 + var x11 uint64 + x10, x11 = bits.Add64(x9, x6, uint64(0x0)) + var x13 uint64 + _, x13 = bits.Add64(x1, x8, uint64(0x0)) + var x14 uint64 + var x15 uint64 + x14, x15 = bits.Add64(uint64(0x0), x10, uint64(fiatScalarUint1(x13))) + var x16 uint64 + var x17 uint64 + x16, x17 = bits.Add64(x14, arg1[1], uint64(0x0)) + var x18 uint64 + _, x18 = bits.Mul64(x16, 0xd2b51da312547e1b) + var x20 uint64 + var x21 uint64 + x21, x20 = bits.Mul64(x18, 0x1000000000000000) + var x22 uint64 + var x23 uint64 + x23, x22 = bits.Mul64(x18, 0x14def9dea2f79cd6) + var x24 uint64 + var x25 uint64 + x25, x24 = bits.Mul64(x18, 0x5812631a5cf5d3ed) + var x26 uint64 + var x27 uint64 + x26, x27 = bits.Add64(x25, x22, uint64(0x0)) + var x29 uint64 + _, x29 = bits.Add64(x16, x24, uint64(0x0)) + var x30 uint64 + var x31 uint64 + x30, x31 = bits.Add64((uint64(fiatScalarUint1(x17)) + (uint64(fiatScalarUint1(x15)) + (uint64(fiatScalarUint1(x11)) + x7))), x26, uint64(fiatScalarUint1(x29))) + var x32 uint64 + var x33 uint64 + x32, x33 = bits.Add64(x4, (uint64(fiatScalarUint1(x27)) + x23), uint64(fiatScalarUint1(x31))) + var x34 uint64 + var x35 uint64 + x34, x35 = bits.Add64(x5, x20, uint64(fiatScalarUint1(x33))) + var x36 uint64 + var x37 uint64 + x36, x37 = bits.Add64(x30, arg1[2], uint64(0x0)) + var x38 uint64 + var x39 uint64 + x38, x39 = bits.Add64(x32, uint64(0x0), uint64(fiatScalarUint1(x37))) + var x40 uint64 + var x41 uint64 + x40, x41 = bits.Add64(x34, uint64(0x0), uint64(fiatScalarUint1(x39))) + var x42 uint64 + _, x42 = bits.Mul64(x36, 0xd2b51da312547e1b) + var x44 uint64 + var x45 uint64 + x45, x44 = bits.Mul64(x42, 0x1000000000000000) + var x46 uint64 + var x47 uint64 + x47, x46 = bits.Mul64(x42, 0x14def9dea2f79cd6) + var x48 uint64 + var x49 uint64 + x49, x48 = bits.Mul64(x42, 0x5812631a5cf5d3ed) + var x50 uint64 + var x51 uint64 + x50, x51 = bits.Add64(x49, x46, uint64(0x0)) + var x53 uint64 + _, x53 = bits.Add64(x36, x48, uint64(0x0)) + var x54 uint64 + var x55 uint64 + x54, x55 = bits.Add64(x38, x50, uint64(fiatScalarUint1(x53))) + var x56 uint64 + var x57 uint64 + x56, x57 = bits.Add64(x40, (uint64(fiatScalarUint1(x51)) + x47), uint64(fiatScalarUint1(x55))) + var x58 uint64 + var x59 uint64 + x58, x59 = bits.Add64((uint64(fiatScalarUint1(x41)) + (uint64(fiatScalarUint1(x35)) + x21)), x44, uint64(fiatScalarUint1(x57))) + var x60 uint64 + var x61 uint64 + x60, x61 = bits.Add64(x54, arg1[3], uint64(0x0)) + var x62 uint64 + var x63 uint64 + x62, x63 = bits.Add64(x56, uint64(0x0), uint64(fiatScalarUint1(x61))) + var x64 uint64 + var x65 uint64 + x64, x65 = bits.Add64(x58, uint64(0x0), uint64(fiatScalarUint1(x63))) + var x66 uint64 + _, x66 = bits.Mul64(x60, 0xd2b51da312547e1b) + var x68 uint64 + var x69 uint64 + x69, x68 = bits.Mul64(x66, 0x1000000000000000) + var x70 uint64 + var x71 uint64 + x71, x70 = bits.Mul64(x66, 0x14def9dea2f79cd6) + var x72 uint64 + var x73 uint64 + x73, x72 = bits.Mul64(x66, 0x5812631a5cf5d3ed) + var x74 uint64 + var x75 uint64 + x74, x75 = bits.Add64(x73, x70, uint64(0x0)) + var x77 uint64 + _, x77 = bits.Add64(x60, x72, uint64(0x0)) + var x78 uint64 + var x79 uint64 + x78, x79 = bits.Add64(x62, x74, uint64(fiatScalarUint1(x77))) + var x80 uint64 + var x81 uint64 + x80, x81 = bits.Add64(x64, (uint64(fiatScalarUint1(x75)) + x71), uint64(fiatScalarUint1(x79))) + var x82 uint64 + var x83 uint64 + x82, x83 = bits.Add64((uint64(fiatScalarUint1(x65)) + (uint64(fiatScalarUint1(x59)) + x45)), x68, uint64(fiatScalarUint1(x81))) + x84 := (uint64(fiatScalarUint1(x83)) + x69) + var x85 uint64 + var x86 uint64 + x85, x86 = bits.Sub64(x78, 0x5812631a5cf5d3ed, uint64(0x0)) + var x87 uint64 + var x88 uint64 + x87, x88 = bits.Sub64(x80, 0x14def9dea2f79cd6, uint64(fiatScalarUint1(x86))) + var x89 uint64 + var x90 uint64 + x89, x90 = bits.Sub64(x82, uint64(0x0), uint64(fiatScalarUint1(x88))) + var x91 uint64 + var x92 uint64 + x91, x92 = bits.Sub64(x84, 0x1000000000000000, uint64(fiatScalarUint1(x90))) + var x94 uint64 + _, x94 = bits.Sub64(uint64(0x0), uint64(0x0), uint64(fiatScalarUint1(x92))) + var x95 uint64 + fiatScalarCmovznzU64(&x95, fiatScalarUint1(x94), x85, x78) + var x96 uint64 + fiatScalarCmovznzU64(&x96, fiatScalarUint1(x94), x87, x80) + var x97 uint64 + fiatScalarCmovznzU64(&x97, fiatScalarUint1(x94), x89, x82) + var x98 uint64 + fiatScalarCmovznzU64(&x98, fiatScalarUint1(x94), x91, x84) + out1[0] = x95 + out1[1] = x96 + out1[2] = x97 + out1[3] = x98 +} + +// fiatScalarToMontgomery translates a field element into the Montgomery domain. +// +// Preconditions: +// +// 0 ≤ eval arg1 < m +// +// Postconditions: +// +// eval (from_montgomery out1) mod m = eval arg1 mod m +// 0 ≤ eval out1 < m +func fiatScalarToMontgomery(out1 *fiatScalarMontgomeryDomainFieldElement, arg1 *fiatScalarNonMontgomeryDomainFieldElement) { + x1 := arg1[1] + x2 := arg1[2] + x3 := arg1[3] + x4 := arg1[0] + var x5 uint64 + var x6 uint64 + x6, x5 = bits.Mul64(x4, 0x399411b7c309a3d) + var x7 uint64 + var x8 uint64 + x8, x7 = bits.Mul64(x4, 0xceec73d217f5be65) + var x9 uint64 + var x10 uint64 + x10, x9 = bits.Mul64(x4, 0xd00e1ba768859347) + var x11 uint64 + var x12 uint64 + x12, x11 = bits.Mul64(x4, 0xa40611e3449c0f01) + var x13 uint64 + var x14 uint64 + x13, x14 = bits.Add64(x12, x9, uint64(0x0)) + var x15 uint64 + var x16 uint64 + x15, x16 = bits.Add64(x10, x7, uint64(fiatScalarUint1(x14))) + var x17 uint64 + var x18 uint64 + x17, x18 = bits.Add64(x8, x5, uint64(fiatScalarUint1(x16))) + var x19 uint64 + _, x19 = bits.Mul64(x11, 0xd2b51da312547e1b) + var x21 uint64 + var x22 uint64 + x22, x21 = bits.Mul64(x19, 0x1000000000000000) + var x23 uint64 + var x24 uint64 + x24, x23 = bits.Mul64(x19, 0x14def9dea2f79cd6) + var x25 uint64 + var x26 uint64 + x26, x25 = bits.Mul64(x19, 0x5812631a5cf5d3ed) + var x27 uint64 + var x28 uint64 + x27, x28 = bits.Add64(x26, x23, uint64(0x0)) + var x30 uint64 + _, x30 = bits.Add64(x11, x25, uint64(0x0)) + var x31 uint64 + var x32 uint64 + x31, x32 = bits.Add64(x13, x27, uint64(fiatScalarUint1(x30))) + var x33 uint64 + var x34 uint64 + x33, x34 = bits.Add64(x15, (uint64(fiatScalarUint1(x28)) + x24), uint64(fiatScalarUint1(x32))) + var x35 uint64 + var x36 uint64 + x35, x36 = bits.Add64(x17, x21, uint64(fiatScalarUint1(x34))) + var x37 uint64 + var x38 uint64 + x38, x37 = bits.Mul64(x1, 0x399411b7c309a3d) + var x39 uint64 + var x40 uint64 + x40, x39 = bits.Mul64(x1, 0xceec73d217f5be65) + var x41 uint64 + var x42 uint64 + x42, x41 = bits.Mul64(x1, 0xd00e1ba768859347) + var x43 uint64 + var x44 uint64 + x44, x43 = bits.Mul64(x1, 0xa40611e3449c0f01) + var x45 uint64 + var x46 uint64 + x45, x46 = bits.Add64(x44, x41, uint64(0x0)) + var x47 uint64 + var x48 uint64 + x47, x48 = bits.Add64(x42, x39, uint64(fiatScalarUint1(x46))) + var x49 uint64 + var x50 uint64 + x49, x50 = bits.Add64(x40, x37, uint64(fiatScalarUint1(x48))) + var x51 uint64 + var x52 uint64 + x51, x52 = bits.Add64(x31, x43, uint64(0x0)) + var x53 uint64 + var x54 uint64 + x53, x54 = bits.Add64(x33, x45, uint64(fiatScalarUint1(x52))) + var x55 uint64 + var x56 uint64 + x55, x56 = bits.Add64(x35, x47, uint64(fiatScalarUint1(x54))) + var x57 uint64 + var x58 uint64 + x57, x58 = bits.Add64(((uint64(fiatScalarUint1(x36)) + (uint64(fiatScalarUint1(x18)) + x6)) + x22), x49, uint64(fiatScalarUint1(x56))) + var x59 uint64 + _, x59 = bits.Mul64(x51, 0xd2b51da312547e1b) + var x61 uint64 + var x62 uint64 + x62, x61 = bits.Mul64(x59, 0x1000000000000000) + var x63 uint64 + var x64 uint64 + x64, x63 = bits.Mul64(x59, 0x14def9dea2f79cd6) + var x65 uint64 + var x66 uint64 + x66, x65 = bits.Mul64(x59, 0x5812631a5cf5d3ed) + var x67 uint64 + var x68 uint64 + x67, x68 = bits.Add64(x66, x63, uint64(0x0)) + var x70 uint64 + _, x70 = bits.Add64(x51, x65, uint64(0x0)) + var x71 uint64 + var x72 uint64 + x71, x72 = bits.Add64(x53, x67, uint64(fiatScalarUint1(x70))) + var x73 uint64 + var x74 uint64 + x73, x74 = bits.Add64(x55, (uint64(fiatScalarUint1(x68)) + x64), uint64(fiatScalarUint1(x72))) + var x75 uint64 + var x76 uint64 + x75, x76 = bits.Add64(x57, x61, uint64(fiatScalarUint1(x74))) + var x77 uint64 + var x78 uint64 + x78, x77 = bits.Mul64(x2, 0x399411b7c309a3d) + var x79 uint64 + var x80 uint64 + x80, x79 = bits.Mul64(x2, 0xceec73d217f5be65) + var x81 uint64 + var x82 uint64 + x82, x81 = bits.Mul64(x2, 0xd00e1ba768859347) + var x83 uint64 + var x84 uint64 + x84, x83 = bits.Mul64(x2, 0xa40611e3449c0f01) + var x85 uint64 + var x86 uint64 + x85, x86 = bits.Add64(x84, x81, uint64(0x0)) + var x87 uint64 + var x88 uint64 + x87, x88 = bits.Add64(x82, x79, uint64(fiatScalarUint1(x86))) + var x89 uint64 + var x90 uint64 + x89, x90 = bits.Add64(x80, x77, uint64(fiatScalarUint1(x88))) + var x91 uint64 + var x92 uint64 + x91, x92 = bits.Add64(x71, x83, uint64(0x0)) + var x93 uint64 + var x94 uint64 + x93, x94 = bits.Add64(x73, x85, uint64(fiatScalarUint1(x92))) + var x95 uint64 + var x96 uint64 + x95, x96 = bits.Add64(x75, x87, uint64(fiatScalarUint1(x94))) + var x97 uint64 + var x98 uint64 + x97, x98 = bits.Add64(((uint64(fiatScalarUint1(x76)) + (uint64(fiatScalarUint1(x58)) + (uint64(fiatScalarUint1(x50)) + x38))) + x62), x89, uint64(fiatScalarUint1(x96))) + var x99 uint64 + _, x99 = bits.Mul64(x91, 0xd2b51da312547e1b) + var x101 uint64 + var x102 uint64 + x102, x101 = bits.Mul64(x99, 0x1000000000000000) + var x103 uint64 + var x104 uint64 + x104, x103 = bits.Mul64(x99, 0x14def9dea2f79cd6) + var x105 uint64 + var x106 uint64 + x106, x105 = bits.Mul64(x99, 0x5812631a5cf5d3ed) + var x107 uint64 + var x108 uint64 + x107, x108 = bits.Add64(x106, x103, uint64(0x0)) + var x110 uint64 + _, x110 = bits.Add64(x91, x105, uint64(0x0)) + var x111 uint64 + var x112 uint64 + x111, x112 = bits.Add64(x93, x107, uint64(fiatScalarUint1(x110))) + var x113 uint64 + var x114 uint64 + x113, x114 = bits.Add64(x95, (uint64(fiatScalarUint1(x108)) + x104), uint64(fiatScalarUint1(x112))) + var x115 uint64 + var x116 uint64 + x115, x116 = bits.Add64(x97, x101, uint64(fiatScalarUint1(x114))) + var x117 uint64 + var x118 uint64 + x118, x117 = bits.Mul64(x3, 0x399411b7c309a3d) + var x119 uint64 + var x120 uint64 + x120, x119 = bits.Mul64(x3, 0xceec73d217f5be65) + var x121 uint64 + var x122 uint64 + x122, x121 = bits.Mul64(x3, 0xd00e1ba768859347) + var x123 uint64 + var x124 uint64 + x124, x123 = bits.Mul64(x3, 0xa40611e3449c0f01) + var x125 uint64 + var x126 uint64 + x125, x126 = bits.Add64(x124, x121, uint64(0x0)) + var x127 uint64 + var x128 uint64 + x127, x128 = bits.Add64(x122, x119, uint64(fiatScalarUint1(x126))) + var x129 uint64 + var x130 uint64 + x129, x130 = bits.Add64(x120, x117, uint64(fiatScalarUint1(x128))) + var x131 uint64 + var x132 uint64 + x131, x132 = bits.Add64(x111, x123, uint64(0x0)) + var x133 uint64 + var x134 uint64 + x133, x134 = bits.Add64(x113, x125, uint64(fiatScalarUint1(x132))) + var x135 uint64 + var x136 uint64 + x135, x136 = bits.Add64(x115, x127, uint64(fiatScalarUint1(x134))) + var x137 uint64 + var x138 uint64 + x137, x138 = bits.Add64(((uint64(fiatScalarUint1(x116)) + (uint64(fiatScalarUint1(x98)) + (uint64(fiatScalarUint1(x90)) + x78))) + x102), x129, uint64(fiatScalarUint1(x136))) + var x139 uint64 + _, x139 = bits.Mul64(x131, 0xd2b51da312547e1b) + var x141 uint64 + var x142 uint64 + x142, x141 = bits.Mul64(x139, 0x1000000000000000) + var x143 uint64 + var x144 uint64 + x144, x143 = bits.Mul64(x139, 0x14def9dea2f79cd6) + var x145 uint64 + var x146 uint64 + x146, x145 = bits.Mul64(x139, 0x5812631a5cf5d3ed) + var x147 uint64 + var x148 uint64 + x147, x148 = bits.Add64(x146, x143, uint64(0x0)) + var x150 uint64 + _, x150 = bits.Add64(x131, x145, uint64(0x0)) + var x151 uint64 + var x152 uint64 + x151, x152 = bits.Add64(x133, x147, uint64(fiatScalarUint1(x150))) + var x153 uint64 + var x154 uint64 + x153, x154 = bits.Add64(x135, (uint64(fiatScalarUint1(x148)) + x144), uint64(fiatScalarUint1(x152))) + var x155 uint64 + var x156 uint64 + x155, x156 = bits.Add64(x137, x141, uint64(fiatScalarUint1(x154))) + x157 := ((uint64(fiatScalarUint1(x156)) + (uint64(fiatScalarUint1(x138)) + (uint64(fiatScalarUint1(x130)) + x118))) + x142) + var x158 uint64 + var x159 uint64 + x158, x159 = bits.Sub64(x151, 0x5812631a5cf5d3ed, uint64(0x0)) + var x160 uint64 + var x161 uint64 + x160, x161 = bits.Sub64(x153, 0x14def9dea2f79cd6, uint64(fiatScalarUint1(x159))) + var x162 uint64 + var x163 uint64 + x162, x163 = bits.Sub64(x155, uint64(0x0), uint64(fiatScalarUint1(x161))) + var x164 uint64 + var x165 uint64 + x164, x165 = bits.Sub64(x157, 0x1000000000000000, uint64(fiatScalarUint1(x163))) + var x167 uint64 + _, x167 = bits.Sub64(uint64(0x0), uint64(0x0), uint64(fiatScalarUint1(x165))) + var x168 uint64 + fiatScalarCmovznzU64(&x168, fiatScalarUint1(x167), x158, x151) + var x169 uint64 + fiatScalarCmovznzU64(&x169, fiatScalarUint1(x167), x160, x153) + var x170 uint64 + fiatScalarCmovznzU64(&x170, fiatScalarUint1(x167), x162, x155) + var x171 uint64 + fiatScalarCmovznzU64(&x171, fiatScalarUint1(x167), x164, x157) + out1[0] = x168 + out1[1] = x169 + out1[2] = x170 + out1[3] = x171 +} + +// fiatScalarToBytes serializes a field element NOT in the Montgomery domain to bytes in little-endian order. +// +// Preconditions: +// +// 0 ≤ eval arg1 < m +// +// Postconditions: +// +// out1 = map (λ x, ⌊((eval arg1 mod m) mod 2^(8 * (x + 1))) / 2^(8 * x)⌋) [0..31] +// +// Input Bounds: +// +// arg1: [[0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0x1fffffffffffffff]] +// +// Output Bounds: +// +// out1: [[0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0x1f]] +func fiatScalarToBytes(out1 *[32]uint8, arg1 *[4]uint64) { + x1 := arg1[3] + x2 := arg1[2] + x3 := arg1[1] + x4 := arg1[0] + x5 := (uint8(x4) & 0xff) + x6 := (x4 >> 8) + x7 := (uint8(x6) & 0xff) + x8 := (x6 >> 8) + x9 := (uint8(x8) & 0xff) + x10 := (x8 >> 8) + x11 := (uint8(x10) & 0xff) + x12 := (x10 >> 8) + x13 := (uint8(x12) & 0xff) + x14 := (x12 >> 8) + x15 := (uint8(x14) & 0xff) + x16 := (x14 >> 8) + x17 := (uint8(x16) & 0xff) + x18 := uint8((x16 >> 8)) + x19 := (uint8(x3) & 0xff) + x20 := (x3 >> 8) + x21 := (uint8(x20) & 0xff) + x22 := (x20 >> 8) + x23 := (uint8(x22) & 0xff) + x24 := (x22 >> 8) + x25 := (uint8(x24) & 0xff) + x26 := (x24 >> 8) + x27 := (uint8(x26) & 0xff) + x28 := (x26 >> 8) + x29 := (uint8(x28) & 0xff) + x30 := (x28 >> 8) + x31 := (uint8(x30) & 0xff) + x32 := uint8((x30 >> 8)) + x33 := (uint8(x2) & 0xff) + x34 := (x2 >> 8) + x35 := (uint8(x34) & 0xff) + x36 := (x34 >> 8) + x37 := (uint8(x36) & 0xff) + x38 := (x36 >> 8) + x39 := (uint8(x38) & 0xff) + x40 := (x38 >> 8) + x41 := (uint8(x40) & 0xff) + x42 := (x40 >> 8) + x43 := (uint8(x42) & 0xff) + x44 := (x42 >> 8) + x45 := (uint8(x44) & 0xff) + x46 := uint8((x44 >> 8)) + x47 := (uint8(x1) & 0xff) + x48 := (x1 >> 8) + x49 := (uint8(x48) & 0xff) + x50 := (x48 >> 8) + x51 := (uint8(x50) & 0xff) + x52 := (x50 >> 8) + x53 := (uint8(x52) & 0xff) + x54 := (x52 >> 8) + x55 := (uint8(x54) & 0xff) + x56 := (x54 >> 8) + x57 := (uint8(x56) & 0xff) + x58 := (x56 >> 8) + x59 := (uint8(x58) & 0xff) + x60 := uint8((x58 >> 8)) + out1[0] = x5 + out1[1] = x7 + out1[2] = x9 + out1[3] = x11 + out1[4] = x13 + out1[5] = x15 + out1[6] = x17 + out1[7] = x18 + out1[8] = x19 + out1[9] = x21 + out1[10] = x23 + out1[11] = x25 + out1[12] = x27 + out1[13] = x29 + out1[14] = x31 + out1[15] = x32 + out1[16] = x33 + out1[17] = x35 + out1[18] = x37 + out1[19] = x39 + out1[20] = x41 + out1[21] = x43 + out1[22] = x45 + out1[23] = x46 + out1[24] = x47 + out1[25] = x49 + out1[26] = x51 + out1[27] = x53 + out1[28] = x55 + out1[29] = x57 + out1[30] = x59 + out1[31] = x60 +} + +// fiatScalarFromBytes deserializes a field element NOT in the Montgomery domain from bytes in little-endian order. +// +// Preconditions: +// +// 0 ≤ bytes_eval arg1 < m +// +// Postconditions: +// +// eval out1 mod m = bytes_eval arg1 mod m +// 0 ≤ eval out1 < m +// +// Input Bounds: +// +// arg1: [[0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0xff], [0x0 ~> 0x1f]] +// +// Output Bounds: +// +// out1: [[0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0xffffffffffffffff], [0x0 ~> 0x1fffffffffffffff]] +func fiatScalarFromBytes(out1 *[4]uint64, arg1 *[32]uint8) { + x1 := (uint64(arg1[31]) << 56) + x2 := (uint64(arg1[30]) << 48) + x3 := (uint64(arg1[29]) << 40) + x4 := (uint64(arg1[28]) << 32) + x5 := (uint64(arg1[27]) << 24) + x6 := (uint64(arg1[26]) << 16) + x7 := (uint64(arg1[25]) << 8) + x8 := arg1[24] + x9 := (uint64(arg1[23]) << 56) + x10 := (uint64(arg1[22]) << 48) + x11 := (uint64(arg1[21]) << 40) + x12 := (uint64(arg1[20]) << 32) + x13 := (uint64(arg1[19]) << 24) + x14 := (uint64(arg1[18]) << 16) + x15 := (uint64(arg1[17]) << 8) + x16 := arg1[16] + x17 := (uint64(arg1[15]) << 56) + x18 := (uint64(arg1[14]) << 48) + x19 := (uint64(arg1[13]) << 40) + x20 := (uint64(arg1[12]) << 32) + x21 := (uint64(arg1[11]) << 24) + x22 := (uint64(arg1[10]) << 16) + x23 := (uint64(arg1[9]) << 8) + x24 := arg1[8] + x25 := (uint64(arg1[7]) << 56) + x26 := (uint64(arg1[6]) << 48) + x27 := (uint64(arg1[5]) << 40) + x28 := (uint64(arg1[4]) << 32) + x29 := (uint64(arg1[3]) << 24) + x30 := (uint64(arg1[2]) << 16) + x31 := (uint64(arg1[1]) << 8) + x32 := arg1[0] + x33 := (x31 + uint64(x32)) + x34 := (x30 + x33) + x35 := (x29 + x34) + x36 := (x28 + x35) + x37 := (x27 + x36) + x38 := (x26 + x37) + x39 := (x25 + x38) + x40 := (x23 + uint64(x24)) + x41 := (x22 + x40) + x42 := (x21 + x41) + x43 := (x20 + x42) + x44 := (x19 + x43) + x45 := (x18 + x44) + x46 := (x17 + x45) + x47 := (x15 + uint64(x16)) + x48 := (x14 + x47) + x49 := (x13 + x48) + x50 := (x12 + x49) + x51 := (x11 + x50) + x52 := (x10 + x51) + x53 := (x9 + x52) + x54 := (x7 + uint64(x8)) + x55 := (x6 + x54) + x56 := (x5 + x55) + x57 := (x4 + x56) + x58 := (x3 + x57) + x59 := (x2 + x58) + x60 := (x1 + x59) + out1[0] = x39 + out1[1] = x46 + out1[2] = x53 + out1[3] = x60 +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_test.go new file mode 100644 index 0000000..76e920a --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalar_test.go @@ -0,0 +1,268 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import ( + "bytes" + "encoding/hex" + "math/big" + mathrand "math/rand" + "reflect" + "testing" + "testing/quick" +) + +// quickCheckConfig returns a quick.Config that scales the max count by the +// given factor if the -short flag is not set. +func quickCheckConfig(slowScale int) *quick.Config { + cfg := new(quick.Config) + if !testing.Short() { + cfg.MaxCountScale = float64(slowScale) + } + return cfg +} + +var scOneBytes = [32]byte{1} +var scOne, _ = new(Scalar).SetCanonicalBytes(scOneBytes[:]) +var scMinusOne = new(Scalar).Subtract(new(Scalar), scOne) + +// Generate returns a valid (reduced modulo l) Scalar with a distribution +// weighted towards high, low, and edge values. +func (Scalar) Generate(rand *mathrand.Rand, size int) reflect.Value { + var s [32]byte + diceRoll := rand.Intn(100) + switch { + case diceRoll == 0: + case diceRoll == 1: + s = scOneBytes + case diceRoll == 2: + s = [32]byte(scMinusOne.Bytes()) + case diceRoll < 5: + // Generate a low scalar in [0, 2^125). + rand.Read(s[:16]) + s[15] &= (1 << 5) - 1 + case diceRoll < 10: + // Generate a high scalar in [2^252, 2^252 + 2^124). + s[31] = 1 << 4 + rand.Read(s[:16]) + s[15] &= (1 << 4) - 1 + default: + // Generate a valid scalar in [0, l) by returning [0, 2^252) which has a + // negligibly different distribution (the former has a 2^-127.6 chance + // of being out of the latter range). + rand.Read(s[:]) + s[31] &= (1 << 4) - 1 + } + + val := Scalar{} + fiatScalarFromBytes((*[4]uint64)(&val.s), &s) + fiatScalarToMontgomery(&val.s, (*fiatScalarNonMontgomeryDomainFieldElement)(&val.s)) + + return reflect.ValueOf(val) +} + +func TestScalarGenerate(t *testing.T) { + f := func(sc Scalar) bool { + return isReduced(sc.Bytes()) + } + if err := quick.Check(f, quickCheckConfig(1024)); err != nil { + t.Errorf("generated unreduced scalar: %v", err) + } +} + +func TestScalarSetCanonicalBytes(t *testing.T) { + f1 := func(in [32]byte, sc Scalar) bool { + // Mask out top 4 bits to guarantee value falls in [0, l). + in[len(in)-1] &= (1 << 4) - 1 + if _, err := sc.SetCanonicalBytes(in[:]); err != nil { + return false + } + repr := sc.Bytes() + return bytes.Equal(in[:], repr) && isReduced(repr) + } + if err := quick.Check(f1, quickCheckConfig(1024)); err != nil { + t.Errorf("failed bytes->scalar->bytes round-trip: %v", err) + } + + f2 := func(sc1, sc2 Scalar) bool { + if _, err := sc2.SetCanonicalBytes(sc1.Bytes()); err != nil { + return false + } + return sc1 == sc2 + } + if err := quick.Check(f2, quickCheckConfig(1024)); err != nil { + t.Errorf("failed scalar->bytes->scalar round-trip: %v", err) + } + + expectReject := func(b []byte) { + t.Helper() + s := scOne + if out, err := s.SetCanonicalBytes(b[:]); err == nil { + t.Errorf("SetCanonicalBytes worked on a non-canonical value") + } else if s != scOne { + t.Errorf("SetCanonicalBytes modified its receiver") + } else if out != nil { + t.Errorf("SetCanonicalBytes did not return nil with an error") + } + } + + b := scMinusOne.Bytes() + b[0] += 1 + expectReject(b) + + b = scMinusOne.Bytes() + b[31] += 1 + expectReject(b) + + b = scMinusOne.Bytes() + b[31] |= 0b1000_0000 + expectReject(b) +} + +func TestScalarSetUniformBytes(t *testing.T) { + mod, _ := new(big.Int).SetString("27742317777372353535851937790883648493", 10) + mod.Add(mod, new(big.Int).Lsh(big.NewInt(1), 252)) + f := func(in [64]byte, sc Scalar) bool { + sc.SetUniformBytes(in[:]) + repr := sc.Bytes() + if !isReduced(repr) { + return false + } + scBig := bigIntFromLittleEndianBytes(repr[:]) + inBig := bigIntFromLittleEndianBytes(in[:]) + return inBig.Mod(inBig, mod).Cmp(scBig) == 0 + } + if err := quick.Check(f, quickCheckConfig(1024)); err != nil { + t.Error(err) + } +} + +func TestScalarSetBytesWithClamping(t *testing.T) { + // Generated with libsodium.js 1.0.18 crypto_scalarmult_ed25519_base. + + random := "633d368491364dc9cd4c1bf891b1d59460face1644813240a313e61f2c88216e" + s, _ := new(Scalar).SetBytesWithClamping(decodeHex(random)) + p := new(Point).ScalarBaseMult(s) + want := "1d87a9026fd0126a5736fe1628c95dd419172b5b618457e041c9c861b2494a94" + if got := hex.EncodeToString(p.Bytes()); got != want { + t.Errorf("random: got %q, want %q", got, want) + } + + zero := "0000000000000000000000000000000000000000000000000000000000000000" + s, _ = new(Scalar).SetBytesWithClamping(decodeHex(zero)) + p = new(Point).ScalarBaseMult(s) + want = "693e47972caf527c7883ad1b39822f026f47db2ab0e1919955b8993aa04411d1" + if got := hex.EncodeToString(p.Bytes()); got != want { + t.Errorf("zero: got %q, want %q", got, want) + } + + one := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + s, _ = new(Scalar).SetBytesWithClamping(decodeHex(one)) + p = new(Point).ScalarBaseMult(s) + want = "12e9a68b73fd5aacdbcaf3e88c46fea6ebedb1aa84eed1842f07f8edab65e3a7" + if got := hex.EncodeToString(p.Bytes()); got != want { + t.Errorf("one: got %q, want %q", got, want) + } +} + +func bigIntFromLittleEndianBytes(b []byte) *big.Int { + bb := make([]byte, len(b)) + for i := range b { + bb[i] = b[len(b)-i-1] + } + return new(big.Int).SetBytes(bb) +} + +func TestScalarMultiplyDistributesOverAdd(t *testing.T) { + multiplyDistributesOverAdd := func(x, y, z Scalar) bool { + // Compute t1 = (x+y)*z + var t1 Scalar + t1.Add(&x, &y) + t1.Multiply(&t1, &z) + + // Compute t2 = x*z + y*z + var t2 Scalar + var t3 Scalar + t2.Multiply(&x, &z) + t3.Multiply(&y, &z) + t2.Add(&t2, &t3) + + reprT1, reprT2 := t1.Bytes(), t2.Bytes() + + return t1 == t2 && isReduced(reprT1) && isReduced(reprT2) + } + + if err := quick.Check(multiplyDistributesOverAdd, quickCheckConfig(1024)); err != nil { + t.Error(err) + } +} + +func TestScalarAddLikeSubNeg(t *testing.T) { + addLikeSubNeg := func(x, y Scalar) bool { + // Compute t1 = x - y + var t1 Scalar + t1.Subtract(&x, &y) + + // Compute t2 = -y + x + var t2 Scalar + t2.Negate(&y) + t2.Add(&t2, &x) + + return t1 == t2 && isReduced(t1.Bytes()) + } + + if err := quick.Check(addLikeSubNeg, quickCheckConfig(1024)); err != nil { + t.Error(err) + } +} + +func TestScalarNonAdjacentForm(t *testing.T) { + s, _ := (&Scalar{}).SetCanonicalBytes([]byte{ + 0x1a, 0x0e, 0x97, 0x8a, 0x90, 0xf6, 0x62, 0x2d, + 0x37, 0x47, 0x02, 0x3f, 0x8a, 0xd8, 0x26, 0x4d, + 0xa7, 0x58, 0xaa, 0x1b, 0x88, 0xe0, 0x40, 0xd1, + 0x58, 0x9e, 0x7b, 0x7f, 0x23, 0x76, 0xef, 0x09, + }) + + expectedNaf := [256]int8{ + 0, 13, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, -9, 0, 0, 0, 0, -11, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1, + 0, 0, 0, 0, 9, 0, 0, 0, 0, -5, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 11, 0, 0, 0, 0, 11, 0, 0, 0, 0, 0, + -9, 0, 0, 0, 0, 0, -3, 0, 0, 0, 0, 9, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 9, 0, + 0, 0, 0, -15, 0, 0, 0, 0, -7, 0, 0, 0, 0, -9, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 13, 0, 0, 0, 0, 0, -3, 0, + 0, 0, 0, -11, 0, 0, 0, 0, -7, 0, 0, 0, 0, -13, 0, 0, 0, 0, 11, 0, 0, 0, 0, -9, 0, 0, 0, 0, 0, 1, 0, 0, + 0, 0, 0, -15, 0, 0, 0, 0, 1, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 13, 0, 0, 0, + 0, 0, 0, 11, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, -9, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0, 7, + 0, 0, 0, 0, 0, -15, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 15, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, + } + + sNaf := s.nonAdjacentForm(5) + + for i := 0; i < 256; i++ { + if expectedNaf[i] != sNaf[i] { + t.Errorf("Wrong digit at position %d, got %d, expected %d", i, sNaf[i], expectedNaf[i]) + } + } +} + +type notZeroScalar Scalar + +func (notZeroScalar) Generate(rand *mathrand.Rand, size int) reflect.Value { + var s Scalar + var isNonZero uint64 + for isNonZero == 0 { + s = Scalar{}.Generate(rand, size).Interface().(Scalar) + fiatScalarNonzero(&isNonZero, (*[4]uint64)(&s.s)) + } + return reflect.ValueOf(notZeroScalar(s)) +} + +func TestScalarEqual(t *testing.T) { + if scOne.Equal(scMinusOne) == 1 { + t.Errorf("scOne.Equal(&scMinusOne) is true") + } + if scMinusOne.Equal(scMinusOne) == 0 { + t.Errorf("scMinusOne.Equal(&scMinusOne) is false") + } +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalarmult.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalarmult.go new file mode 100644 index 0000000..f7ca3ce --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalarmult.go @@ -0,0 +1,214 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import "sync" + +// basepointTable is a set of 32 affineLookupTables, where table i is generated +// from 256i * basepoint. It is precomputed the first time it's used. +func basepointTable() *[32]affineLookupTable { + basepointTablePrecomp.initOnce.Do(func() { + p := NewGeneratorPoint() + for i := 0; i < 32; i++ { + basepointTablePrecomp.table[i].FromP3(p) + for j := 0; j < 8; j++ { + p.Add(p, p) + } + } + }) + return &basepointTablePrecomp.table +} + +var basepointTablePrecomp struct { + table [32]affineLookupTable + initOnce sync.Once +} + +// ScalarBaseMult sets v = x * B, where B is the canonical generator, and +// returns v. +// +// The scalar multiplication is done in constant time. +func (v *Point) ScalarBaseMult(x *Scalar) *Point { + basepointTable := basepointTable() + + // Write x = sum(x_i * 16^i) so x*B = sum( B*x_i*16^i ) + // as described in the Ed25519 paper + // + // Group even and odd coefficients + // x*B = x_0*16^0*B + x_2*16^2*B + ... + x_62*16^62*B + // + x_1*16^1*B + x_3*16^3*B + ... + x_63*16^63*B + // x*B = x_0*16^0*B + x_2*16^2*B + ... + x_62*16^62*B + // + 16*( x_1*16^0*B + x_3*16^2*B + ... + x_63*16^62*B) + // + // We use a lookup table for each i to get x_i*16^(2*i)*B + // and do four doublings to multiply by 16. + digits := x.signedRadix16() + + multiple := &affineCached{} + tmp1 := &projP1xP1{} + tmp2 := &projP2{} + + // Accumulate the odd components first + v.Set(NewIdentityPoint()) + for i := 1; i < 64; i += 2 { + basepointTable[i/2].SelectInto(multiple, digits[i]) + tmp1.AddAffine(v, multiple) + v.fromP1xP1(tmp1) + } + + // Multiply by 16 + tmp2.FromP3(v) // tmp2 = v in P2 coords + tmp1.Double(tmp2) // tmp1 = 2*v in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 2*v in P2 coords + tmp1.Double(tmp2) // tmp1 = 4*v in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 4*v in P2 coords + tmp1.Double(tmp2) // tmp1 = 8*v in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 8*v in P2 coords + tmp1.Double(tmp2) // tmp1 = 16*v in P1xP1 coords + v.fromP1xP1(tmp1) // now v = 16*(odd components) + + // Accumulate the even components + for i := 0; i < 64; i += 2 { + basepointTable[i/2].SelectInto(multiple, digits[i]) + tmp1.AddAffine(v, multiple) + v.fromP1xP1(tmp1) + } + + return v +} + +// ScalarMult sets v = x * q, and returns v. +// +// The scalar multiplication is done in constant time. +func (v *Point) ScalarMult(x *Scalar, q *Point) *Point { + checkInitialized(q) + + var table projLookupTable + table.FromP3(q) + + // Write x = sum(x_i * 16^i) + // so x*Q = sum( Q*x_i*16^i ) + // = Q*x_0 + 16*(Q*x_1 + 16*( ... + Q*x_63) ... ) + // <------compute inside out--------- + // + // We use the lookup table to get the x_i*Q values + // and do four doublings to compute 16*Q + digits := x.signedRadix16() + + // Unwrap first loop iteration to save computing 16*identity + multiple := &projCached{} + tmp1 := &projP1xP1{} + tmp2 := &projP2{} + table.SelectInto(multiple, digits[63]) + + v.Set(NewIdentityPoint()) + tmp1.Add(v, multiple) // tmp1 = x_63*Q in P1xP1 coords + for i := 62; i >= 0; i-- { + tmp2.FromP1xP1(tmp1) // tmp2 = (prev) in P2 coords + tmp1.Double(tmp2) // tmp1 = 2*(prev) in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 2*(prev) in P2 coords + tmp1.Double(tmp2) // tmp1 = 4*(prev) in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 4*(prev) in P2 coords + tmp1.Double(tmp2) // tmp1 = 8*(prev) in P1xP1 coords + tmp2.FromP1xP1(tmp1) // tmp2 = 8*(prev) in P2 coords + tmp1.Double(tmp2) // tmp1 = 16*(prev) in P1xP1 coords + v.fromP1xP1(tmp1) // v = 16*(prev) in P3 coords + table.SelectInto(multiple, digits[i]) + tmp1.Add(v, multiple) // tmp1 = x_i*Q + 16*(prev) in P1xP1 coords + } + v.fromP1xP1(tmp1) + return v +} + +// basepointNafTable is the nafLookupTable8 for the basepoint. +// It is precomputed the first time it's used. +func basepointNafTable() *nafLookupTable8 { + basepointNafTablePrecomp.initOnce.Do(func() { + basepointNafTablePrecomp.table.FromP3(NewGeneratorPoint()) + }) + return &basepointNafTablePrecomp.table +} + +var basepointNafTablePrecomp struct { + table nafLookupTable8 + initOnce sync.Once +} + +// VarTimeDoubleScalarBaseMult sets v = a * A + b * B, where B is the canonical +// generator, and returns v. +// +// Execution time depends on the inputs. +func (v *Point) VarTimeDoubleScalarBaseMult(a *Scalar, A *Point, b *Scalar) *Point { + checkInitialized(A) + + // Similarly to the single variable-base approach, we compute + // digits and use them with a lookup table. However, because + // we are allowed to do variable-time operations, we don't + // need constant-time lookups or constant-time digit + // computations. + // + // So we use a non-adjacent form of some width w instead of + // radix 16. This is like a binary representation (one digit + // for each binary place) but we allow the digits to grow in + // magnitude up to 2^{w-1} so that the nonzero digits are as + // sparse as possible. Intuitively, this "condenses" the + // "mass" of the scalar onto sparse coefficients (meaning + // fewer additions). + + basepointNafTable := basepointNafTable() + var aTable nafLookupTable5 + aTable.FromP3(A) + // Because the basepoint is fixed, we can use a wider NAF + // corresponding to a bigger table. + aNaf := a.nonAdjacentForm(5) + bNaf := b.nonAdjacentForm(8) + + // Find the first nonzero coefficient. + i := 255 + for j := i; j >= 0; j-- { + if aNaf[j] != 0 || bNaf[j] != 0 { + break + } + } + + multA := &projCached{} + multB := &affineCached{} + tmp1 := &projP1xP1{} + tmp2 := &projP2{} + tmp2.Zero() + + // Move from high to low bits, doubling the accumulator + // at each iteration and checking whether there is a nonzero + // coefficient to look up a multiple of. + for ; i >= 0; i-- { + tmp1.Double(tmp2) + + // Only update v if we have a nonzero coeff to add in. + if aNaf[i] > 0 { + v.fromP1xP1(tmp1) + aTable.SelectInto(multA, aNaf[i]) + tmp1.Add(v, multA) + } else if aNaf[i] < 0 { + v.fromP1xP1(tmp1) + aTable.SelectInto(multA, -aNaf[i]) + tmp1.Sub(v, multA) + } + + if bNaf[i] > 0 { + v.fromP1xP1(tmp1) + basepointNafTable.SelectInto(multB, bNaf[i]) + tmp1.AddAffine(v, multB) + } else if bNaf[i] < 0 { + v.fromP1xP1(tmp1) + basepointNafTable.SelectInto(multB, -bNaf[i]) + tmp1.SubAffine(v, multB) + } + + tmp2.FromP1xP1(tmp1) + } + + v.fromP2(tmp2) + return v +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalarmult_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalarmult_test.go new file mode 100644 index 0000000..4a00c79 --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/scalarmult_test.go @@ -0,0 +1,205 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import ( + "testing" + "testing/quick" +) + +var ( + // a random scalar generated using dalek. + dalekScalar, _ = (&Scalar{}).SetCanonicalBytes([]byte{219, 106, 114, 9, 174, 249, 155, 89, 69, 203, 201, 93, 92, 116, 234, 187, 78, 115, 103, 172, 182, 98, 62, 103, 187, 136, 13, 100, 248, 110, 12, 4}) + // the above, times the edwards25519 basepoint. + dalekScalarBasepoint, _ = new(Point).SetBytes([]byte{0xf4, 0xef, 0x7c, 0xa, 0x34, 0x55, 0x7b, 0x9f, 0x72, 0x3b, 0xb6, 0x1e, 0xf9, 0x46, 0x9, 0x91, 0x1c, 0xb9, 0xc0, 0x6c, 0x17, 0x28, 0x2d, 0x8b, 0x43, 0x2b, 0x5, 0x18, 0x6a, 0x54, 0x3e, 0x48}) +) + +func TestScalarMultSmallScalars(t *testing.T) { + var z Scalar + var p Point + p.ScalarMult(&z, B) + if I.Equal(&p) != 1 { + t.Error("0*B != 0") + } + checkOnCurve(t, &p) + + scEight, _ := (&Scalar{}).SetCanonicalBytes([]byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + p.ScalarMult(scEight, B) + if B.Equal(&p) != 1 { + t.Error("1*B != 1") + } + checkOnCurve(t, &p) +} + +func TestScalarMultVsDalek(t *testing.T) { + var p Point + p.ScalarMult(dalekScalar, B) + if dalekScalarBasepoint.Equal(&p) != 1 { + t.Error("Scalar mul does not match dalek") + } + checkOnCurve(t, &p) +} + +func TestBaseMultVsDalek(t *testing.T) { + var p Point + p.ScalarBaseMult(dalekScalar) + if dalekScalarBasepoint.Equal(&p) != 1 { + t.Error("Scalar mul does not match dalek") + } + checkOnCurve(t, &p) +} + +func TestVarTimeDoubleBaseMultVsDalek(t *testing.T) { + var p Point + var z Scalar + p.VarTimeDoubleScalarBaseMult(dalekScalar, B, &z) + if dalekScalarBasepoint.Equal(&p) != 1 { + t.Error("VarTimeDoubleScalarBaseMult fails with b=0") + } + checkOnCurve(t, &p) + p.VarTimeDoubleScalarBaseMult(&z, B, dalekScalar) + if dalekScalarBasepoint.Equal(&p) != 1 { + t.Error("VarTimeDoubleScalarBaseMult fails with a=0") + } + checkOnCurve(t, &p) +} + +func TestScalarMultDistributesOverAdd(t *testing.T) { + scalarMultDistributesOverAdd := func(x, y Scalar) bool { + var z Scalar + z.Add(&x, &y) + var p, q, r, check Point + p.ScalarMult(&x, B) + q.ScalarMult(&y, B) + r.ScalarMult(&z, B) + check.Add(&p, &q) + checkOnCurve(t, &p, &q, &r, &check) + return check.Equal(&r) == 1 + } + + if err := quick.Check(scalarMultDistributesOverAdd, quickCheckConfig(32)); err != nil { + t.Error(err) + } +} + +func TestScalarMultNonIdentityPoint(t *testing.T) { + // Check whether p.ScalarMult and q.ScalaBaseMult give the same, + // when p and q are originally set to the base point. + + scalarMultNonIdentityPoint := func(x Scalar) bool { + var p, q Point + p.Set(B) + q.Set(B) + + p.ScalarMult(&x, B) + q.ScalarBaseMult(&x) + + checkOnCurve(t, &p, &q) + + return p.Equal(&q) == 1 + } + + if err := quick.Check(scalarMultNonIdentityPoint, quickCheckConfig(32)); err != nil { + t.Error(err) + } +} + +func TestBasepointTableGeneration(t *testing.T) { + // The basepoint table is 32 affineLookupTables, + // corresponding to (16^2i)*B for table i. + basepointTable := basepointTable() + + tmp1 := &projP1xP1{} + tmp2 := &projP2{} + tmp3 := &Point{} + tmp3.Set(B) + table := make([]affineLookupTable, 32) + for i := 0; i < 32; i++ { + // Build the table + table[i].FromP3(tmp3) + // Assert equality with the hardcoded one + if table[i] != basepointTable[i] { + t.Errorf("Basepoint table %d does not match", i) + } + + // Set p = (16^2)*p = 256*p = 2^8*p + tmp2.FromP3(tmp3) + for j := 0; j < 7; j++ { + tmp1.Double(tmp2) + tmp2.FromP1xP1(tmp1) + } + tmp1.Double(tmp2) + tmp3.fromP1xP1(tmp1) + checkOnCurve(t, tmp3) + } +} + +func TestScalarMultMatchesBaseMult(t *testing.T) { + scalarMultMatchesBaseMult := func(x Scalar) bool { + var p, q Point + p.ScalarMult(&x, B) + q.ScalarBaseMult(&x) + checkOnCurve(t, &p, &q) + return p.Equal(&q) == 1 + } + + if err := quick.Check(scalarMultMatchesBaseMult, quickCheckConfig(32)); err != nil { + t.Error(err) + } +} + +func TestBasepointNafTableGeneration(t *testing.T) { + var table nafLookupTable8 + table.FromP3(B) + + if table != *basepointNafTable() { + t.Error("BasepointNafTable does not match") + } +} + +func TestVarTimeDoubleBaseMultMatchesBaseMult(t *testing.T) { + varTimeDoubleBaseMultMatchesBaseMult := func(x, y Scalar) bool { + var p, q1, q2, check Point + + p.VarTimeDoubleScalarBaseMult(&x, B, &y) + + q1.ScalarBaseMult(&x) + q2.ScalarBaseMult(&y) + check.Add(&q1, &q2) + + checkOnCurve(t, &p, &check, &q1, &q2) + return p.Equal(&check) == 1 + } + + if err := quick.Check(varTimeDoubleBaseMultMatchesBaseMult, quickCheckConfig(32)); err != nil { + t.Error(err) + } +} + +// Benchmarks. + +func BenchmarkScalarBaseMult(b *testing.B) { + var p Point + + for i := 0; i < b.N; i++ { + p.ScalarBaseMult(dalekScalar) + } +} + +func BenchmarkScalarMult(b *testing.B) { + var p Point + + for i := 0; i < b.N; i++ { + p.ScalarMult(dalekScalar, B) + } +} + +func BenchmarkVarTimeDoubleScalarBaseMult(b *testing.B) { + var p Point + + for i := 0; i < b.N; i++ { + p.VarTimeDoubleScalarBaseMult(dalekScalar, B, dalekScalar) + } +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/tables.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/tables.go new file mode 100644 index 0000000..4a2b54e --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/tables.go @@ -0,0 +1,127 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import "crypto/subtle" + +// A dynamic lookup table for variable-base, constant-time scalar muls. +type projLookupTable struct { + points [8]projCached +} + +// A precomputed lookup table for fixed-base, constant-time scalar muls. +type affineLookupTable struct { + points [8]affineCached +} + +// A dynamic lookup table for variable-base, variable-time scalar muls. +type nafLookupTable5 struct { + points [8]projCached +} + +// A precomputed lookup table for fixed-base, variable-time scalar muls. +type nafLookupTable8 struct { + points [64]affineCached +} + +// Constructors. + +// Builds a lookup table at runtime. Fast. +func (v *projLookupTable) FromP3(q *Point) { + // Goal: v.points[i] = (i+1)*Q, i.e., Q, 2Q, ..., 8Q + // This allows lookup of -8Q, ..., -Q, 0, Q, ..., 8Q + v.points[0].FromP3(q) + tmpP3 := Point{} + tmpP1xP1 := projP1xP1{} + for i := 0; i < 7; i++ { + // Compute (i+1)*Q as Q + i*Q and convert to a projCached + // This is needlessly complicated because the API has explicit + // receivers instead of creating stack objects and relying on RVO + v.points[i+1].FromP3(tmpP3.fromP1xP1(tmpP1xP1.Add(q, &v.points[i]))) + } +} + +// This is not optimised for speed; fixed-base tables should be precomputed. +func (v *affineLookupTable) FromP3(q *Point) { + // Goal: v.points[i] = (i+1)*Q, i.e., Q, 2Q, ..., 8Q + // This allows lookup of -8Q, ..., -Q, 0, Q, ..., 8Q + v.points[0].FromP3(q) + tmpP3 := Point{} + tmpP1xP1 := projP1xP1{} + for i := 0; i < 7; i++ { + // Compute (i+1)*Q as Q + i*Q and convert to affineCached + v.points[i+1].FromP3(tmpP3.fromP1xP1(tmpP1xP1.AddAffine(q, &v.points[i]))) + } +} + +// Builds a lookup table at runtime. Fast. +func (v *nafLookupTable5) FromP3(q *Point) { + // Goal: v.points[i] = (2*i+1)*Q, i.e., Q, 3Q, 5Q, ..., 15Q + // This allows lookup of -15Q, ..., -3Q, -Q, 0, Q, 3Q, ..., 15Q + v.points[0].FromP3(q) + q2 := Point{} + q2.Add(q, q) + tmpP3 := Point{} + tmpP1xP1 := projP1xP1{} + for i := 0; i < 7; i++ { + v.points[i+1].FromP3(tmpP3.fromP1xP1(tmpP1xP1.Add(&q2, &v.points[i]))) + } +} + +// This is not optimised for speed; fixed-base tables should be precomputed. +func (v *nafLookupTable8) FromP3(q *Point) { + v.points[0].FromP3(q) + q2 := Point{} + q2.Add(q, q) + tmpP3 := Point{} + tmpP1xP1 := projP1xP1{} + for i := 0; i < 63; i++ { + v.points[i+1].FromP3(tmpP3.fromP1xP1(tmpP1xP1.AddAffine(&q2, &v.points[i]))) + } +} + +// Selectors. + +// Set dest to x*Q, where -8 <= x <= 8, in constant time. +func (v *projLookupTable) SelectInto(dest *projCached, x int8) { + // Compute xabs = |x| + xmask := x >> 7 + xabs := uint8((x + xmask) ^ xmask) + + dest.Zero() + for j := 1; j <= 8; j++ { + // Set dest = j*Q if |x| = j + cond := subtle.ConstantTimeByteEq(xabs, uint8(j)) + dest.Select(&v.points[j-1], dest, cond) + } + // Now dest = |x|*Q, conditionally negate to get x*Q + dest.CondNeg(int(xmask & 1)) +} + +// Set dest to x*Q, where -8 <= x <= 8, in constant time. +func (v *affineLookupTable) SelectInto(dest *affineCached, x int8) { + // Compute xabs = |x| + xmask := x >> 7 + xabs := uint8((x + xmask) ^ xmask) + + dest.Zero() + for j := 1; j <= 8; j++ { + // Set dest = j*Q if |x| = j + cond := subtle.ConstantTimeByteEq(xabs, uint8(j)) + dest.Select(&v.points[j-1], dest, cond) + } + // Now dest = |x|*Q, conditionally negate to get x*Q + dest.CondNeg(int(xmask & 1)) +} + +// Given odd x with 0 < x < 2^4, return x*Q (in variable time). +func (v *nafLookupTable5) SelectInto(dest *projCached, x int8) { + *dest = v.points[x/2] +} + +// Given odd x with 0 < x < 2^7, return x*Q (in variable time). +func (v *nafLookupTable8) SelectInto(dest *affineCached, x int8) { + *dest = v.points[x/2] +} diff --git a/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/tables_test.go b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/tables_test.go new file mode 100644 index 0000000..b5d161a --- /dev/null +++ b/platform/pkg/mod/filippo.io/edwards25519@v1.2.0/tables_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package edwards25519 + +import ( + "testing" +) + +func TestProjLookupTable(t *testing.T) { + var table projLookupTable + table.FromP3(B) + + var tmp1, tmp2, tmp3 projCached + table.SelectInto(&tmp1, 6) + table.SelectInto(&tmp2, -2) + table.SelectInto(&tmp3, -4) + // Expect T1 + T2 + T3 = identity + + var accP1xP1 projP1xP1 + accP3 := NewIdentityPoint() + + accP1xP1.Add(accP3, &tmp1) + accP3.fromP1xP1(&accP1xP1) + accP1xP1.Add(accP3, &tmp2) + accP3.fromP1xP1(&accP1xP1) + accP1xP1.Add(accP3, &tmp3) + accP3.fromP1xP1(&accP1xP1) + + if accP3.Equal(I) != 1 { + t.Errorf("Consistency check on ProjLookupTable.SelectInto failed! %x %x %x", tmp1, tmp2, tmp3) + } +} + +func TestAffineLookupTable(t *testing.T) { + var table affineLookupTable + table.FromP3(B) + + var tmp1, tmp2, tmp3 affineCached + table.SelectInto(&tmp1, 3) + table.SelectInto(&tmp2, -7) + table.SelectInto(&tmp3, 4) + // Expect T1 + T2 + T3 = identity + + var accP1xP1 projP1xP1 + accP3 := NewIdentityPoint() + + accP1xP1.AddAffine(accP3, &tmp1) + accP3.fromP1xP1(&accP1xP1) + accP1xP1.AddAffine(accP3, &tmp2) + accP3.fromP1xP1(&accP1xP1) + accP1xP1.AddAffine(accP3, &tmp3) + accP3.fromP1xP1(&accP1xP1) + + if accP3.Equal(I) != 1 { + t.Errorf("Consistency check on ProjLookupTable.SelectInto failed! %x %x %x", tmp1, tmp2, tmp3) + } +} + +func TestNafLookupTable5(t *testing.T) { + var table nafLookupTable5 + table.FromP3(B) + + var tmp1, tmp2, tmp3, tmp4 projCached + table.SelectInto(&tmp1, 9) + table.SelectInto(&tmp2, 11) + table.SelectInto(&tmp3, 7) + table.SelectInto(&tmp4, 13) + // Expect T1 + T2 = T3 + T4 + + var accP1xP1 projP1xP1 + lhs := NewIdentityPoint() + rhs := NewIdentityPoint() + + accP1xP1.Add(lhs, &tmp1) + lhs.fromP1xP1(&accP1xP1) + accP1xP1.Add(lhs, &tmp2) + lhs.fromP1xP1(&accP1xP1) + + accP1xP1.Add(rhs, &tmp3) + rhs.fromP1xP1(&accP1xP1) + accP1xP1.Add(rhs, &tmp4) + rhs.fromP1xP1(&accP1xP1) + + if lhs.Equal(rhs) != 1 { + t.Errorf("Consistency check on nafLookupTable5 failed") + } +} + +func TestNafLookupTable8(t *testing.T) { + var table nafLookupTable8 + table.FromP3(B) + + var tmp1, tmp2, tmp3, tmp4 affineCached + table.SelectInto(&tmp1, 49) + table.SelectInto(&tmp2, 11) + table.SelectInto(&tmp3, 35) + table.SelectInto(&tmp4, 25) + // Expect T1 + T2 = T3 + T4 + + var accP1xP1 projP1xP1 + lhs := NewIdentityPoint() + rhs := NewIdentityPoint() + + accP1xP1.AddAffine(lhs, &tmp1) + lhs.fromP1xP1(&accP1xP1) + accP1xP1.AddAffine(lhs, &tmp2) + lhs.fromP1xP1(&accP1xP1) + + accP1xP1.AddAffine(rhs, &tmp3) + rhs.fromP1xP1(&accP1xP1) + accP1xP1.AddAffine(rhs, &tmp4) + rhs.fromP1xP1(&accP1xP1) + + if lhs.Equal(rhs) != 1 { + t.Errorf("Consistency check on nafLookupTable8 failed") + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/CONTRIBUTING.md b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/CONTRIBUTING.md new file mode 100644 index 0000000..8fe16bc --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# Contributing Guidelines + +## Reporting Issues + +Before creating a new Issue, please check first if a similar Issue [already exists](https://github.com/go-sql-driver/mysql/issues?state=open) or was [recently closed](https://github.com/go-sql-driver/mysql/issues?direction=desc&page=1&sort=updated&state=closed). + +## Contributing Code + +By contributing to this project, you share your code under the Mozilla Public License 2, as specified in the LICENSE file. +Don't forget to add yourself to the AUTHORS file. + +### Code Review + +Everyone is invited to review and comment on pull requests. +If it looks fine to you, comment with "LGTM" (Looks good to me). + +If changes are required, notice the reviewers with "PTAL" (Please take another look) after committing the fixes. + +Before merging the Pull Request, at least one [team member](https://github.com/go-sql-driver?tab=members) must have commented with "LGTM". + +## Development Ideas + +If you are looking for ideas for code contributions, please check our [Development Ideas](https://github.com/go-sql-driver/mysql/wiki/Development-Ideas) Wiki page. diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/ISSUE_TEMPLATE.md b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..d9771f1 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,21 @@ +### Issue description +Tell us what should happen and what happens instead + +### Example code +```go +If possible, please enter some example code here to reproduce the issue. +``` + +### Error log +``` +If you have an error log, please paste it here. +``` + +### Configuration +*Driver version (or git SHA):* + +*Go version:* run `go version` in your console + +*Server version:* E.g. MySQL 5.6, MariaDB 10.0.20 + +*Server OS:* E.g. Debian 8.1 (Jessie), Windows 10 diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/PULL_REQUEST_TEMPLATE.md b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6f5c7eb --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +### Description +Please explain the changes you made here. + +### Checklist +- [ ] Code compiles correctly +- [ ] Created tests which fail without the change (if possible) +- [ ] All tests passing +- [ ] Extended the README / documentation, if necessary +- [ ] Added myself / the copyright holder to the AUTHORS file diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/dependabot.yml b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/dependabot.yml new file mode 100644 index 0000000..5e513f2 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/dependabot.yml @@ -0,0 +1,24 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + groups: + all-dependencies: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + all-dependencies: + patterns: + - "*" diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/workflows/codeql.yml b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/workflows/codeql.yml new file mode 100644 index 0000000..005a61c --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "18 19 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ go ] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/workflows/test.yml b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/workflows/test.yml new file mode 100644 index 0000000..071b69a --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.github/workflows/test.yml @@ -0,0 +1,118 @@ +name: test +on: + pull_request: + push: + workflow_dispatch: + +env: + MYSQL_TEST_USER: gotest + MYSQL_TEST_PASS: secret + MYSQL_TEST_ADDR: 127.0.0.1:3306 + MYSQL_TEST_CONCURRENT: 1 + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dominikh/staticcheck-action@v1.4.1 + + list: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: list + id: set-matrix + run: | + import json + import os + go = [ + # Keep the most recent production release at the top + '1.26', + # Older production releases + '1.25', + '1.24', + ] + mysql = [ + '9.0', + '8.4', # LTS + '8.0', + '5.7', + 'mariadb-11.4', # LTS + 'mariadb-11.2', + 'mariadb-11.1', + 'mariadb-10.11', # LTS + 'mariadb-10.6', # LTS + 'mariadb-10.5', # LTS + ] + + includes = [] + # Go versions compatibility check + for v in go[1:]: + includes.append({'os': 'ubuntu-latest', 'go': v, 'mysql': mysql[0]}) + + matrix = { + # OS vs MySQL versions + 'os': [ 'ubuntu-latest', 'macos-latest', 'windows-latest' ], + 'go': [ go[0] ], + 'mysql': mysql, + + 'include': includes + } + output = json.dumps(matrix, separators=(',', ':')) + with open(os.environ["GITHUB_OUTPUT"], 'a', encoding="utf-8") as f: + print(f"matrix={output}", file=f) + shell: python + test: + needs: list + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.list.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go }} + - uses: shogo82148/actions-setup-mysql@v1 + with: + mysql-version: ${{ matrix.mysql }} + user: ${{ env.MYSQL_TEST_USER }} + password: ${{ env.MYSQL_TEST_PASS }} + my-cnf: | + innodb_log_file_size=256MB + innodb_buffer_pool_size=512MB + max_allowed_packet=48MB + ; TestConcurrent fails if max_connections is too large + max_connections=50 + local_infile=1 + performance_schema=on + - name: setup database + run: | + mysql --user 'root' --host '127.0.0.1' -e 'create database gotest;' + + - name: test + run: | + go test -v '-race' '-covermode=atomic' '-coverprofile=coverage.out' -parallel 10 + + - name: benchmark + run: | + go test -run '^$' -bench . + + - name: Send coverage + uses: shogo82148/actions-goveralls@v1 + with: + path-to-profile: coverage.out + flag-name: ${{ runner.os }}-Go-${{ matrix.go }}-DB-${{ matrix.mysql }} + parallel: true + + # notifies that all test jobs are finished. + finish: + needs: test + if: always() + runs-on: ubuntu-latest + steps: + - uses: shogo82148/actions-goveralls@v1 + with: + parallel-finished: true diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.gitignore b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.gitignore new file mode 100644 index 0000000..2de28da --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Icon? +ehthumbs.db +Thumbs.db +.idea diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/AUTHORS b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/AUTHORS new file mode 100644 index 0000000..42c7f02 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/AUTHORS @@ -0,0 +1,159 @@ +# This is the official list of Go-MySQL-Driver authors for copyright purposes. + +# If you are submitting a patch, please add your name or the name of the +# organization which holds the copyright to this list in alphabetical order. + +# Names should be added to this file as +# Name +# The email address is not required for organizations. +# Please keep the list sorted. + + +# Individual Persons + +Aaron Hopkins +Achille Roussel +Aidan +Alex Snast +Alexey Palazhchenko +Andrew Reid +Animesh Ray +Ariel Mashraki +Arne Hormann +Artur Melanchyk +Asta Xie +B Lamarche +Bes Dollma +Bogdan Constantinescu +Brad Higgins +Brian Hendriks +Bulat Gaifullin +Caine Jette +Carlos Nieto +Chris Kirkland +Chris Moos +Craig Wilson +Daemonxiao <735462752 at qq.com> +Daniel Montoya +Daniel Nichter +Daniël van Eeden +Dave Protasowski +Demouth +Diego Dupin +Dirkjan Bussink +DisposaBoy +Egor Smolyakov +Erwan Martin +Evan Elias +Evan Shaw +Frederick Mayle +Gustavo Kristic +Gusted +Hajime Nakagami +Hanno Braun +Henri Yandell +Hirotaka Yamamoto +Huyiguang +ICHINOSE Shogo +Ilia Cimpoes +INADA Naoki +Jacek Szwec +Jakub Adamus +James Harr +Janek Vedock +Jason Ng +Jean-Yves Pellé +Jeff Hodges +Jeffrey Charles +Jennifer Purevsuren +Jerome Meyer +Jiabin Zhang +Jiajia Zhong +Jian Zhen +Joe Mann +Joshua Prunier +Julien Lefevre +Julien Schmidt +Justin Li +Justin Nuß +Kamil Dziedzic +Kei Kamikawa +Kevin Malachowski +Kieron Woodhouse +Lance Tian +Lennart Rudolph +Leonardo YongUk Kim +Linh Tran Tuan +Lion Yang +Luca Looz +Lucas Liu +Luke Scott +Lunny Xiao +Maciej Zimnoch +Michael Woolnough +Minh Quang +Morgan Tocker +Nao Yokotsuka +Nathanial Murphy +Nicola Peduzzi +Oliver Bone +Olivier Mengué +oscarzhao +Paul Bonser +Paulius Lozys +Peter Schultz +Phil Porada +Rebecca Chin +Reed Allman +Richard Wilkes +Robert Russell +Runrioter Wung +Samantha Frank +Santhosh Kumar Tekuri +Sho Iizuka +Sho Ikeda +Shuode Li +Simon J Mudd +Soroush Pour +Stan Putrya +Stanley Gunawan +Steven Hartland +Tan Jinhua <312841925 at qq.com> +Tetsuro Aoki +Thomas Wodarek +Tim Ruffles +Tom Jenkinson +Vladimir Kovpak +Vladyslav Zhelezniak +Xiangyu Hu +Xiaobing Jiang +Xiuming Chen +Xuehong Chan +Zhang Xiang +Zhenye Xie +Zhixin Wen +Ziheng Lyu + +# Organizations + +Barracuda Networks, Inc. +Block, Inc. +Counting Ltd. +Defined Networking Inc. +DigitalOcean Inc. +Dolthub Inc. +dyves labs AG +Facebook Inc. +GitHub Inc. +Google Inc. +InfoSum Ltd. +Keybase Inc. +Microsoft Corp. +Multiplay Ltd. +Percona LLC +PingCAP Inc. +Pivotal Inc. +Shattered Silicon Ltd. +Stripe Inc. +ThousandEyes +Zendesk Inc. diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/CHANGELOG.md b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/CHANGELOG.md new file mode 100644 index 0000000..b24af9b --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/CHANGELOG.md @@ -0,0 +1,373 @@ +# Changelog + +## v1.10.0 (2026-04-28) + +* Fix `getSystemVar("max_allowed_packet")` potentially returned wrong value. (#1754) + This affects only when `maxAllowedPacket=0` is set. + +* Bump filippo.io/edwards25519 from 1.1.1 to 1.2.0. (#1756) + While older versions have reported CVEs, they do not affect go-mysql. + +* Update Go versions to 1.24-1.26. (#1763) + +* Enhance interpolateParams to correctly handle placeholders. (#1732) + The question mark (?) within strings and comments will no longer be treated as a placeholder. + + +## v1.9.3 (2025-06-13) + +* `tx.Commit()` and `tx.Rollback()` returned `ErrInvalidConn` always. + Now they return cached real error if present. (#1690) + +* Optimize reading small result sets to fix a performance regression + introduced by compression protocol support. (`#1707`) +* Fix `db.Ping()` on compressed connection. (#1723) + + +## v1.9.2 (2025-04-07) + +v1.9.2 is a re-release of v1.9.1 due to a release process issue; no changes were made to the content. + + +## v1.9.1 (2025-03-21) + +### Major Changes + +* Add Charset() option. (#1679) + +### Bugfixes + +* go.mod: fix go version format (#1682) +* Fix FormatDSN missing ConnectionAttributes (#1619) + +## v1.9.0 (2025-02-18) + +### Major Changes + +- Implement zlib compression. (#1487) +- Supported Go version is updated to Go 1.21+. (#1639) +- Add support for VECTOR type introduced in MySQL 9.0. (#1609) +- Config object can have custom dial function. (#1527) + +### Bugfixes + +- Fix auth errors when username/password are too long. (#1625) +- Check if MySQL supports CLIENT_CONNECT_ATTRS before sending client attributes. (#1640) +- Fix auth switch request handling. (#1666) + +### Other changes + +- Add "filename:line" prefix to log in go-mysql. Custom loggers now show it. (#1589) +- Improve error handling. It reduces the "busy buffer" errors. (#1595, #1601, #1641) +- Use `strconv.Atoi` to parse max_allowed_packet. (#1661) +- `rejectReadOnly` option now handles ER_READ_ONLY_MODE (1290) error too. (#1660) + + +## Version 1.8.1 (2024-03-26) + +Bugfixes: + +- fix race condition when context is canceled in [#1562](https://github.com/go-sql-driver/mysql/pull/1562) and [#1570](https://github.com/go-sql-driver/mysql/pull/1570) + +## Version 1.8.0 (2024-03-09) + +Major Changes: + +- Use `SET NAMES charset COLLATE collation`. by @methane in [#1437](https://github.com/go-sql-driver/mysql/pull/1437) + - Older go-mysql-driver used `collation_id` in the handshake packet. But it caused collation mismatch in some situation. + - If you don't specify charset nor collation, go-mysql-driver sends `SET NAMES utf8mb4` for new connection. This uses server's default collation for utf8mb4. + - If you specify charset, go-mysql-driver sends `SET NAMES `. This uses the server's default collation for ``. + - If you specify collation and/or charset, go-mysql-driver sends `SET NAMES charset COLLATE collation`. +- PathEscape dbname in DSN. by @methane in [#1432](https://github.com/go-sql-driver/mysql/pull/1432) + - This is backward incompatible in rare case. Check your DSN. +- Drop Go 1.13-17 support by @methane in [#1420](https://github.com/go-sql-driver/mysql/pull/1420) + - Use Go 1.18+ +- Parse numbers on text protocol too by @methane in [#1452](https://github.com/go-sql-driver/mysql/pull/1452) + - When text protocol is used, go-mysql-driver passed bare `[]byte` to database/sql for avoid unnecessary allocation and conversion. + - If user specified `*any` to `Scan()`, database/sql passed the `[]byte` into the target variable. + - This confused users because most user doesn't know when text/binary protocol used. + - go-mysql-driver 1.8 converts integer/float values into int64/double even in text protocol. This doesn't increase allocation compared to `[]byte` and conversion cost is negatable. +- New options start using the Functional Option Pattern to avoid increasing technical debt in the Config object. Future version may introduce Functional Option for existing options, but not for now. + - Make TimeTruncate functional option by @methane in [1552](https://github.com/go-sql-driver/mysql/pull/1552) + - Add BeforeConnect callback to configuration object by @ItalyPaleAle in [#1469](https://github.com/go-sql-driver/mysql/pull/1469) + + +Other changes: + +- Adding DeregisterDialContext to prevent memory leaks with dialers we don't need anymore by @jypelle in https://github.com/go-sql-driver/mysql/pull/1422 +- Make logger configurable per connection by @frozenbonito in https://github.com/go-sql-driver/mysql/pull/1408 +- Fix ColumnType.DatabaseTypeName for mediumint unsigned by @evanelias in https://github.com/go-sql-driver/mysql/pull/1428 +- Add connection attributes by @Daemonxiao in https://github.com/go-sql-driver/mysql/pull/1389 +- Stop `ColumnTypeScanType()` from returning `sql.RawBytes` by @methane in https://github.com/go-sql-driver/mysql/pull/1424 +- Exec() now provides access to status of multiple statements. by @mherr-google in https://github.com/go-sql-driver/mysql/pull/1309 +- Allow to change (or disable) the default driver name for registration by @dolmen in https://github.com/go-sql-driver/mysql/pull/1499 +- Add default connection attribute '_server_host' by @oblitorum in https://github.com/go-sql-driver/mysql/pull/1506 +- QueryUnescape DSN ConnectionAttribute value by @zhangyangyu in https://github.com/go-sql-driver/mysql/pull/1470 +- Add client_ed25519 authentication by @Gusted in https://github.com/go-sql-driver/mysql/pull/1518 + +## Version 1.7.1 (2023-04-25) + +Changes: + + - bump actions/checkout@v3 and actions/setup-go@v3 (#1375) + - Add go1.20 and mariadb10.11 to the testing matrix (#1403) + - Increase default maxAllowedPacket size. (#1411) + +Bugfixes: + + - Use SET syntax as specified in the MySQL documentation (#1402) + + +## Version 1.7 (2022-11-29) + +Changes: + + - Drop support of Go 1.12 (#1211) + - Refactoring `(*textRows).readRow` in a more clear way (#1230) + - util: Reduce boundary check in escape functions. (#1316) + - enhancement for mysqlConn handleAuthResult (#1250) + +New Features: + + - support Is comparison on MySQLError (#1210) + - return unsigned in database type name when necessary (#1238) + - Add API to express like a --ssl-mode=PREFERRED MySQL client (#1370) + - Add SQLState to MySQLError (#1321) + +Bugfixes: + + - Fix parsing 0 year. (#1257) + + +## Version 1.6 (2021-04-01) + +Changes: + + - Migrate the CI service from travis-ci to GitHub Actions (#1176, #1183, #1190) + - `NullTime` is deprecated (#960, #1144) + - Reduce allocations when building SET command (#1111) + - Performance improvement for time formatting (#1118) + - Performance improvement for time parsing (#1098, #1113) + +New Features: + + - Implement `driver.Validator` interface (#1106, #1174) + - Support returning `uint64` from `Valuer` in `ConvertValue` (#1143) + - Add `json.RawMessage` for converter and prepared statement (#1059) + - Interpolate `json.RawMessage` as `string` (#1058) + - Implements `CheckNamedValue` (#1090) + +Bugfixes: + + - Stop rounding times (#1121, #1172) + - Put zero filler into the SSL handshake packet (#1066) + - Fix checking cancelled connections back into the connection pool (#1095) + - Fix remove last 0 byte for mysql_old_password when password is empty (#1133) + + +## Version 1.5 (2020-01-07) + +Changes: + + - Dropped support Go 1.9 and lower (#823, #829, #886, #1016, #1017) + - Improve buffer handling (#890) + - Document potentially insecure TLS configs (#901) + - Use a double-buffering scheme to prevent data races (#943) + - Pass uint64 values without converting them to string (#838, #955) + - Update collations and make utf8mb4 default (#877, #1054) + - Make NullTime compatible with sql.NullTime in Go 1.13+ (#995) + - Removed CloudSQL support (#993, #1007) + - Add Go Module support (#1003) + +New Features: + + - Implement support of optional TLS (#900) + - Check connection liveness (#934, #964, #997, #1048, #1051, #1052) + - Implement Connector Interface (#941, #958, #1020, #1035) + +Bugfixes: + + - Mark connections as bad on error during ping (#875) + - Mark connections as bad on error during dial (#867) + - Fix connection leak caused by rapid context cancellation (#1024) + - Mark connections as bad on error during Conn.Prepare (#1030) + + +## Version 1.4.1 (2018-11-14) + +Bugfixes: + + - Fix TIME format for binary columns (#818) + - Fix handling of empty auth plugin names (#835) + - Fix caching_sha2_password with empty password (#826) + - Fix canceled context broke mysqlConn (#862) + - Fix OldAuthSwitchRequest support (#870) + - Fix Auth Response packet for cleartext password (#887) + +## Version 1.4 (2018-06-03) + +Changes: + + - Documentation fixes (#530, #535, #567) + - Refactoring (#575, #579, #580, #581, #603, #615, #704) + - Cache column names (#444) + - Sort the DSN parameters in DSNs generated from a config (#637) + - Allow native password authentication by default (#644) + - Use the default port if it is missing in the DSN (#668) + - Removed the `strict` mode (#676) + - Do not query `max_allowed_packet` by default (#680) + - Dropped support Go 1.6 and lower (#696) + - Updated `ConvertValue()` to match the database/sql/driver implementation (#760) + - Document the usage of `0000-00-00T00:00:00` as the time.Time zero value (#783) + - Improved the compatibility of the authentication system (#807) + +New Features: + + - Multi-Results support (#537) + - `rejectReadOnly` DSN option (#604) + - `context.Context` support (#608, #612, #627, #761) + - Transaction isolation level support (#619, #744) + - Read-Only transactions support (#618, #634) + - `NewConfig` function which initializes a config with default values (#679) + - Implemented the `ColumnType` interfaces (#667, #724) + - Support for custom string types in `ConvertValue` (#623) + - Implemented `NamedValueChecker`, improving support for uint64 with high bit set (#690, #709, #710) + - `caching_sha2_password` authentication plugin support (#794, #800, #801, #802) + - Implemented `driver.SessionResetter` (#779) + - `sha256_password` authentication plugin support (#808) + +Bugfixes: + + - Use the DSN hostname as TLS default ServerName if `tls=true` (#564, #718) + - Fixed LOAD LOCAL DATA INFILE for empty files (#590) + - Removed columns definition cache since it sometimes cached invalid data (#592) + - Don't mutate registered TLS configs (#600) + - Make RegisterTLSConfig concurrency-safe (#613) + - Handle missing auth data in the handshake packet correctly (#646) + - Do not retry queries when data was written to avoid data corruption (#302, #736) + - Cache the connection pointer for error handling before invalidating it (#678) + - Fixed imports for appengine/cloudsql (#700) + - Fix sending STMT_LONG_DATA for 0 byte data (#734) + - Set correct capacity for []bytes read from length-encoded strings (#766) + - Make RegisterDial concurrency-safe (#773) + + +## Version 1.3 (2016-12-01) + +Changes: + + - Go 1.1 is no longer supported + - Use decimals fields in MySQL to format time types (#249) + - Buffer optimizations (#269) + - TLS ServerName defaults to the host (#283) + - Refactoring (#400, #410, #437) + - Adjusted documentation for second generation CloudSQL (#485) + - Documented DSN system var quoting rules (#502) + - Made statement.Close() calls idempotent to avoid errors in Go 1.6+ (#512) + +New Features: + + - Enable microsecond resolution on TIME, DATETIME and TIMESTAMP (#249) + - Support for returning table alias on Columns() (#289, #359, #382) + - Placeholder interpolation, can be activated with the DSN parameter `interpolateParams=true` (#309, #318, #490) + - Support for uint64 parameters with high bit set (#332, #345) + - Cleartext authentication plugin support (#327) + - Exported ParseDSN function and the Config struct (#403, #419, #429) + - Read / Write timeouts (#401) + - Support for JSON field type (#414) + - Support for multi-statements and multi-results (#411, #431) + - DSN parameter to set the driver-side max_allowed_packet value manually (#489) + - Native password authentication plugin support (#494, #524) + +Bugfixes: + + - Fixed handling of queries without columns and rows (#255) + - Fixed a panic when SetKeepAlive() failed (#298) + - Handle ERR packets while reading rows (#321) + - Fixed reading NULL length-encoded integers in MySQL 5.6+ (#349) + - Fixed absolute paths support in LOAD LOCAL DATA INFILE (#356) + - Actually zero out bytes in handshake response (#378) + - Fixed race condition in registering LOAD DATA INFILE handler (#383) + - Fixed tests with MySQL 5.7.9+ (#380) + - QueryUnescape TLS config names (#397) + - Fixed "broken pipe" error by writing to closed socket (#390) + - Fixed LOAD LOCAL DATA INFILE buffering (#424) + - Fixed parsing of floats into float64 when placeholders are used (#434) + - Fixed DSN tests with Go 1.7+ (#459) + - Handle ERR packets while waiting for EOF (#473) + - Invalidate connection on error while discarding additional results (#513) + - Allow terminating packets of length 0 (#516) + + +## Version 1.2 (2014-06-03) + +Changes: + + - We switched back to a "rolling release". `go get` installs the current master branch again + - Version v1 of the driver will not be maintained anymore. Go 1.0 is no longer supported by this driver + - Exported errors to allow easy checking from application code + - Enabled TCP Keepalives on TCP connections + - Optimized INFILE handling (better buffer size calculation, lazy init, ...) + - The DSN parser also checks for a missing separating slash + - Faster binary date / datetime to string formatting + - Also exported the MySQLWarning type + - mysqlConn.Close returns the first error encountered instead of ignoring all errors + - writePacket() automatically writes the packet size to the header + - readPacket() uses an iterative approach instead of the recursive approach to merge split packets + +New Features: + + - `RegisterDial` allows the usage of a custom dial function to establish the network connection + - Setting the connection collation is possible with the `collation` DSN parameter. This parameter should be preferred over the `charset` parameter + - Logging of critical errors is configurable with `SetLogger` + - Google CloudSQL support + +Bugfixes: + + - Allow more than 32 parameters in prepared statements + - Various old_password fixes + - Fixed TestConcurrent test to pass Go's race detection + - Fixed appendLengthEncodedInteger for large numbers + - Renamed readLengthEnodedString to readLengthEncodedString and skipLengthEnodedString to skipLengthEncodedString (fixed typo) + + +## Version 1.1 (2013-11-02) + +Changes: + + - Go-MySQL-Driver now requires Go 1.1 + - Connections now use the collation `utf8_general_ci` by default. Adding `&charset=UTF8` to the DSN should not be necessary anymore + - Made closing rows and connections error tolerant. This allows for example deferring rows.Close() without checking for errors + - `[]byte(nil)` is now treated as a NULL value. Before, it was treated like an empty string / `[]byte("")` + - DSN parameter values must now be url.QueryEscape'ed. This allows text values to contain special characters, such as '&'. + - Use the IO buffer also for writing. This results in zero allocations (by the driver) for most queries + - Optimized the buffer for reading + - stmt.Query now caches column metadata + - New Logo + - Changed the copyright header to include all contributors + - Improved the LOAD INFILE documentation + - The driver struct is now exported to make the driver directly accessible + - Refactored the driver tests + - Added more benchmarks and moved all to a separate file + - Other small refactoring + +New Features: + + - Added *old_passwords* support: Required in some cases, but must be enabled by adding `allowOldPasswords=true` to the DSN since it is insecure + - Added a `clientFoundRows` parameter: Return the number of matching rows instead of the number of rows changed on UPDATEs + - Added TLS/SSL support: Use a TLS/SSL encrypted connection to the server. Custom TLS configs can be registered and used + +Bugfixes: + + - Fixed MySQL 4.1 support: MySQL 4.1 sends packets with lengths which differ from the specification + - Convert to DB timezone when inserting `time.Time` + - Split packets (more than 16MB) are now merged correctly + - Fixed false positive `io.EOF` errors when the data was fully read + - Avoid panics on reuse of closed connections + - Fixed empty string producing false nil values + - Fixed sign byte for positive TIME fields + + +## Version 1.0 (2013-05-14) + +Initial Release diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/LICENSE b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/LICENSE new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/README.md b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/README.md new file mode 100644 index 0000000..3da0538 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/README.md @@ -0,0 +1,598 @@ +# Go-MySQL-Driver + +[![DeepWiki](https://img.shields.io/badge/DeepWiki-go--sql--driver%2Fmysql-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/go-sql-driver/mysql) + + +A MySQL-Driver for Go's [database/sql](https://golang.org/pkg/database/sql/) package + +![Go-MySQL-Driver logo](https://raw.github.com/wiki/go-sql-driver/mysql/gomysql_m.png "Golang Gopher holding the MySQL Dolphin") + +--------------------------------------- + * [Features](#features) + * [Requirements](#requirements) + * [Installation](#installation) + * [Usage](#usage) + * [DSN (Data Source Name)](#dsn-data-source-name) + * [Password](#password) + * [Protocol](#protocol) + * [Address](#address) + * [Parameters](#parameters) + * [Examples](#examples) + * [Connection pool and timeouts](#connection-pool-and-timeouts) + * [context.Context Support](#contextcontext-support) + * [ColumnType Support](#columntype-support) + * [LOAD DATA LOCAL INFILE support](#load-data-local-infile-support) + * [time.Time support](#timetime-support) + * [Unicode support](#unicode-support) + * [Testing / Development](#testing--development) + * [License](#license) + +--------------------------------------- + +## Features + * Lightweight and [fast](https://github.com/go-sql-driver/sql-benchmark "golang MySQL-Driver performance") + * Native Go implementation. No C-bindings, just pure Go + * Connections over TCP/IPv4, TCP/IPv6, Unix domain sockets or [custom protocols](https://godoc.org/github.com/go-sql-driver/mysql#DialFunc) + * Automatic handling of broken connections + * Automatic Connection Pooling *(by database/sql package)* + * Supports queries larger than 16MB + * Full [`sql.RawBytes`](https://golang.org/pkg/database/sql/#RawBytes) support. + * Intelligent `LONG DATA` handling in prepared statements + * Secure `LOAD DATA LOCAL INFILE` support with file allowlisting and `io.Reader` support + * Optional `time.Time` parsing + * Optional placeholder interpolation + * Supports zlib compression. + +## Requirements + +* Go 1.24 or higher. We aim to support the 3 latest versions of Go. +* MySQL (5.7+) and MariaDB (10.5+) are supported by maintainers. +* [TiDB](https://github.com/pingcap/tidb) is supported by PingCAP. + * Do not ask questions about TiDB in our issue tracker or forum. + * [Document](https://docs.pingcap.com/tidb/v6.1/dev-guide-sample-application-golang) + * [Forum](https://ask.pingcap.com/) +* go-mysql would work with Percona Server, Google CloudSQL or Sphinx (2.2.3+). + * Maintainers won't support them. Do not expect issues are investigated and resolved by maintainers. + * Investigate issues yourself and please send a pull request to fix it. + +--------------------------------------- + +## Installation +Simple install the package to your [$GOPATH](https://github.com/golang/go/wiki/GOPATH "GOPATH") with the [go tool](https://golang.org/cmd/go/ "go command") from shell: +```bash +go get -u github.com/go-sql-driver/mysql +``` +Make sure [Git is installed](https://git-scm.com/downloads) on your machine and in your system's `PATH`. + +## Usage +_Go MySQL Driver_ is an implementation of Go's `database/sql/driver` interface. You only need to import the driver and can use the full [`database/sql`](https://golang.org/pkg/database/sql/) API then. + +Use `mysql` as `driverName` and a valid [DSN](#dsn-data-source-name) as `dataSourceName`: + +```go +import ( + "database/sql" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// ... + +db, err := sql.Open("mysql", "user:password@/dbname") +if err != nil { + panic(err) +} +// See "Important settings" section. +db.SetConnMaxLifetime(time.Minute * 3) +db.SetMaxOpenConns(10) +db.SetMaxIdleConns(10) +``` + +[Examples are available in our Wiki](https://github.com/go-sql-driver/mysql/wiki/Examples "Go-MySQL-Driver Examples"). + +### Important settings + +`db.SetConnMaxLifetime()` is required to ensure connections are closed by the driver safely before connection is closed by MySQL server, OS, or other middlewares. Since some middlewares close idle connections by 5 minutes, we recommend timeout shorter than 5 minutes. This setting helps load balancing and changing system variables too. + +`db.SetMaxOpenConns()` is highly recommended to limit the number of connection used by the application. There is no recommended limit number because it depends on application and MySQL server. + +`db.SetMaxIdleConns()` is recommended to be set same to `db.SetMaxOpenConns()`. When it is smaller than `SetMaxOpenConns()`, connections can be opened and closed much more frequently than you expect. Idle connections can be closed by the `db.SetConnMaxLifetime()`. If you want to close idle connections more rapidly, you can use `db.SetConnMaxIdleTime()` since Go 1.15. + + +### DSN (Data Source Name) + +The Data Source Name has a common format, like e.g. [PEAR DB](http://pear.php.net/manual/en/package.database.db.intro-dsn.php) uses it, but without type-prefix (optional parts marked by squared brackets): +``` +[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...¶mN=valueN] +``` + +A DSN in its fullest form: +``` +username:password@protocol(address)/dbname?param=value +``` + +Except for the databasename, all values are optional. So the minimal DSN is: +``` +/dbname +``` + +If you do not want to preselect a database, leave `dbname` empty: +``` +/ +``` +This has the same effect as an empty DSN string: +``` + +``` + +`dbname` is escaped by [PathEscape()](https://pkg.go.dev/net/url#PathEscape) since v1.8.0. If your database name is `dbname/withslash`, it becomes: + +``` +/dbname%2Fwithslash +``` + +Alternatively, [Config.FormatDSN](https://godoc.org/github.com/go-sql-driver/mysql#Config.FormatDSN) can be used to create a DSN string by filling a struct. + +#### Password +Passwords can consist of any character. Escaping is **not** necessary. + +#### Protocol +See [net.Dial](https://golang.org/pkg/net/#Dial) for more information which networks are available. +In general you should use a Unix domain socket if available and TCP otherwise for best performance. + +#### Address +For TCP and UDP networks, addresses have the form `host[:port]`. +If `port` is omitted, the default port will be used. +If `host` is a literal IPv6 address, it must be enclosed in square brackets. +The functions [net.JoinHostPort](https://golang.org/pkg/net/#JoinHostPort) and [net.SplitHostPort](https://golang.org/pkg/net/#SplitHostPort) manipulate addresses in this form. + +For Unix domain sockets the address is the absolute path to the MySQL-Server-socket, e.g. `/var/run/mysqld/mysqld.sock` or `/tmp/mysql.sock`. + +#### Parameters +*Parameters are case-sensitive!* + +Notice that any of `true`, `TRUE`, `True` or `1` is accepted to stand for a true boolean value. Not surprisingly, false can be specified as any of: `false`, `FALSE`, `False` or `0`. + +##### `allowAllFiles` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +`allowAllFiles=true` disables the file allowlist for `LOAD DATA LOCAL INFILE` and allows *all* files. +[*Might be insecure!*](https://dev.mysql.com/doc/refman/8.0/en/load-data.html#load-data-local) + +##### `allowCleartextPasswords` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +`allowCleartextPasswords=true` allows using the [cleartext client side plugin](https://dev.mysql.com/doc/en/cleartext-pluggable-authentication.html) if required by an account, such as one defined with the [PAM authentication plugin](http://dev.mysql.com/doc/en/pam-authentication-plugin.html). Sending passwords in clear text may be a security problem in some configurations. To avoid problems if there is any possibility that the password would be intercepted, clients should connect to MySQL Server using a method that protects the password. Possibilities include [TLS / SSL](#tls), IPsec, or a private network. + + +##### `allowFallbackToPlaintext` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +`allowFallbackToPlaintext=true` acts like a `--ssl-mode=PREFERRED` MySQL client as described in [Command Options for Connecting to the Server](https://dev.mysql.com/doc/refman/5.7/en/connection-options.html#option_general_ssl-mode) + +##### `allowNativePasswords` + +``` +Type: bool +Valid Values: true, false +Default: true +``` +`allowNativePasswords=false` disallows the usage of MySQL native password method. + +##### `allowOldPasswords` + +``` +Type: bool +Valid Values: true, false +Default: false +``` +`allowOldPasswords=true` allows the usage of the insecure old password method. This should be avoided, but is necessary in some cases. See also [the old_passwords wiki page](https://github.com/go-sql-driver/mysql/wiki/old_passwords). + +##### `charset` + +``` +Type: string +Valid Values: +Default: none +``` + +Sets the charset used for client-server interaction (`"SET NAMES "`). If multiple charsets are set (separated by a comma), the following charset is used if setting the charset fails. This enables for example support for `utf8mb4` ([introduced in MySQL 5.5.3](http://dev.mysql.com/doc/refman/5.5/en/charset-unicode-utf8mb4.html)) with fallback to `utf8` for older servers (`charset=utf8mb4,utf8`). + +See also [Unicode Support](#unicode-support). + +##### `checkConnLiveness` + +``` +Type: bool +Valid Values: true, false +Default: true +``` + +On supported platforms connections retrieved from the connection pool are checked for liveness before using them. If the check fails, the respective connection is marked as bad and the query retried with another connection. +`checkConnLiveness=false` disables this liveness check of connections. + +##### `collation` + +``` +Type: string +Valid Values: +Default: utf8mb4_general_ci +``` + +Sets the collation used for client-server interaction on connection. In contrast to `charset`, `collation` does not issue additional queries. If the specified collation is unavailable on the target server, the connection will fail. + +A list of valid charsets for a server is retrievable with `SHOW COLLATION`. + +The default collation (`utf8mb4_general_ci`) is supported from MySQL 5.5. You should use an older collation (e.g. `utf8_general_ci`) for older MySQL. + +Collations for charset "ucs2", "utf16", "utf16le", and "utf32" can not be used ([ref](https://dev.mysql.com/doc/refman/5.7/en/charset-connection.html#charset-connection-impermissible-client-charset)). + +See also [Unicode Support](#unicode-support). + +##### `clientFoundRows` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +`clientFoundRows=true` causes an UPDATE to return the number of matching rows instead of the number of rows changed. + +##### `columnsWithAlias` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +When `columnsWithAlias` is true, calls to `sql.Rows.Columns()` will return the table alias and the column name separated by a dot. For example: + +``` +SELECT u.id FROM users as u +``` + +will return `u.id` instead of just `id` if `columnsWithAlias=true`. + +##### `compress` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +Toggles zlib compression. false by default. + +##### `interpolateParams` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +If `interpolateParams` is true, placeholders (`?`) in calls to `db.Query()` and `db.Exec()` are interpolated into a single query string with given parameters. This reduces the number of roundtrips, since the driver has to prepare a statement, execute it with given parameters and close the statement again with `interpolateParams=false`. + +*This can not be used together with the multibyte encodings BIG5, CP932, GB2312, GBK or SJIS. These are rejected as they may [introduce a SQL injection vulnerability](http://stackoverflow.com/a/12118602/3430118)!* + +##### `loc` + +``` +Type: string +Valid Values: +Default: UTC +``` + +Sets the location for time.Time values (when using `parseTime=true`). *"Local"* sets the system's location. See [time.LoadLocation](https://golang.org/pkg/time/#LoadLocation) for details. + +Note that this sets the location for time.Time values but does not change MySQL's [time_zone setting](https://dev.mysql.com/doc/refman/5.5/en/time-zone-support.html). For that see the [time_zone system variable](#system-variables), which can also be set as a DSN parameter. + +Please keep in mind, that param values must be [url.QueryEscape](https://golang.org/pkg/net/url/#QueryEscape)'ed. Alternatively you can manually replace the `/` with `%2F`. For example `US/Pacific` would be `loc=US%2FPacific`. + +##### `timeTruncate` + +``` +Type: duration +Default: 0 +``` + +[Truncate time values](https://pkg.go.dev/time#Duration.Truncate) to the specified duration. The value must be a decimal number with a unit suffix (*"ms"*, *"s"*, *"m"*, *"h"*), such as *"30s"*, *"0.5m"* or *"1m30s"*. + +##### `maxAllowedPacket` +``` +Type: decimal number +Default: 64*1024*1024 +``` + +Max packet size allowed in bytes. The default value is 64 MiB and should be adjusted to match the server settings. `maxAllowedPacket=0` can be used to automatically fetch the `max_allowed_packet` variable from server *on every connection*. + +##### `multiStatements` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +Allow multiple statements in one query. This can be used to bach multiple queries. Use [Rows.NextResultSet()](https://pkg.go.dev/database/sql#Rows.NextResultSet) to get result of the second and subsequent queries. + +When `multiStatements` is used, `?` parameters must only be used in the first statement. [interpolateParams](#interpolateparams) can be used to avoid this limitation unless prepared statement is used explicitly. + +It's possible to access the last inserted ID and number of affected rows for multiple statements by using `sql.Conn.Raw()` and the `mysql.Result`. For example: + +```go +conn, _ := db.Conn(ctx) +conn.Raw(func(conn any) error { + ex := conn.(driver.Execer) + res, err := ex.Exec(` + UPDATE point SET x = 1 WHERE y = 2; + UPDATE point SET x = 2 WHERE y = 3; + `, nil) + // Both slices have 2 elements. + log.Print(res.(mysql.Result).AllRowsAffected()) + log.Print(res.(mysql.Result).AllLastInsertIds()) +}) +``` + +##### `parseTime` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + +`parseTime=true` changes the output type of `DATE` and `DATETIME` values to `time.Time` instead of `[]byte` / `string` +The date or datetime like `0000-00-00 00:00:00` is converted into zero value of `time.Time`. + + +##### `readTimeout` + +``` +Type: duration +Default: 0 +``` + +I/O read timeout. The value must be a decimal number with a unit suffix (*"ms"*, *"s"*, *"m"*, *"h"*), such as *"30s"*, *"0.5m"* or *"1m30s"*. + +##### `rejectReadOnly` + +``` +Type: bool +Valid Values: true, false +Default: false +``` + + +`rejectReadOnly=true` causes the driver to reject read-only connections. This +is for a possible race condition during an automatic failover, where the mysql +client gets connected to a read-only replica after the failover. + +Note that this should be a fairly rare case, as an automatic failover normally +happens when the primary is down, and the race condition shouldn't happen +unless it comes back up online as soon as the failover is kicked off. On the +other hand, when this happens, a MySQL application can get stuck on a +read-only connection until restarted. It is however fairly easy to reproduce, +for example, using a manual failover on AWS Aurora's MySQL-compatible cluster. + +If you are not relying on read-only transactions to reject writes that aren't +supposed to happen, setting this on some MySQL providers (such as AWS Aurora) +is safer for failovers. + +Note that ERROR 1290 can be returned for a `read-only` server and this option will +cause a retry for that error. However the same error number is used for some +other cases. You should ensure your application will never cause an ERROR 1290 +except for `read-only` mode when enabling this option. + + +##### `serverPubKey` + +``` +Type: string +Valid Values: +Default: none +``` + +Server public keys can be registered with [`mysql.RegisterServerPubKey`](https://godoc.org/github.com/go-sql-driver/mysql#RegisterServerPubKey), which can then be used by the assigned name in the DSN. +Public keys are used to transmit encrypted data, e.g. for authentication. +If the server's public key is known, it should be set manually to avoid expensive and potentially insecure transmissions of the public key from the server to the client each time it is required. + + +##### `timeout` + +``` +Type: duration +Default: OS default +``` + +Timeout for establishing connections, aka dial timeout. The value must be a decimal number with a unit suffix (*"ms"*, *"s"*, *"m"*, *"h"*), such as *"30s"*, *"0.5m"* or *"1m30s"*. + + +##### `tls` + +``` +Type: bool / string +Valid Values: true, false, skip-verify, preferred, +Default: false +``` + +`tls=true` enables TLS / SSL encrypted connection to the server. Use `skip-verify` if you want to use a self-signed or invalid certificate (server side) or use `preferred` to use TLS only when advertised by the server. This is similar to `skip-verify`, but additionally allows a fallback to a connection which is not encrypted. Neither `skip-verify` nor `preferred` add any reliable security. You can use a custom TLS config after registering it with [`mysql.RegisterTLSConfig`](https://godoc.org/github.com/go-sql-driver/mysql#RegisterTLSConfig). + + +##### `writeTimeout` + +``` +Type: duration +Default: 0 +``` + +I/O write timeout. The value must be a decimal number with a unit suffix (*"ms"*, *"s"*, *"m"*, *"h"*), such as *"30s"*, *"0.5m"* or *"1m30s"*. + +##### `connectionAttributes` + +``` +Type: comma-delimited string of user-defined "key:value" pairs +Valid Values: (:,:,...) +Default: none +``` + +[Connection attributes](https://dev.mysql.com/doc/refman/8.0/en/performance-schema-connection-attribute-tables.html) are key-value pairs that application programs can pass to the server at connect time. + +##### System Variables + +Any other parameters are interpreted as system variables: + * `=`: `SET =` + * `=`: `SET =` + * `=%27%27`: `SET =''` + +Rules: +* The values for string variables must be quoted with `'`. +* The values must also be [url.QueryEscape](http://golang.org/pkg/net/url/#QueryEscape)'ed! + (which implies values of string variables must be wrapped with `%27`). + +Examples: + * `autocommit=1`: `SET autocommit=1` + * [`time_zone=%27Europe%2FParis%27`](https://dev.mysql.com/doc/refman/5.5/en/time-zone-support.html): `SET time_zone='Europe/Paris'` + * [`transaction_isolation=%27REPEATABLE-READ%27`](https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_transaction_isolation): `SET transaction_isolation='REPEATABLE-READ'` + + +#### Examples +``` +user@unix(/path/to/socket)/dbname +``` + +``` +root:pw@unix(/tmp/mysql.sock)/myDatabase?loc=Local +``` + +``` +user:password@tcp(localhost:5555)/dbname?tls=skip-verify&autocommit=true +``` + +Treat warnings as errors by setting the system variable [`sql_mode`](https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html): +``` +user:password@/dbname?sql_mode=TRADITIONAL +``` + +TCP via IPv6: +``` +user:password@tcp([de:ad:be:ef::ca:fe]:80)/dbname?timeout=90s&collation=utf8mb4_unicode_ci +``` + +TCP on a remote host, e.g. Amazon RDS: +``` +id:password@tcp(your-amazonaws-uri.com:3306)/dbname +``` + +Google Cloud SQL on App Engine: +``` +user:password@unix(/cloudsql/project-id:region-name:instance-name)/dbname +``` + +TCP using default port (3306) on localhost: +``` +user:password@tcp/dbname?charset=utf8mb4,utf8&sys_var=esc%40ped +``` + +Use the default protocol (tcp) and host (localhost:3306): +``` +user:password@/dbname +``` + +No Database preselected: +``` +user:password@/ +``` + + +### Connection pool and timeouts +The connection pool is managed by Go's database/sql package. For details on how to configure the size of the pool and how long connections stay in the pool see `*DB.SetMaxOpenConns`, `*DB.SetMaxIdleConns`, and `*DB.SetConnMaxLifetime` in the [database/sql documentation](https://golang.org/pkg/database/sql/). The read, write, and dial timeouts for each individual connection are configured with the DSN parameters [`readTimeout`](#readtimeout), [`writeTimeout`](#writetimeout), and [`timeout`](#timeout), respectively. + +## `ColumnType` Support +This driver supports the [`ColumnType` interface](https://golang.org/pkg/database/sql/#ColumnType) introduced in Go 1.8, with the exception of [`ColumnType.Length()`](https://golang.org/pkg/database/sql/#ColumnType.Length), which is currently not supported. All Unsigned database type names will be returned `UNSIGNED ` with `INT`, `TINYINT`, `SMALLINT`, `MEDIUMINT`, `BIGINT`. + +## `context.Context` Support +Go 1.8 added `database/sql` support for `context.Context`. This driver supports query timeouts and cancellation via contexts. +See [context support in the database/sql package](https://golang.org/doc/go1.8#database_sql) for more details. + +> [!IMPORTANT] +> The `QueryContext`, `ExecContext`, etc. variants provided by `database/sql` will cause the connection to be closed if the provided context is cancelled or timed out before the result is received by the driver. + + +### `LOAD DATA LOCAL INFILE` support +For this feature you need direct access to the package. Therefore you must change the import path (no `_`): +```go +import "github.com/go-sql-driver/mysql" +``` + +Files must be explicitly allowed by registering them with `mysql.RegisterLocalFile(filepath)` (recommended) or the allowlist check must be deactivated by using the DSN parameter `allowAllFiles=true` ([*Might be insecure!*](https://dev.mysql.com/doc/refman/8.0/en/load-data.html#load-data-local)). + +To use a `io.Reader` a handler function must be registered with `mysql.RegisterReaderHandler(name, handler)` which returns a `io.Reader` or `io.ReadCloser`. The Reader is available with the filepath `Reader::` then. Choose different names for different handlers and `DeregisterReaderHandler` when you don't need it anymore. + +See the [godoc of Go-MySQL-Driver](https://godoc.org/github.com/go-sql-driver/mysql "golang mysql driver documentation") for details. + + +### `time.Time` support +The default internal output type of MySQL `DATE` and `DATETIME` values is `[]byte` which allows you to scan the value into a `[]byte`, `string` or `sql.RawBytes` variable in your program. + +However, many want to scan MySQL `DATE` and `DATETIME` values into `time.Time` variables, which is the logical equivalent in Go to `DATE` and `DATETIME` in MySQL. You can do that by changing the internal output type from `[]byte` to `time.Time` with the DSN parameter `parseTime=true`. You can set the default [`time.Time` location](https://golang.org/pkg/time/#Location) with the `loc` DSN parameter. + +**Caution:** As of Go 1.1, this makes `time.Time` the only variable type you can scan `DATE` and `DATETIME` values into. This breaks for example [`sql.RawBytes` support](https://github.com/go-sql-driver/mysql/wiki/Examples#rawbytes). + + +### Unicode support +Since version 1.5 Go-MySQL-Driver automatically uses the collation ` utf8mb4_general_ci` by default. + +Other charsets / collations can be set using the [`charset`](#charset) or [`collation`](#collation) DSN parameter. + +- When only the `charset` is specified, the `SET NAMES ` query is sent and the server's default collation is used. +- When both the `charset` and `collation` are specified, the `SET NAMES COLLATE ` query is sent. +- When only the `collation` is specified, the collation is specified in the protocol handshake and the `SET NAMES` query is not sent. This can save one roundtrip, but note that the server may ignore the specified collation silently and use the server's default charset/collation instead. + +See http://dev.mysql.com/doc/refman/8.0/en/charset-unicode.html for more details on MySQL's Unicode support. + +## Testing / Development +To run the driver tests you may need to adjust the configuration. See the [Testing Wiki-Page](https://github.com/go-sql-driver/mysql/wiki/Testing "Testing") for details. + +Go-MySQL-Driver is not feature-complete yet. Your help is very appreciated. +If you want to contribute, you can work on an [open issue](https://github.com/go-sql-driver/mysql/issues?state=open) or review a [pull request](https://github.com/go-sql-driver/mysql/pulls). + +See the [Contribution Guidelines](https://github.com/go-sql-driver/mysql/blob/master/.github/CONTRIBUTING.md) for details. + +--------------------------------------- + +## License +Go-MySQL-Driver is licensed under the [Mozilla Public License Version 2.0](https://raw.github.com/go-sql-driver/mysql/master/LICENSE) + +Mozilla summarizes the license scope as follows: +> MPL: The copyleft applies to any files containing MPLed code. + + +That means: + * You can **use** the **unchanged** source code both in private and commercially. + * When distributing, you **must publish** the source code of any **changed files** licensed under the MPL 2.0 under a) the MPL 2.0 itself or b) a compatible license (e.g. GPL 3.0 or Apache License 2.0). + * You **needn't publish** the source code of your library as long as the files licensed under the MPL 2.0 are **unchanged**. + +Please read the [MPL 2.0 FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/) if you have further questions regarding the license. + +You can read the full terms here: [LICENSE](https://raw.github.com/go-sql-driver/mysql/master/LICENSE). + +![Go Gopher and MySQL Dolphin](https://raw.github.com/wiki/go-sql-driver/mysql/go-mysql-driver_m.jpg "Golang Gopher transporting the MySQL Dolphin in a wheelbarrow") diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/auth.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/auth.go new file mode 100644 index 0000000..610044f --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/auth.go @@ -0,0 +1,484 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2018 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/pem" + "fmt" + "sync" + + "filippo.io/edwards25519" +) + +// server pub keys registry +var ( + serverPubKeyLock sync.RWMutex + serverPubKeyRegistry map[string]*rsa.PublicKey +) + +// RegisterServerPubKey registers a server RSA public key which can be used to +// send data in a secure manner to the server without receiving the public key +// in a potentially insecure way from the server first. +// Registered keys can afterwards be used adding serverPubKey= to the DSN. +// +// Note: The provided rsa.PublicKey instance is exclusively owned by the driver +// after registering it and may not be modified. +// +// data, err := os.ReadFile("mykey.pem") +// if err != nil { +// log.Fatal(err) +// } +// +// block, _ := pem.Decode(data) +// if block == nil || block.Type != "PUBLIC KEY" { +// log.Fatal("failed to decode PEM block containing public key") +// } +// +// pub, err := x509.ParsePKIXPublicKey(block.Bytes) +// if err != nil { +// log.Fatal(err) +// } +// +// if rsaPubKey, ok := pub.(*rsa.PublicKey); ok { +// mysql.RegisterServerPubKey("mykey", rsaPubKey) +// } else { +// log.Fatal("not a RSA public key") +// } +func RegisterServerPubKey(name string, pubKey *rsa.PublicKey) { + serverPubKeyLock.Lock() + if serverPubKeyRegistry == nil { + serverPubKeyRegistry = make(map[string]*rsa.PublicKey) + } + + serverPubKeyRegistry[name] = pubKey + serverPubKeyLock.Unlock() +} + +// DeregisterServerPubKey removes the public key registered with the given name. +func DeregisterServerPubKey(name string) { + serverPubKeyLock.Lock() + if serverPubKeyRegistry != nil { + delete(serverPubKeyRegistry, name) + } + serverPubKeyLock.Unlock() +} + +func getServerPubKey(name string) (pubKey *rsa.PublicKey) { + serverPubKeyLock.RLock() + if v, ok := serverPubKeyRegistry[name]; ok { + pubKey = v + } + serverPubKeyLock.RUnlock() + return +} + +// Hash password using pre 4.1 (old password) method +// https://github.com/atcurtis/mariadb/blob/master/mysys/my_rnd.c +type myRnd struct { + seed1, seed2 uint32 +} + +const myRndMaxVal = 0x3FFFFFFF + +// Pseudo random number generator +func newMyRnd(seed1, seed2 uint32) *myRnd { + return &myRnd{ + seed1: seed1 % myRndMaxVal, + seed2: seed2 % myRndMaxVal, + } +} + +// Tested to be equivalent to MariaDB's floating point variant +// http://play.golang.org/p/QHvhd4qved +// http://play.golang.org/p/RG0q4ElWDx +func (r *myRnd) NextByte() byte { + r.seed1 = (r.seed1*3 + r.seed2) % myRndMaxVal + r.seed2 = (r.seed1 + r.seed2 + 33) % myRndMaxVal + + return byte(uint64(r.seed1) * 31 / myRndMaxVal) +} + +// Generate binary hash from byte string using insecure pre 4.1 method +func pwHash(password []byte) (result [2]uint32) { + var add uint32 = 7 + var tmp uint32 + + result[0] = 1345345333 + result[1] = 0x12345671 + + for _, c := range password { + // skip spaces and tabs in password + if c == ' ' || c == '\t' { + continue + } + + tmp = uint32(c) + result[0] ^= (((result[0] & 63) + add) * tmp) + (result[0] << 8) + result[1] += (result[1] << 8) ^ result[0] + add += tmp + } + + // Remove sign bit (1<<31)-1) + result[0] &= 0x7FFFFFFF + result[1] &= 0x7FFFFFFF + + return +} + +// Hash password using insecure pre 4.1 method +func scrambleOldPassword(scramble []byte, password string) []byte { + scramble = scramble[:8] + + hashPw := pwHash([]byte(password)) + hashSc := pwHash(scramble) + + r := newMyRnd(hashPw[0]^hashSc[0], hashPw[1]^hashSc[1]) + + var out [8]byte + for i := range out { + out[i] = r.NextByte() + 64 + } + + mask := r.NextByte() + for i := range out { + out[i] ^= mask + } + + return out[:] +} + +// Hash password using 4.1+ method (SHA1) +func scramblePassword(scramble []byte, password string) []byte { + if len(password) == 0 { + return nil + } + + // stage1Hash = SHA1(password) + crypt := sha1.New() + crypt.Write([]byte(password)) + stage1 := crypt.Sum(nil) + + // scrambleHash = SHA1(scramble + SHA1(stage1Hash)) + // inner Hash + crypt.Reset() + crypt.Write(stage1) + hash := crypt.Sum(nil) + + // outer Hash + crypt.Reset() + crypt.Write(scramble) + crypt.Write(hash) + scramble = crypt.Sum(nil) + + // token = scrambleHash XOR stage1Hash + for i := range scramble { + scramble[i] ^= stage1[i] + } + return scramble +} + +// Hash password using MySQL 8+ method (SHA256) +func scrambleSHA256Password(scramble []byte, password string) []byte { + if len(password) == 0 { + return nil + } + + // XOR(SHA256(password), SHA256(SHA256(SHA256(password)), scramble)) + + crypt := sha256.New() + crypt.Write([]byte(password)) + message1 := crypt.Sum(nil) + + crypt.Reset() + crypt.Write(message1) + message1Hash := crypt.Sum(nil) + + crypt.Reset() + crypt.Write(message1Hash) + crypt.Write(scramble) + message2 := crypt.Sum(nil) + + for i := range message1 { + message1[i] ^= message2[i] + } + + return message1 +} + +func encryptPassword(password string, seed []byte, pub *rsa.PublicKey) ([]byte, error) { + plain := make([]byte, len(password)+1) + copy(plain, password) + for i := range plain { + j := i % len(seed) + plain[i] ^= seed[j] + } + sha1 := sha1.New() + return rsa.EncryptOAEP(sha1, rand.Reader, pub, plain, nil) +} + +// authEd25519 does ed25519 authentication used by MariaDB. +func authEd25519(scramble []byte, password string) ([]byte, error) { + // Derived from https://github.com/MariaDB/server/blob/d8e6bb00888b1f82c031938f4c8ac5d97f6874c3/plugin/auth_ed25519/ref10/sign.c + // Code style is from https://cs.opensource.google/go/go/+/refs/tags/go1.21.5:src/crypto/ed25519/ed25519.go;l=207 + h := sha512.Sum512([]byte(password)) + + s, err := edwards25519.NewScalar().SetBytesWithClamping(h[:32]) + if err != nil { + return nil, err + } + A := (&edwards25519.Point{}).ScalarBaseMult(s) + + mh := sha512.New() + mh.Write(h[32:]) + mh.Write(scramble) + messageDigest := mh.Sum(nil) + r, err := edwards25519.NewScalar().SetUniformBytes(messageDigest) + if err != nil { + return nil, err + } + + R := (&edwards25519.Point{}).ScalarBaseMult(r) + + kh := sha512.New() + kh.Write(R.Bytes()) + kh.Write(A.Bytes()) + kh.Write(scramble) + hramDigest := kh.Sum(nil) + k, err := edwards25519.NewScalar().SetUniformBytes(hramDigest) + if err != nil { + return nil, err + } + + S := k.MultiplyAdd(k, s, r) + + return append(R.Bytes(), S.Bytes()...), nil +} + +func (mc *mysqlConn) sendEncryptedPassword(seed []byte, pub *rsa.PublicKey) error { + enc, err := encryptPassword(mc.cfg.Passwd, seed, pub) + if err != nil { + return err + } + return mc.writeAuthSwitchPacket(enc) +} + +func (mc *mysqlConn) auth(authData []byte, plugin string) ([]byte, error) { + switch plugin { + case "caching_sha2_password": + authResp := scrambleSHA256Password(authData, mc.cfg.Passwd) + return authResp, nil + + case "mysql_old_password": + if !mc.cfg.AllowOldPasswords { + return nil, ErrOldPassword + } + if len(mc.cfg.Passwd) == 0 { + return nil, nil + } + // Note: there are edge cases where this should work but doesn't; + // this is currently "wontfix": + // https://github.com/go-sql-driver/mysql/issues/184 + authResp := append(scrambleOldPassword(authData[:8], mc.cfg.Passwd), 0) + return authResp, nil + + case "mysql_clear_password": + if !mc.cfg.AllowCleartextPasswords { + return nil, ErrCleartextPassword + } + // http://dev.mysql.com/doc/refman/5.7/en/cleartext-authentication-plugin.html + // http://dev.mysql.com/doc/refman/5.7/en/pam-authentication-plugin.html + return append([]byte(mc.cfg.Passwd), 0), nil + + case "mysql_native_password": + if !mc.cfg.AllowNativePasswords { + return nil, ErrNativePassword + } + // https://dev.mysql.com/doc/dev/mysql-server/8.4.5/page_protocol_connection_phase_authentication_methods_native_password_authentication.html + // Native password authentication only need and will need 20-byte challenge. + authResp := scramblePassword(authData[:20], mc.cfg.Passwd) + return authResp, nil + + case "sha256_password": + if len(mc.cfg.Passwd) == 0 { + return []byte{0}, nil + } + // unlike caching_sha2_password, sha256_password does not accept + // cleartext password on unix transport. + if mc.cfg.TLS != nil { + // write cleartext auth packet + return append([]byte(mc.cfg.Passwd), 0), nil + } + + pubKey := mc.cfg.pubKey + if pubKey == nil { + // request public key from server + return []byte{1}, nil + } + + // encrypted password + enc, err := encryptPassword(mc.cfg.Passwd, authData, pubKey) + return enc, err + + case "client_ed25519": + if len(authData) != 32 { + return nil, ErrMalformPkt + } + return authEd25519(authData, mc.cfg.Passwd) + + default: + mc.log("unknown auth plugin:", plugin) + return nil, ErrUnknownPlugin + } +} + +func (mc *mysqlConn) handleAuthResult(oldAuthData []byte, plugin string) error { + // Read Result Packet + authData, newPlugin, err := mc.readAuthResult() + if err != nil { + return err + } + + // handle auth plugin switch, if requested + if newPlugin != "" { + // If CLIENT_PLUGIN_AUTH capability is not supported, no new cipher is + // sent and we have to keep using the cipher sent in the init packet. + if authData == nil { + authData = oldAuthData + } else { + // copy data from read buffer to owned slice + copy(oldAuthData, authData) + } + + plugin = newPlugin + + authResp, err := mc.auth(authData, plugin) + if err != nil { + return err + } + if err = mc.writeAuthSwitchPacket(authResp); err != nil { + return err + } + + // Read Result Packet + authData, newPlugin, err = mc.readAuthResult() + if err != nil { + return err + } + + // Do not allow to change the auth plugin more than once + if newPlugin != "" { + return ErrMalformPkt + } + } + + switch plugin { + + // https://dev.mysql.com/blog-archive/preparing-your-community-connector-for-mysql-8-part-2-sha256/ + case "caching_sha2_password": + switch len(authData) { + case 0: + return nil // auth successful + case 1: + switch authData[0] { + case cachingSha2PasswordFastAuthSuccess: + if err = mc.resultUnchanged().readResultOK(); err == nil { + return nil // auth successful + } + + case cachingSha2PasswordPerformFullAuthentication: + if mc.cfg.TLS != nil || mc.cfg.Net == "unix" { + // write cleartext auth packet + err = mc.writeAuthSwitchPacket(append([]byte(mc.cfg.Passwd), 0)) + if err != nil { + return err + } + } else { + pubKey := mc.cfg.pubKey + if pubKey == nil { + // request public key from server + data, err := mc.buf.takeSmallBuffer(4 + 1) + if err != nil { + return err + } + data[4] = cachingSha2PasswordRequestPublicKey + err = mc.writePacket(data) + if err != nil { + return err + } + + if data, err = mc.readPacket(); err != nil { + return err + } + + if data[0] != iAuthMoreData { + return fmt.Errorf("unexpected resp from server for caching_sha2_password, perform full authentication") + } + + // parse public key + block, rest := pem.Decode(data[1:]) + if block == nil { + return fmt.Errorf("no pem data found, data: %s", rest) + } + pkix, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return err + } + pubKey = pkix.(*rsa.PublicKey) + } + + // send encrypted password + err = mc.sendEncryptedPassword(oldAuthData, pubKey) + if err != nil { + return err + } + } + return mc.resultUnchanged().readResultOK() + + default: + return ErrMalformPkt + } + default: + return ErrMalformPkt + } + + case "sha256_password": + switch len(authData) { + case 0: + return nil // auth successful + default: + block, _ := pem.Decode(authData) + if block == nil { + return fmt.Errorf("no Pem data found, data: %s", authData) + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return err + } + + // send encrypted password + err = mc.sendEncryptedPassword(oldAuthData, pub.(*rsa.PublicKey)) + if err != nil { + return err + } + return mc.resultUnchanged().readResultOK() + } + + default: + return nil // auth successful + } + + return err +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/auth_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/auth_test.go new file mode 100644 index 0000000..46e1e3b --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/auth_test.go @@ -0,0 +1,1381 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2018 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "testing" +) + +var testPubKey = []byte("-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAol0Z8G8U+25Btxk/g/fm\n" + + "UAW/wEKjQCTjkibDE4B+qkuWeiumg6miIRhtilU6m9BFmLQSy1ltYQuu4k17A4tQ\n" + + "rIPpOQYZges/qsDFkZh3wyK5jL5WEFVdOasf6wsfszExnPmcZS4axxoYJfiuilrN\n" + + "hnwinBAqfi3S0sw5MpSI4Zl1AbOrHG4zDI62Gti2PKiMGyYDZTS9xPrBLbN95Kby\n" + + "FFclQLEzA9RJcS1nHFsWtRgHjGPhhjCQxEm9NQ1nePFhCfBfApyfH1VM2VCOQum6\n" + + "Ci9bMuHWjTjckC84mzF99kOxOWVU7mwS6gnJqBzpuz8t3zq8/iQ2y7QrmZV+jTJP\n" + + "WQIDAQAB\n" + + "-----END PUBLIC KEY-----\n") + +var testPubKeyRSA *rsa.PublicKey + +func init() { + block, _ := pem.Decode(testPubKey) + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + panic(err) + } + testPubKeyRSA = pub.(*rsa.PublicKey) +} + +func TestScrambleOldPass(t *testing.T) { + scramble := []byte{9, 8, 7, 6, 5, 4, 3, 2} + vectors := []struct { + pass string + out string + }{ + {" pass", "47575c5a435b4251"}, + {"pass ", "47575c5a435b4251"}, + {"123\t456", "575c47505b5b5559"}, + {"C0mpl!ca ted#PASS123", "5d5d554849584a45"}, + } + for _, tuple := range vectors { + ours := scrambleOldPassword(scramble, tuple.pass) + if tuple.out != fmt.Sprintf("%x", ours) { + t.Errorf("Failed old password %q", tuple.pass) + } + } +} + +func TestScrambleSHA256Pass(t *testing.T) { + scramble := []byte{10, 47, 74, 111, 75, 73, 34, 48, 88, 76, 114, 74, 37, 13, 3, 80, 82, 2, 23, 21} + vectors := []struct { + pass string + out string + }{ + {"secret", "f490e76f66d9d86665ce54d98c78d0acfe2fb0b08b423da807144873d30b312c"}, + {"secret2", "abc3934a012cf342e876071c8ee202de51785b430258a7a0138bc79c4d800bc6"}, + } + for _, tuple := range vectors { + ours := scrambleSHA256Password(scramble, tuple.pass) + if tuple.out != fmt.Sprintf("%x", ours) { + t.Errorf("Failed SHA256 password %q", tuple.pass) + } + } +} + +func TestAuthFastCachingSHA256PasswordCached(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + + authData := []byte{90, 105, 74, 126, 30, 48, 37, 56, 3, 23, 115, 127, 69, + 22, 41, 84, 32, 123, 43, 118} + plugin := "caching_sha2_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{102, 32, 5, 35, 143, 161, 140, 241, 171, 232, 56, + 139, 43, 14, 107, 196, 249, 170, 147, 60, 220, 204, 120, 178, 214, 15, + 184, 150, 26, 61, 57, 235} + if writtenAuthRespLen != 32 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 2, 0, 0, 2, 1, 3, // Fast Auth Success + 7, 0, 0, 3, 0, 0, 0, 2, 0, 0, 0, // OK + } + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } +} + +func TestAuthFastCachingSHA256PasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "" + + authData := []byte{90, 105, 74, 126, 30, 48, 37, 56, 3, 23, 115, 127, 69, + 22, 41, 84, 32, 123, 43, 118} + plugin := "caching_sha2_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + if writtenAuthRespLen != 0 { + t.Fatalf("unexpected written auth response (%d bytes): %v", + writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, // OK + } + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } +} + +func TestAuthFastCachingSHA256PasswordFullRSA(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + + authData := []byte{6, 81, 96, 114, 14, 42, 50, 30, 76, 47, 1, 95, 126, 81, + 62, 94, 83, 80, 52, 85} + plugin := "caching_sha2_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{171, 201, 138, 146, 89, 159, 11, 170, 0, 67, 165, + 49, 175, 94, 218, 68, 177, 109, 110, 86, 34, 33, 44, 190, 67, 240, 70, + 110, 40, 139, 124, 41} + if writtenAuthRespLen != 32 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 2, 0, 0, 2, 1, 4, // Perform Full Authentication + } + conn.queuedReplies = [][]byte{ + // pub key response + append([]byte{byte(1 + len(testPubKey)), 1, 0, 4, 1}, testPubKey...), + + // OK + {7, 0, 0, 6, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 3 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + if !bytes.HasPrefix(conn.written, []byte{1, 0, 0, 3, 2, 0, 1, 0, 5}) { + t.Errorf("unexpected written data: %v", conn.written) + } +} + +func TestAuthFastCachingSHA256PasswordFullRSAWithKey(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + mc.cfg.pubKey = testPubKeyRSA + + authData := []byte{6, 81, 96, 114, 14, 42, 50, 30, 76, 47, 1, 95, 126, 81, + 62, 94, 83, 80, 52, 85} + plugin := "caching_sha2_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{171, 201, 138, 146, 89, 159, 11, 170, 0, 67, 165, + 49, 175, 94, 218, 68, 177, 109, 110, 86, 34, 33, 44, 190, 67, 240, 70, + 110, 40, 139, 124, 41} + if writtenAuthRespLen != 32 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 2, 0, 0, 2, 1, 4, // Perform Full Authentication + } + conn.queuedReplies = [][]byte{ + // OK + {7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 2 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + if !bytes.HasPrefix(conn.written, []byte{0, 1, 0, 3}) { + t.Errorf("unexpected written data: %v", conn.written) + } +} + +func TestAuthFastCachingSHA256PasswordFullSecure(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + + authData := []byte{6, 81, 96, 114, 14, 42, 50, 30, 76, 47, 1, 95, 126, 81, + 62, 94, 83, 80, 52, 85} + plugin := "caching_sha2_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // Hack to make the caching_sha2_password plugin believe that the connection + // is secure + mc.cfg.TLS = &tls.Config{InsecureSkipVerify: true} + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{171, 201, 138, 146, 89, 159, 11, 170, 0, 67, 165, + 49, 175, 94, 218, 68, 177, 109, 110, 86, 34, 33, 44, 190, 67, 240, 70, + 110, 40, 139, 124, 41} + if writtenAuthRespLen != 32 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 2, 0, 0, 2, 1, 4, // Perform Full Authentication + } + conn.queuedReplies = [][]byte{ + // OK + {7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 3 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + if !bytes.Equal(conn.written, []byte{7, 0, 0, 3, 115, 101, 99, 114, 101, 116, 0}) { + t.Errorf("unexpected written data: %v", conn.written) + } +} + +func TestAuthFastCleartextPasswordNotAllowed(t *testing.T) { + _, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + + authData := []byte{70, 114, 92, 94, 1, 38, 11, 116, 63, 114, 23, 101, 126, + 103, 26, 95, 81, 17, 24, 21} + plugin := "mysql_clear_password" + + // Send Client Authentication Packet + _, err := mc.auth(authData, plugin) + if err != ErrCleartextPassword { + t.Errorf("expected ErrCleartextPassword, got %v", err) + } +} + +func TestAuthFastCleartextPassword(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + mc.cfg.AllowCleartextPasswords = true + + authData := []byte{70, 114, 92, 94, 1, 38, 11, 116, 63, 114, 23, 101, 126, + 103, 26, 95, 81, 17, 24, 21} + plugin := "mysql_clear_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{115, 101, 99, 114, 101, 116, 0} + if writtenAuthRespLen != 7 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, // OK + } + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } +} + +func TestAuthFastCleartextPasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "" + mc.cfg.AllowCleartextPasswords = true + + authData := []byte{70, 114, 92, 94, 1, 38, 11, 116, 63, 114, 23, 101, 126, + 103, 26, 95, 81, 17, 24, 21} + plugin := "mysql_clear_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{0} + if writtenAuthRespLen != 1 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, // OK + } + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } +} + +func TestAuthFastNativePasswordNotAllowed(t *testing.T) { + _, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + mc.cfg.AllowNativePasswords = false + + authData := []byte{70, 114, 92, 94, 1, 38, 11, 116, 63, 114, 23, 101, 126, + 103, 26, 95, 81, 17, 24, 21} + plugin := "mysql_native_password" + + // Send Client Authentication Packet + _, err := mc.auth(authData, plugin) + if err != ErrNativePassword { + t.Errorf("expected ErrNativePassword, got %v", err) + } +} + +func TestAuthFastNativePassword(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + + authData := []byte{70, 114, 92, 94, 1, 38, 11, 116, 63, 114, 23, 101, 126, + 103, 26, 95, 81, 17, 24, 21} + plugin := "mysql_native_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{53, 177, 140, 159, 251, 189, 127, 53, 109, 252, + 172, 50, 211, 192, 240, 164, 26, 48, 207, 45} + if writtenAuthRespLen != 20 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, // OK + } + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } +} + +func TestAuthFastNativePasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "" + + authData := []byte{70, 114, 92, 94, 1, 38, 11, 116, 63, 114, 23, 101, 126, + 103, 26, 95, 81, 17, 24, 21} + plugin := "mysql_native_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + if writtenAuthRespLen != 0 { + t.Fatalf("unexpected written auth response (%d bytes): %v", + writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, // OK + } + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } +} + +func TestAuthFastSHA256PasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "" + + authData := []byte{6, 81, 96, 114, 14, 42, 50, 30, 76, 47, 1, 95, 126, 81, + 62, 94, 83, 80, 52, 85} + plugin := "sha256_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{0} + if writtenAuthRespLen != 1 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response (pub key response) + conn.data = append([]byte{byte(1 + len(testPubKey)), 1, 0, 2, 1}, testPubKey...) + conn.queuedReplies = [][]byte{ + // OK + {7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 2 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + if !bytes.HasPrefix(conn.written, []byte{0, 1, 0, 3}) { + t.Errorf("unexpected written data: %v", conn.written) + } +} + +func TestAuthFastSHA256PasswordRSA(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + + authData := []byte{6, 81, 96, 114, 14, 42, 50, 30, 76, 47, 1, 95, 126, 81, + 62, 94, 83, 80, 52, 85} + plugin := "sha256_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{1} + if writtenAuthRespLen != 1 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response (pub key response) + conn.data = append([]byte{byte(1 + len(testPubKey)), 1, 0, 2, 1}, testPubKey...) + conn.queuedReplies = [][]byte{ + // OK + {7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 2 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + if !bytes.HasPrefix(conn.written, []byte{0, 1, 0, 3}) { + t.Errorf("unexpected written data: %v", conn.written) + } +} + +func TestAuthFastSHA256PasswordRSAWithKey(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + mc.cfg.pubKey = testPubKeyRSA + + authData := []byte{6, 81, 96, 114, 14, 42, 50, 30, 76, 47, 1, 95, 126, 81, + 62, 94, 83, 80, 52, 85} + plugin := "sha256_password" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // auth response (OK) + conn.data = []byte{7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0} + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } +} + +func TestAuthFastSHA256PasswordSecure(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "secret" + + // hack to make the caching_sha2_password plugin believe that the connection + // is secure + mc.cfg.TLS = &tls.Config{InsecureSkipVerify: true} + + authData := []byte{6, 81, 96, 114, 14, 42, 50, 30, 76, 47, 1, 95, 126, 81, + 62, 94, 83, 80, 52, 85} + plugin := "sha256_password" + + // send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + + // unset TLS config to prevent the actual establishment of a TLS wrapper + mc.cfg.TLS = nil + + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{115, 101, 99, 114, 101, 116, 0} + if writtenAuthRespLen != 7 || !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("unexpected written auth response (%d bytes): %v", writtenAuthRespLen, writtenAuthResp) + } + conn.written = nil + + // auth response (OK) + conn.data = []byte{7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0} + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + if !bytes.Equal(conn.written, []byte{}) { + t.Errorf("unexpected written data: %v", conn.written) + } +} + +func TestAuthSwitchCachingSHA256PasswordCached(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "secret" + + // auth switch request + conn.data = []byte{44, 0, 0, 2, 254, 99, 97, 99, 104, 105, 110, 103, 95, + 115, 104, 97, 50, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 101, + 11, 26, 18, 94, 97, 22, 72, 2, 46, 70, 106, 29, 55, 45, 94, 76, 90, 84, + 50, 0} + + // auth response + conn.queuedReplies = [][]byte{ + {7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}, // OK + } + conn.maxReads = 3 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{ + // 1. Packet: Hash + 32, 0, 0, 3, 219, 72, 64, 97, 56, 197, 167, 203, 64, 236, 168, 80, 223, + 56, 103, 217, 196, 176, 124, 60, 253, 41, 195, 10, 205, 190, 177, 206, 63, + 118, 211, 69, + } + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchCachingSHA256PasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "" + + // auth switch request + conn.data = []byte{44, 0, 0, 2, 254, 99, 97, 99, 104, 105, 110, 103, 95, + 115, 104, 97, 50, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 101, + 11, 26, 18, 94, 97, 22, 72, 2, 46, 70, 106, 29, 55, 45, 94, 76, 90, 84, + 50, 0} + + // auth response + conn.queuedReplies = [][]byte{{7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{0, 0, 0, 3} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchCachingSHA256PasswordFullRSA(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "secret" + + // auth switch request + conn.data = []byte{44, 0, 0, 2, 254, 99, 97, 99, 104, 105, 110, 103, 95, + 115, 104, 97, 50, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 101, + 11, 26, 18, 94, 97, 22, 72, 2, 46, 70, 106, 29, 55, 45, 94, 76, 90, 84, + 50, 0} + + conn.queuedReplies = [][]byte{ + // Perform Full Authentication + {2, 0, 0, 4, 1, 4}, + + // Pub Key Response + append([]byte{byte(1 + len(testPubKey)), 1, 0, 6, 1}, testPubKey...), + + // OK + {7, 0, 0, 8, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 4 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReplyPrefix := []byte{ + // 1. Packet: Hash + 32, 0, 0, 3, 219, 72, 64, 97, 56, 197, 167, 203, 64, 236, 168, 80, 223, + 56, 103, 217, 196, 176, 124, 60, 253, 41, 195, 10, 205, 190, 177, 206, 63, + 118, 211, 69, + + // 2. Packet: Pub Key Request + 1, 0, 0, 5, 2, + + // 3. Packet: Encrypted Password + 0, 1, 0, 7, // [changing bytes] + } + if !bytes.HasPrefix(conn.written, expectedReplyPrefix) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchCachingSHA256PasswordFullRSAWithKey(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "secret" + mc.cfg.pubKey = testPubKeyRSA + + // auth switch request + conn.data = []byte{44, 0, 0, 2, 254, 99, 97, 99, 104, 105, 110, 103, 95, + 115, 104, 97, 50, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 101, + 11, 26, 18, 94, 97, 22, 72, 2, 46, 70, 106, 29, 55, 45, 94, 76, 90, 84, + 50, 0} + + conn.queuedReplies = [][]byte{ + // Perform Full Authentication + {2, 0, 0, 4, 1, 4}, + + // OK + {7, 0, 0, 6, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 3 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReplyPrefix := []byte{ + // 1. Packet: Hash + 32, 0, 0, 3, 219, 72, 64, 97, 56, 197, 167, 203, 64, 236, 168, 80, 223, + 56, 103, 217, 196, 176, 124, 60, 253, 41, 195, 10, 205, 190, 177, 206, 63, + 118, 211, 69, + + // 2. Packet: Encrypted Password + 0, 1, 0, 5, // [changing bytes] + } + if !bytes.HasPrefix(conn.written, expectedReplyPrefix) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchCachingSHA256PasswordFullSecure(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "secret" + + // Hack to make the caching_sha2_password plugin believe that the connection + // is secure + mc.cfg.TLS = &tls.Config{InsecureSkipVerify: true} + + // auth switch request + conn.data = []byte{44, 0, 0, 2, 254, 99, 97, 99, 104, 105, 110, 103, 95, + 115, 104, 97, 50, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 101, + 11, 26, 18, 94, 97, 22, 72, 2, 46, 70, 106, 29, 55, 45, 94, 76, 90, 84, + 50, 0} + + // auth response + conn.queuedReplies = [][]byte{ + {2, 0, 0, 4, 1, 4}, // Perform Full Authentication + {7, 0, 0, 6, 0, 0, 0, 2, 0, 0, 0}, // OK + } + conn.maxReads = 3 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{ + // 1. Packet: Hash + 32, 0, 0, 3, 219, 72, 64, 97, 56, 197, 167, 203, 64, 236, 168, 80, 223, + 56, 103, 217, 196, 176, 124, 60, 253, 41, 195, 10, 205, 190, 177, 206, 63, + 118, 211, 69, + + // 2. Packet: Cleartext password + 7, 0, 0, 5, 115, 101, 99, 114, 101, 116, 0, + } + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchCleartextPasswordNotAllowed(t *testing.T) { + conn, mc := newRWMockConn(2) + + conn.data = []byte{22, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 99, 108, + 101, 97, 114, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0} + conn.maxReads = 1 + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + err := mc.handleAuthResult(authData, plugin) + if err != ErrCleartextPassword { + t.Errorf("expected ErrCleartextPassword, got %v", err) + } +} + +func TestAuthSwitchCleartextPassword(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowCleartextPasswords = true + mc.cfg.Passwd = "secret" + + // auth switch request + conn.data = []byte{22, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 99, 108, + 101, 97, 114, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0} + + // auth response + conn.queuedReplies = [][]byte{{7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{7, 0, 0, 3, 115, 101, 99, 114, 101, 116, 0} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchCleartextPasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowCleartextPasswords = true + mc.cfg.Passwd = "" + + // auth switch request + conn.data = []byte{22, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 99, 108, + 101, 97, 114, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0} + + // auth response + conn.queuedReplies = [][]byte{{7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{1, 0, 0, 3, 0} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchNativePasswordNotAllowed(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowNativePasswords = false + + conn.data = []byte{44, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 110, 97, + 116, 105, 118, 101, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 96, + 71, 63, 8, 1, 58, 75, 12, 69, 95, 66, 60, 117, 31, 48, 31, 89, 39, 55, + 31, 0} + conn.maxReads = 1 + authData := []byte{96, 71, 63, 8, 1, 58, 75, 12, 69, 95, 66, 60, 117, 31, + 48, 31, 89, 39, 55, 31} + plugin := "caching_sha2_password" + err := mc.handleAuthResult(authData, plugin) + if err != ErrNativePassword { + t.Errorf("expected ErrNativePassword, got %v", err) + } +} + +func TestAuthSwitchNativePassword(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowNativePasswords = true + mc.cfg.Passwd = "secret" + + // auth switch request + conn.data = []byte{44, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 110, 97, + 116, 105, 118, 101, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 96, + 71, 63, 8, 1, 58, 75, 12, 69, 95, 66, 60, 117, 31, 48, 31, 89, 39, 55, + 31, 0} + + // auth response + conn.queuedReplies = [][]byte{{7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{96, 71, 63, 8, 1, 58, 75, 12, 69, 95, 66, 60, 117, 31, + 48, 31, 89, 39, 55, 31} + plugin := "caching_sha2_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{20, 0, 0, 3, 202, 41, 195, 164, 34, 226, 49, 103, + 21, 211, 167, 199, 227, 116, 8, 48, 57, 71, 149, 146} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchNativePasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowNativePasswords = true + mc.cfg.Passwd = "" + + // auth switch request + conn.data = []byte{44, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 110, 97, + 116, 105, 118, 101, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 96, + 71, 63, 8, 1, 58, 75, 12, 69, 95, 66, 60, 117, 31, 48, 31, 89, 39, 55, + 31, 0} + + // auth response + conn.queuedReplies = [][]byte{{7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{96, 71, 63, 8, 1, 58, 75, 12, 69, 95, 66, 60, 117, 31, + 48, 31, 89, 39, 55, 31} + plugin := "caching_sha2_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{0, 0, 0, 3} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchOldPasswordNotAllowed(t *testing.T) { + conn, mc := newRWMockConn(2) + + conn.data = []byte{41, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 111, 108, + 100, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 95, 84, 103, 43, 61, + 49, 123, 61, 91, 50, 40, 113, 35, 84, 96, 101, 92, 123, 121, 107, 0} + conn.maxReads = 1 + authData := []byte{95, 84, 103, 43, 61, 49, 123, 61, 91, 50, 40, 113, 35, + 84, 96, 101, 92, 123, 121, 107} + plugin := "mysql_native_password" + err := mc.handleAuthResult(authData, plugin) + if err != ErrOldPassword { + t.Errorf("expected ErrOldPassword, got %v", err) + } +} + +// Same to TestAuthSwitchOldPasswordNotAllowed, but use OldAuthSwitch request. +func TestOldAuthSwitchNotAllowed(t *testing.T) { + conn, mc := newRWMockConn(2) + + // OldAuthSwitch request + conn.data = []byte{1, 0, 0, 2, 0xfe} + conn.maxReads = 1 + authData := []byte{95, 84, 103, 43, 61, 49, 123, 61, 91, 50, 40, 113, 35, + 84, 96, 101, 92, 123, 121, 107} + plugin := "mysql_native_password" + err := mc.handleAuthResult(authData, plugin) + if err != ErrOldPassword { + t.Errorf("expected ErrOldPassword, got %v", err) + } +} + +func TestAuthSwitchOldPassword(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowOldPasswords = true + mc.cfg.Passwd = "secret" + + // auth switch request + conn.data = []byte{41, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 111, 108, + 100, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 95, 84, 103, 43, 61, + 49, 123, 61, 91, 50, 40, 113, 35, 84, 96, 101, 92, 123, 121, 107, 0} + + // auth response + conn.queuedReplies = [][]byte{{8, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{95, 84, 103, 43, 61, 49, 123, 61, 91, 50, 40, 113, 35, + 84, 96, 101, 92, 123, 121, 107} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{9, 0, 0, 3, 86, 83, 83, 79, 74, 78, 65, 66, 0} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +// Same to TestAuthSwitchOldPassword, but use OldAuthSwitch request. +func TestOldAuthSwitch(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowOldPasswords = true + mc.cfg.Passwd = "secret" + + // OldAuthSwitch request + conn.data = []byte{1, 0, 0, 2, 0xfe} + + // auth response + conn.queuedReplies = [][]byte{{8, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{95, 84, 103, 43, 61, 49, 123, 61, 91, 50, 40, 113, 35, + 84, 96, 101, 92, 123, 121, 107} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{9, 0, 0, 3, 86, 83, 83, 79, 74, 78, 65, 66, 0} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} +func TestAuthSwitchOldPasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowOldPasswords = true + mc.cfg.Passwd = "" + + // auth switch request + conn.data = []byte{41, 0, 0, 2, 254, 109, 121, 115, 113, 108, 95, 111, 108, + 100, 95, 112, 97, 115, 115, 119, 111, 114, 100, 0, 95, 84, 103, 43, 61, + 49, 123, 61, 91, 50, 40, 113, 35, 84, 96, 101, 92, 123, 121, 107, 0} + + // auth response + conn.queuedReplies = [][]byte{{8, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{95, 84, 103, 43, 61, 49, 123, 61, 91, 50, 40, 113, 35, + 84, 96, 101, 92, 123, 121, 107} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{0, 0, 0, 3} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +// Same to TestAuthSwitchOldPasswordEmpty, but use OldAuthSwitch request. +func TestOldAuthSwitchPasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.AllowOldPasswords = true + mc.cfg.Passwd = "" + + // OldAuthSwitch request. + conn.data = []byte{1, 0, 0, 2, 0xfe} + + // auth response + conn.queuedReplies = [][]byte{{8, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0, 0}} + conn.maxReads = 2 + + authData := []byte{95, 84, 103, 43, 61, 49, 123, 61, 91, 50, 40, 113, 35, + 84, 96, 101, 92, 123, 121, 107} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReply := []byte{0, 0, 0, 3} + if !bytes.Equal(conn.written, expectedReply) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchSHA256PasswordEmpty(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "" + + // auth switch request + conn.data = []byte{38, 0, 0, 2, 254, 115, 104, 97, 50, 53, 54, 95, 112, 97, + 115, 115, 119, 111, 114, 100, 0, 78, 82, 62, 40, 100, 1, 59, 31, 44, 69, + 33, 112, 8, 81, 51, 96, 65, 82, 16, 114, 0} + + conn.queuedReplies = [][]byte{ + // OK + {7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 3 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReplyPrefix := []byte{ + // 1. Packet: Empty Password + 1, 0, 0, 3, 0, + } + if !bytes.HasPrefix(conn.written, expectedReplyPrefix) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchSHA256PasswordRSA(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "secret" + + // auth switch request + conn.data = []byte{38, 0, 0, 2, 254, 115, 104, 97, 50, 53, 54, 95, 112, 97, + 115, 115, 119, 111, 114, 100, 0, 78, 82, 62, 40, 100, 1, 59, 31, 44, 69, + 33, 112, 8, 81, 51, 96, 65, 82, 16, 114, 0} + + conn.queuedReplies = [][]byte{ + // Pub Key Response + append([]byte{byte(1 + len(testPubKey)), 1, 0, 4, 1}, testPubKey...), + + // OK + {7, 0, 0, 6, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 3 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReplyPrefix := []byte{ + // 1. Packet: Pub Key Request + 1, 0, 0, 3, 1, + + // 2. Packet: Encrypted Password + 0, 1, 0, 5, // [changing bytes] + } + if !bytes.HasPrefix(conn.written, expectedReplyPrefix) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchSHA256PasswordRSAWithKey(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "secret" + mc.cfg.pubKey = testPubKeyRSA + + // auth switch request + conn.data = []byte{38, 0, 0, 2, 254, 115, 104, 97, 50, 53, 54, 95, 112, 97, + 115, 115, 119, 111, 114, 100, 0, 78, 82, 62, 40, 100, 1, 59, 31, 44, 69, + 33, 112, 8, 81, 51, 96, 65, 82, 16, 114, 0} + + conn.queuedReplies = [][]byte{ + // OK + {7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 2 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReplyPrefix := []byte{ + // 1. Packet: Encrypted Password + 0, 1, 0, 3, // [changing bytes] + } + if !bytes.HasPrefix(conn.written, expectedReplyPrefix) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +func TestAuthSwitchSHA256PasswordSecure(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.Passwd = "secret" + + // Hack to make the caching_sha2_password plugin believe that the connection + // is secure + mc.cfg.TLS = &tls.Config{InsecureSkipVerify: true} + + // auth switch request + conn.data = []byte{38, 0, 0, 2, 254, 115, 104, 97, 50, 53, 54, 95, 112, 97, + 115, 115, 119, 111, 114, 100, 0, 78, 82, 62, 40, 100, 1, 59, 31, 44, 69, + 33, 112, 8, 81, 51, 96, 65, 82, 16, 114, 0} + + conn.queuedReplies = [][]byte{ + // OK + {7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}, + } + conn.maxReads = 2 + + authData := []byte{123, 87, 15, 84, 20, 58, 37, 121, 91, 117, 51, 24, 19, + 47, 43, 9, 41, 112, 67, 110} + plugin := "mysql_native_password" + + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } + + expectedReplyPrefix := []byte{ + // 1. Packet: Cleartext Password + 7, 0, 0, 3, 115, 101, 99, 114, 101, 116, 0, + } + if !bytes.Equal(conn.written, expectedReplyPrefix) { + t.Errorf("got unexpected data: %v", conn.written) + } +} + +// Derived from https://github.com/MariaDB/server/blob/6b2287fff23fbdc362499501c562f01d0d2db52e/plugin/auth_ed25519/ed25519-t.c +func TestEd25519Auth(t *testing.T) { + conn, mc := newRWMockConn(1) + mc.cfg.User = "root" + mc.cfg.Passwd = "foobar" + + authData := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + plugin := "client_ed25519" + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + t.Fatal(err) + } + err = mc.writeHandshakeResponsePacket(authResp, plugin) + if err != nil { + t.Fatal(err) + } + + // check written auth response + authRespStart := 4 + 4 + 4 + 1 + 23 + len(mc.cfg.User) + 1 + authRespEnd := authRespStart + 1 + len(authResp) + writtenAuthRespLen := conn.written[authRespStart] + writtenAuthResp := conn.written[authRespStart+1 : authRespEnd] + expectedAuthResp := []byte{ + 232, 61, 201, 63, 67, 63, 51, 53, 86, 73, 238, 35, 170, 117, 146, + 214, 26, 17, 35, 9, 8, 132, 245, 141, 48, 99, 66, 58, 36, 228, 48, + 84, 115, 254, 187, 168, 88, 162, 249, 57, 35, 85, 79, 238, 167, 106, + 68, 117, 56, 135, 171, 47, 20, 14, 133, 79, 15, 229, 124, 160, 176, + 100, 138, 14, + } + if writtenAuthRespLen != 64 { + t.Fatalf("expected 64 bytes from client, got %d", writtenAuthRespLen) + } + if !bytes.Equal(writtenAuthResp, expectedAuthResp) { + t.Fatalf("auth response did not match expected value:\n%v\n%v", writtenAuthResp, expectedAuthResp) + } + conn.written = nil + + // auth response + conn.data = []byte{ + 7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, // OK + } + conn.maxReads = 1 + + // Handle response to auth packet + if err := mc.handleAuthResult(authData, plugin); err != nil { + t.Errorf("got error: %v", err) + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/benchmark_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/benchmark_test.go new file mode 100644 index 0000000..87844a9 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/benchmark_test.go @@ -0,0 +1,511 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "context" + "database/sql" + "database/sql/driver" + "fmt" + "math" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type TB testing.B + +func (tb *TB) check(err error) { + if err != nil { + tb.Fatal(err) + } +} + +func (tb *TB) checkDB(db *sql.DB, err error) *sql.DB { + tb.check(err) + return db +} + +func (tb *TB) checkRows(rows *sql.Rows, err error) *sql.Rows { + tb.check(err) + return rows +} + +func (tb *TB) checkStmt(stmt *sql.Stmt, err error) *sql.Stmt { + tb.check(err) + return stmt +} + +func initDB(b *testing.B, compress bool, queries ...string) *sql.DB { + tb := (*TB)(b) + comprStr := "" + if compress { + comprStr = "&compress=1" + } + db := tb.checkDB(sql.Open(driverNameTest, dsn+comprStr)) + for _, query := range queries { + if _, err := db.Exec(query); err != nil { + b.Fatalf("error on %q: %v", query, err) + } + } + return db +} + +const concurrencyLevel = 10 + +func BenchmarkQuery(b *testing.B) { + benchmarkQuery(b, false) +} + +func BenchmarkQueryCompressed(b *testing.B) { + benchmarkQuery(b, true) +} + +func benchmarkQuery(b *testing.B, compr bool) { + tb := (*TB)(b) + b.ReportAllocs() + db := initDB(b, compr, + "DROP TABLE IF EXISTS foo", + "CREATE TABLE foo (id INT PRIMARY KEY, val CHAR(50))", + `INSERT INTO foo VALUES (1, "one")`, + `INSERT INTO foo VALUES (2, "two")`, + ) + db.SetMaxIdleConns(concurrencyLevel) + defer db.Close() + + stmt := tb.checkStmt(db.Prepare("SELECT val FROM foo WHERE id=?")) + defer stmt.Close() + + remain := int64(b.N) + var wg sync.WaitGroup + wg.Add(concurrencyLevel) + defer wg.Wait() + b.StartTimer() + + for range concurrencyLevel { + go func() { + for { + if atomic.AddInt64(&remain, -1) < 0 { + wg.Done() + return + } + + var got string + tb.check(stmt.QueryRow(1).Scan(&got)) + if got != "one" { + b.Errorf("query = %q; want one", got) + wg.Done() + return + } + } + }() + } +} + +func BenchmarkExec(b *testing.B) { + tb := (*TB)(b) + db := tb.checkDB(sql.Open(driverNameTest, dsn)) + db.SetMaxIdleConns(concurrencyLevel) + defer db.Close() + + stmt := tb.checkStmt(db.Prepare("DO 1")) + defer stmt.Close() + + remain := int64(b.N) + var wg sync.WaitGroup + wg.Add(concurrencyLevel) + defer wg.Wait() + + b.ReportAllocs() + b.ResetTimer() + + for range concurrencyLevel { + go func() { + for { + if atomic.AddInt64(&remain, -1) < 0 { + wg.Done() + return + } + + if _, err := stmt.Exec(); err != nil { + b.Logf("stmt.Exec failed: %v", err) + b.Fail() + } + } + }() + } +} + +// data, but no db writes +var roundtripSample []byte + +func initRoundtripBenchmarks() ([]byte, int, int) { + if roundtripSample == nil { + roundtripSample = []byte(strings.Repeat("0123456789abcdef", 1024*1024)) + } + return roundtripSample, 16, len(roundtripSample) +} + +func BenchmarkRoundtripTxt(b *testing.B) { + sample, min, max := initRoundtripBenchmarks() + sampleString := string(sample) + tb := (*TB)(b) + db := tb.checkDB(sql.Open(driverNameTest, dsn)) + defer db.Close() + + b.ReportAllocs() + + var result string + for i := 0; b.Loop(); i++ { + length := min + i + if length > max { + length = max + } + test := sampleString[0:length] + rows := tb.checkRows(db.Query(`SELECT "` + test + `"`)) + if !rows.Next() { + rows.Close() + b.Fatalf("crashed") + } + err := rows.Scan(&result) + if err != nil { + rows.Close() + b.Fatalf("crashed") + } + if result != test { + rows.Close() + b.Errorf("mismatch") + } + rows.Close() + } +} + +func BenchmarkRoundtripBin(b *testing.B) { + sample, min, max := initRoundtripBenchmarks() + tb := (*TB)(b) + db := tb.checkDB(sql.Open(driverNameTest, dsn)) + defer db.Close() + stmt := tb.checkStmt(db.Prepare("SELECT ?")) + defer stmt.Close() + + b.ReportAllocs() + + var result sql.RawBytes + for i := 0; b.Loop(); i++ { + length := min + i + if length > max { + length = max + } + test := sample[0:length] + rows := tb.checkRows(stmt.Query(test)) + if !rows.Next() { + rows.Close() + b.Fatalf("crashed") + } + err := rows.Scan(&result) + if err != nil { + rows.Close() + b.Fatalf("crashed") + } + if !bytes.Equal(result, test) { + rows.Close() + b.Errorf("mismatch") + } + rows.Close() + } +} + +func BenchmarkInterpolation(b *testing.B) { + mc := &mysqlConn{ + cfg: &Config{ + InterpolateParams: true, + Loc: time.UTC, + }, + maxAllowedPacket: maxPacketSize, + maxWriteSize: maxPacketSize - 1, + buf: newBuffer(), + } + + args := []driver.Value{ + int64(42424242), + float64(math.Pi), + false, + time.Unix(1423411542, 807015000), + []byte("bytes containing special chars ' \" \a \x00"), + "string containing special chars ' \" \a \x00", + } + q := "SELECT ?, ?, ?, ?, ?, ?" + + b.ReportAllocs() + + for b.Loop() { + _, err := mc.interpolateParams(q, args) + if err != nil { + b.Fatal(err) + } + } +} + +func benchmarkQueryContext(b *testing.B, db *sql.DB, p int) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + db.SetMaxIdleConns(p * runtime.GOMAXPROCS(0)) + + tb := (*TB)(b) + stmt := tb.checkStmt(db.PrepareContext(ctx, "SELECT val FROM foo WHERE id=?")) + defer stmt.Close() + + b.SetParallelism(p) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + var got string + for pb.Next() { + tb.check(stmt.QueryRow(1).Scan(&got)) + if got != "one" { + b.Fatalf("query = %q; want one", got) + } + } + }) +} + +func BenchmarkQueryContext(b *testing.B) { + db := initDB(b, false, + "DROP TABLE IF EXISTS foo", + "CREATE TABLE foo (id INT PRIMARY KEY, val CHAR(50))", + `INSERT INTO foo VALUES (1, "one")`, + `INSERT INTO foo VALUES (2, "two")`, + ) + defer db.Close() + for _, p := range []int{1, 2, 3, 4} { + b.Run(fmt.Sprintf("%d", p), func(b *testing.B) { + benchmarkQueryContext(b, db, p) + }) + } +} + +func benchmarkExecContext(b *testing.B, db *sql.DB, p int) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + db.SetMaxIdleConns(p * runtime.GOMAXPROCS(0)) + + tb := (*TB)(b) + stmt := tb.checkStmt(db.PrepareContext(ctx, "DO 1")) + defer stmt.Close() + + b.SetParallelism(p) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if _, err := stmt.ExecContext(ctx); err != nil { + b.Fatal(err) + } + } + }) +} + +func BenchmarkExecContext(b *testing.B) { + db := initDB(b, false, + "DROP TABLE IF EXISTS foo", + "CREATE TABLE foo (id INT PRIMARY KEY, val CHAR(50))", + `INSERT INTO foo VALUES (1, "one")`, + `INSERT INTO foo VALUES (2, "two")`, + ) + defer db.Close() + for _, p := range []int{1, 2, 3, 4} { + b.Run(fmt.Sprintf("%d", p), func(b *testing.B) { + benchmarkExecContext(b, db, p) + }) + } +} + +// BenchmarkQueryRawBytes benchmarks fetching 100 blobs using sql.RawBytes. +// "size=" means size of each blobs. +func BenchmarkQueryRawBytes(b *testing.B) { + var sizes []int = []int{100, 1000, 2000, 4000, 8000, 12000, 16000, 32000, 64000, 256000} + db := initDB(b, false, + "DROP TABLE IF EXISTS bench_rawbytes", + "CREATE TABLE bench_rawbytes (id INT PRIMARY KEY, val LONGBLOB)", + ) + defer db.Close() + + blob := make([]byte, sizes[len(sizes)-1]) + for i := range blob { + blob[i] = 42 + } + for i := range 100 { + _, err := db.Exec("INSERT INTO bench_rawbytes VALUES (?, ?)", i, blob) + if err != nil { + b.Fatal(err) + } + } + + for _, s := range sizes { + b.Run(fmt.Sprintf("size=%v", s), func(b *testing.B) { + db.SetMaxIdleConns(0) + db.SetMaxIdleConns(1) + b.ReportAllocs() + b.ResetTimer() + + for j := 0; j < b.N; j++ { + rows, err := db.Query("SELECT LEFT(val, ?) as v FROM bench_rawbytes", s) + if err != nil { + b.Fatal(err) + } + nrows := 0 + for rows.Next() { + var buf sql.RawBytes + err := rows.Scan(&buf) + if err != nil { + b.Fatal(err) + } + if len(buf) != s { + b.Fatalf("size mismatch: expected %v, got %v", s, len(buf)) + } + nrows++ + } + rows.Close() + if nrows != 100 { + b.Fatalf("numbers of rows mismatch: expected %v, got %v", 100, nrows) + } + } + }) + } +} + +func benchmark10kRows(b *testing.B, compress bool) { + // Setup -- prepare 10000 rows. + db := initDB(b, compress, + "DROP TABLE IF EXISTS foo", + "CREATE TABLE foo (id INT PRIMARY KEY, val TEXT)") + defer db.Close() + + sval := strings.Repeat("x", 50) + stmt, err := db.Prepare(`INSERT INTO foo (id, val) VALUES (?, ?)` + strings.Repeat(",(?,?)", 99)) + if err != nil { + b.Errorf("failed to prepare query: %v", err) + return + } + + args := make([]any, 200) + for i := 1; i < 200; i += 2 { + args[i] = sval + } + for i := 0; i < 10000; i += 100 { + for j := range 100 { + args[j*2] = i + j + } + _, err := stmt.Exec(args...) + if err != nil { + b.Error(err) + return + } + } + stmt.Close() + + // benchmark function called several times with different b.N. + // it means heavy setup is called multiple times. + // Use b.Run() to run expensive setup only once. + // Go 1.24 introduced b.Loop() for this purpose. But we keep this + // benchmark compatible with Go 1.20. + b.Run("query", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + rows, err := db.Query(`SELECT id, val FROM foo`) + if err != nil { + b.Errorf("failed to select: %v", err) + return + } + // rows.Scan() escapes arguments. So these variables must be defined + // before loop. + var i int + var s sql.RawBytes + for rows.Next() { + if err := rows.Scan(&i, &s); err != nil { + b.Errorf("failed to scan: %v", err) + rows.Close() + return + } + } + if err = rows.Err(); err != nil { + b.Errorf("failed to read rows: %v", err) + } + rows.Close() + } + }) +} + +// BenchmarkReceive10kRows measures performance of receiving large number of rows. +func BenchmarkReceive10kRows(b *testing.B) { + benchmark10kRows(b, false) +} + +func BenchmarkReceive10kRowsCompressed(b *testing.B) { + benchmark10kRows(b, true) +} + +// BenchmarkReceiveMetadata measures performance of receiving lots of metadata compare to data in rows +func BenchmarkReceiveMetadata(b *testing.B) { + tb := (*TB)(b) + + // Create a table with 1000 integer fields + var createTableQuery strings.Builder + createTableQuery.WriteString("CREATE TABLE large_integer_table (") + for i := range 1000 { + createTableQuery.WriteString(fmt.Sprintf("col_%d INT", i)) + if i < 999 { + createTableQuery.WriteString(", ") + } + } + createTableQuery.WriteString(")") + + // Initialize database + db := initDB(b, false, + "DROP TABLE IF EXISTS large_integer_table", + createTableQuery.String(), + "INSERT INTO large_integer_table VALUES ("+ + strings.Repeat("0,", 999)+"0)", // Insert a row of zeros + ) + defer db.Close() + + b.Run("query", func(b *testing.B) { + db.SetMaxIdleConns(0) + db.SetMaxIdleConns(1) + + // Create a slice to scan all columns + values := make([]any, 1000) + valuePtrs := make([]any, 1000) + for j := range values { + valuePtrs[j] = &values[j] + } + + // Prepare a SELECT query to retrieve metadata + stmt := tb.checkStmt(db.Prepare("SELECT * FROM large_integer_table LIMIT 1")) + defer stmt.Close() + + // Benchmark metadata retrieval + b.ReportAllocs() + b.ResetTimer() + for range b.N { + rows := tb.checkRows(stmt.Query()) + + rows.Next() + // Scan the row + err := rows.Scan(valuePtrs...) + tb.check(err) + + rows.Close() + } + }) +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/buffer.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/buffer.go new file mode 100644 index 0000000..f895e87 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/buffer.go @@ -0,0 +1,149 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "io" +) + +const defaultBufSize = 4096 +const maxCachedBufSize = 256 * 1024 + +// readerFunc is a function that compatible with io.Reader. +// We use this function type instead of io.Reader because we want to +// just pass mc.readWithTimeout. +type readerFunc func([]byte) (int, error) + +// A buffer which is used for both reading and writing. +// This is possible since communication on each connection is synchronous. +// In other words, we can't write and read simultaneously on the same connection. +// The buffer is similar to bufio.Reader / Writer but zero-copy-ish +// Also highly optimized for this particular use case. +type buffer struct { + buf []byte // read buffer. + cachedBuf []byte // buffer that will be reused. len(cachedBuf) <= maxCachedBufSize. +} + +// newBuffer allocates and returns a new buffer. +func newBuffer() buffer { + return buffer{ + cachedBuf: make([]byte, defaultBufSize), + } +} + +// busy returns true if the read buffer is not empty. +func (b *buffer) busy() bool { + return len(b.buf) > 0 +} + +// len returns how many bytes in the read buffer. +func (b *buffer) len() int { + return len(b.buf) +} + +// fill reads into the read buffer until at least _need_ bytes are in it. +func (b *buffer) fill(need int, r readerFunc) error { + // we'll move the contents of the current buffer to dest before filling it. + dest := b.cachedBuf + + // grow buffer if necessary to fit the whole packet. + if need > len(dest) { + // Round up to the next multiple of the default size + dest = make([]byte, ((need/defaultBufSize)+1)*defaultBufSize) + + // if the allocated buffer is not too large, move it to backing storage + // to prevent extra allocations on applications that perform large reads + if len(dest) <= maxCachedBufSize { + b.cachedBuf = dest + } + } + + // move the existing data to the start of the buffer. + n := len(b.buf) + copy(dest[:n], b.buf) + + for { + nn, err := r(dest[n:]) + n += nn + + if err == nil && n < need { + continue + } + + b.buf = dest[:n] + + if err == io.EOF { + if n < need { + err = io.ErrUnexpectedEOF + } else { + err = nil + } + } + return err + } +} + +// returns next N bytes from buffer. +// The returned slice is only guaranteed to be valid until the next read +func (b *buffer) readNext(need int) []byte { + data := b.buf[:need:need] + b.buf = b.buf[need:] + return data +} + +// takeBuffer returns a buffer with the requested size. +// If possible, a slice from the existing buffer is returned. +// Otherwise a bigger buffer is made. +// Only one buffer (total) can be used at a time. +func (b *buffer) takeBuffer(length int) ([]byte, error) { + if b.busy() { + return nil, ErrBusyBuffer + } + + // test (cheap) general case first + if length <= len(b.cachedBuf) { + return b.cachedBuf[:length], nil + } + + if length < maxCachedBufSize { + b.cachedBuf = make([]byte, length) + return b.cachedBuf, nil + } + + // buffer is larger than we want to store. + return make([]byte, length), nil +} + +// takeSmallBuffer is shortcut which can be used if length is +// known to be smaller than defaultBufSize. +// Only one buffer (total) can be used at a time. +func (b *buffer) takeSmallBuffer(length int) ([]byte, error) { + if b.busy() { + return nil, ErrBusyBuffer + } + return b.cachedBuf[:length], nil +} + +// takeCompleteBuffer returns the complete existing buffer. +// This can be used if the necessary buffer size is unknown. +// cap and len of the returned buffer will be equal. +// Only one buffer (total) can be used at a time. +func (b *buffer) takeCompleteBuffer() ([]byte, error) { + if b.busy() { + return nil, ErrBusyBuffer + } + return b.cachedBuf, nil +} + +// store stores buf, an updated buffer, if its suitable to do so. +func (b *buffer) store(buf []byte) { + if cap(buf) <= maxCachedBufSize && cap(buf) > cap(b.cachedBuf) { + b.cachedBuf = buf[:cap(buf)] + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/collations.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/collations.go new file mode 100644 index 0000000..29b1aa4 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/collations.go @@ -0,0 +1,266 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2014 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +const defaultCollationID = 45 // utf8mb4_general_ci +const binaryCollationID = 63 + +// A list of available collations mapped to the internal ID. +// To update this map use the following MySQL query: +// +// SELECT COLLATION_NAME, ID FROM information_schema.COLLATIONS WHERE ID<256 ORDER BY ID +// +// Handshake packet have only 1 byte for collation_id. So we can't use collations with ID > 255. +// +// ucs2, utf16, and utf32 can't be used for connection charset. +// https://dev.mysql.com/doc/refman/5.7/en/charset-connection.html#charset-connection-impermissible-client-charset +// They are commented out to reduce this map. +var collations = map[string]byte{ + "big5_chinese_ci": 1, + "latin2_czech_cs": 2, + "dec8_swedish_ci": 3, + "cp850_general_ci": 4, + "latin1_german1_ci": 5, + "hp8_english_ci": 6, + "koi8r_general_ci": 7, + "latin1_swedish_ci": 8, + "latin2_general_ci": 9, + "swe7_swedish_ci": 10, + "ascii_general_ci": 11, + "ujis_japanese_ci": 12, + "sjis_japanese_ci": 13, + "cp1251_bulgarian_ci": 14, + "latin1_danish_ci": 15, + "hebrew_general_ci": 16, + "tis620_thai_ci": 18, + "euckr_korean_ci": 19, + "latin7_estonian_cs": 20, + "latin2_hungarian_ci": 21, + "koi8u_general_ci": 22, + "cp1251_ukrainian_ci": 23, + "gb2312_chinese_ci": 24, + "greek_general_ci": 25, + "cp1250_general_ci": 26, + "latin2_croatian_ci": 27, + "gbk_chinese_ci": 28, + "cp1257_lithuanian_ci": 29, + "latin5_turkish_ci": 30, + "latin1_german2_ci": 31, + "armscii8_general_ci": 32, + "utf8_general_ci": 33, + "cp1250_czech_cs": 34, + //"ucs2_general_ci": 35, + "cp866_general_ci": 36, + "keybcs2_general_ci": 37, + "macce_general_ci": 38, + "macroman_general_ci": 39, + "cp852_general_ci": 40, + "latin7_general_ci": 41, + "latin7_general_cs": 42, + "macce_bin": 43, + "cp1250_croatian_ci": 44, + "utf8mb4_general_ci": 45, + "utf8mb4_bin": 46, + "latin1_bin": 47, + "latin1_general_ci": 48, + "latin1_general_cs": 49, + "cp1251_bin": 50, + "cp1251_general_ci": 51, + "cp1251_general_cs": 52, + "macroman_bin": 53, + //"utf16_general_ci": 54, + //"utf16_bin": 55, + //"utf16le_general_ci": 56, + "cp1256_general_ci": 57, + "cp1257_bin": 58, + "cp1257_general_ci": 59, + //"utf32_general_ci": 60, + //"utf32_bin": 61, + //"utf16le_bin": 62, + "binary": 63, + "armscii8_bin": 64, + "ascii_bin": 65, + "cp1250_bin": 66, + "cp1256_bin": 67, + "cp866_bin": 68, + "dec8_bin": 69, + "greek_bin": 70, + "hebrew_bin": 71, + "hp8_bin": 72, + "keybcs2_bin": 73, + "koi8r_bin": 74, + "koi8u_bin": 75, + "utf8_tolower_ci": 76, + "latin2_bin": 77, + "latin5_bin": 78, + "latin7_bin": 79, + "cp850_bin": 80, + "cp852_bin": 81, + "swe7_bin": 82, + "utf8_bin": 83, + "big5_bin": 84, + "euckr_bin": 85, + "gb2312_bin": 86, + "gbk_bin": 87, + "sjis_bin": 88, + "tis620_bin": 89, + //"ucs2_bin": 90, + "ujis_bin": 91, + "geostd8_general_ci": 92, + "geostd8_bin": 93, + "latin1_spanish_ci": 94, + "cp932_japanese_ci": 95, + "cp932_bin": 96, + "eucjpms_japanese_ci": 97, + "eucjpms_bin": 98, + "cp1250_polish_ci": 99, + //"utf16_unicode_ci": 101, + //"utf16_icelandic_ci": 102, + //"utf16_latvian_ci": 103, + //"utf16_romanian_ci": 104, + //"utf16_slovenian_ci": 105, + //"utf16_polish_ci": 106, + //"utf16_estonian_ci": 107, + //"utf16_spanish_ci": 108, + //"utf16_swedish_ci": 109, + //"utf16_turkish_ci": 110, + //"utf16_czech_ci": 111, + //"utf16_danish_ci": 112, + //"utf16_lithuanian_ci": 113, + //"utf16_slovak_ci": 114, + //"utf16_spanish2_ci": 115, + //"utf16_roman_ci": 116, + //"utf16_persian_ci": 117, + //"utf16_esperanto_ci": 118, + //"utf16_hungarian_ci": 119, + //"utf16_sinhala_ci": 120, + //"utf16_german2_ci": 121, + //"utf16_croatian_ci": 122, + //"utf16_unicode_520_ci": 123, + //"utf16_vietnamese_ci": 124, + //"ucs2_unicode_ci": 128, + //"ucs2_icelandic_ci": 129, + //"ucs2_latvian_ci": 130, + //"ucs2_romanian_ci": 131, + //"ucs2_slovenian_ci": 132, + //"ucs2_polish_ci": 133, + //"ucs2_estonian_ci": 134, + //"ucs2_spanish_ci": 135, + //"ucs2_swedish_ci": 136, + //"ucs2_turkish_ci": 137, + //"ucs2_czech_ci": 138, + //"ucs2_danish_ci": 139, + //"ucs2_lithuanian_ci": 140, + //"ucs2_slovak_ci": 141, + //"ucs2_spanish2_ci": 142, + //"ucs2_roman_ci": 143, + //"ucs2_persian_ci": 144, + //"ucs2_esperanto_ci": 145, + //"ucs2_hungarian_ci": 146, + //"ucs2_sinhala_ci": 147, + //"ucs2_german2_ci": 148, + //"ucs2_croatian_ci": 149, + //"ucs2_unicode_520_ci": 150, + //"ucs2_vietnamese_ci": 151, + //"ucs2_general_mysql500_ci": 159, + //"utf32_unicode_ci": 160, + //"utf32_icelandic_ci": 161, + //"utf32_latvian_ci": 162, + //"utf32_romanian_ci": 163, + //"utf32_slovenian_ci": 164, + //"utf32_polish_ci": 165, + //"utf32_estonian_ci": 166, + //"utf32_spanish_ci": 167, + //"utf32_swedish_ci": 168, + //"utf32_turkish_ci": 169, + //"utf32_czech_ci": 170, + //"utf32_danish_ci": 171, + //"utf32_lithuanian_ci": 172, + //"utf32_slovak_ci": 173, + //"utf32_spanish2_ci": 174, + //"utf32_roman_ci": 175, + //"utf32_persian_ci": 176, + //"utf32_esperanto_ci": 177, + //"utf32_hungarian_ci": 178, + //"utf32_sinhala_ci": 179, + //"utf32_german2_ci": 180, + //"utf32_croatian_ci": 181, + //"utf32_unicode_520_ci": 182, + //"utf32_vietnamese_ci": 183, + "utf8_unicode_ci": 192, + "utf8_icelandic_ci": 193, + "utf8_latvian_ci": 194, + "utf8_romanian_ci": 195, + "utf8_slovenian_ci": 196, + "utf8_polish_ci": 197, + "utf8_estonian_ci": 198, + "utf8_spanish_ci": 199, + "utf8_swedish_ci": 200, + "utf8_turkish_ci": 201, + "utf8_czech_ci": 202, + "utf8_danish_ci": 203, + "utf8_lithuanian_ci": 204, + "utf8_slovak_ci": 205, + "utf8_spanish2_ci": 206, + "utf8_roman_ci": 207, + "utf8_persian_ci": 208, + "utf8_esperanto_ci": 209, + "utf8_hungarian_ci": 210, + "utf8_sinhala_ci": 211, + "utf8_german2_ci": 212, + "utf8_croatian_ci": 213, + "utf8_unicode_520_ci": 214, + "utf8_vietnamese_ci": 215, + "utf8_general_mysql500_ci": 223, + "utf8mb4_unicode_ci": 224, + "utf8mb4_icelandic_ci": 225, + "utf8mb4_latvian_ci": 226, + "utf8mb4_romanian_ci": 227, + "utf8mb4_slovenian_ci": 228, + "utf8mb4_polish_ci": 229, + "utf8mb4_estonian_ci": 230, + "utf8mb4_spanish_ci": 231, + "utf8mb4_swedish_ci": 232, + "utf8mb4_turkish_ci": 233, + "utf8mb4_czech_ci": 234, + "utf8mb4_danish_ci": 235, + "utf8mb4_lithuanian_ci": 236, + "utf8mb4_slovak_ci": 237, + "utf8mb4_spanish2_ci": 238, + "utf8mb4_roman_ci": 239, + "utf8mb4_persian_ci": 240, + "utf8mb4_esperanto_ci": 241, + "utf8mb4_hungarian_ci": 242, + "utf8mb4_sinhala_ci": 243, + "utf8mb4_german2_ci": 244, + "utf8mb4_croatian_ci": 245, + "utf8mb4_unicode_520_ci": 246, + "utf8mb4_vietnamese_ci": 247, + "gb18030_chinese_ci": 248, + "gb18030_bin": 249, + "gb18030_unicode_520_ci": 250, + "utf8mb4_0900_ai_ci": 255, +} + +// A denylist of collations which is unsafe to interpolate parameters. +// These multibyte encodings may contains 0x5c (`\`) in their trailing bytes. +var unsafeCollations = map[string]bool{ + "big5_chinese_ci": true, + "sjis_japanese_ci": true, + "gbk_chinese_ci": true, + "big5_bin": true, + "gb2312_bin": true, + "gbk_bin": true, + "sjis_bin": true, + "cp932_japanese_ci": true, + "cp932_bin": true, + "gb18030_chinese_ci": true, + "gb18030_bin": true, + "gb18030_unicode_520_ci": true, +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/compress.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/compress.go new file mode 100644 index 0000000..38bfa00 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/compress.go @@ -0,0 +1,213 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2024 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "compress/zlib" + "fmt" + "io" + "sync" +) + +var ( + zrPool *sync.Pool // Do not use directly. Use zDecompress() instead. + zwPool *sync.Pool // Do not use directly. Use zCompress() instead. +) + +func init() { + zrPool = &sync.Pool{ + New: func() any { return nil }, + } + zwPool = &sync.Pool{ + New: func() any { + zw, err := zlib.NewWriterLevel(new(bytes.Buffer), 2) + if err != nil { + panic(err) // compress/zlib return non-nil error only if level is invalid + } + return zw + }, + } +} + +func zDecompress(src []byte, dst *bytes.Buffer) (int, error) { + br := bytes.NewReader(src) + var zr io.ReadCloser + var err error + + if a := zrPool.Get(); a == nil { + if zr, err = zlib.NewReader(br); err != nil { + return 0, err + } + } else { + zr = a.(io.ReadCloser) + if err := zr.(zlib.Resetter).Reset(br, nil); err != nil { + return 0, err + } + } + + n, _ := dst.ReadFrom(zr) // ignore err because zr.Close() will return it again. + err = zr.Close() // zr.Close() may return chuecksum error. + zrPool.Put(zr) + return int(n), err +} + +func zCompress(src []byte, dst io.Writer) error { + zw := zwPool.Get().(*zlib.Writer) + zw.Reset(dst) + if _, err := zw.Write(src); err != nil { + return err + } + err := zw.Close() + zwPool.Put(zw) + return err +} + +type compIO struct { + mc *mysqlConn + buff bytes.Buffer +} + +func newCompIO(mc *mysqlConn) *compIO { + return &compIO{ + mc: mc, + } +} + +func (c *compIO) reset() { + c.buff.Reset() +} + +func (c *compIO) readNext(need int) ([]byte, error) { + for c.buff.Len() < need { + if err := c.readCompressedPacket(); err != nil { + return nil, err + } + } + data := c.buff.Next(need) + return data[:need:need], nil // prevent caller writes into c.buff +} + +func (c *compIO) readCompressedPacket() error { + header, err := c.mc.readNext(7) + if err != nil { + return err + } + _ = header[6] // bounds check hint to compiler; guaranteed by readNext + + // compressed header structure + comprLength := getUint24(header[0:3]) + compressionSequence := header[3] + uncompressedLength := getUint24(header[4:7]) + if debug { + fmt.Printf("uncompress cmplen=%v uncomplen=%v pkt_cmp_seq=%v expected_cmp_seq=%v\n", + comprLength, uncompressedLength, compressionSequence, c.mc.sequence) + } + // Do not return ErrPktSync here. + // Server may return error packet (e.g. 1153 Got a packet bigger than 'max_allowed_packet' bytes) + // before receiving all packets from client. In this case, seqnr is younger than expected. + // NOTE: Both of mariadbclient and mysqlclient do not check seqnr. Only server checks it. + if debug && compressionSequence != c.mc.compressSequence { + fmt.Printf("WARN: unexpected cmpress seq nr: expected %v, got %v", + c.mc.compressSequence, compressionSequence) + } + c.mc.compressSequence = compressionSequence + 1 + + comprData, err := c.mc.readNext(comprLength) + if err != nil { + return err + } + + // if payload is uncompressed, its length will be specified as zero, and its + // true length is contained in comprLength + if uncompressedLength == 0 { + c.buff.Write(comprData) + return nil + } + + // use existing capacity in bytesBuf if possible + c.buff.Grow(uncompressedLength) + nread, err := zDecompress(comprData, &c.buff) + if err != nil { + return err + } + if nread != uncompressedLength { + return fmt.Errorf("invalid compressed packet: uncompressed length in header is %d, actual %d", + uncompressedLength, nread) + } + return nil +} + +const minCompressLength = 150 +const maxPayloadLen = maxPacketSize - 4 + +// writePackets sends one or some packets with compression. +// Use this instead of mc.netConn.Write() when mc.compress is true. +func (c *compIO) writePackets(packets []byte) (int, error) { + totalBytes := len(packets) + blankHeader := make([]byte, 7) + buf := &c.buff + + for len(packets) > 0 { + payloadLen := min(maxPayloadLen, len(packets)) + payload := packets[:payloadLen] + uncompressedLen := payloadLen + + buf.Reset() + buf.Write(blankHeader) // Buffer.Write() never returns error + + // If payload is less than minCompressLength, don't compress. + if uncompressedLen < minCompressLength { + buf.Write(payload) + uncompressedLen = 0 + } else { + err := zCompress(payload, buf) + if debug && err != nil { + fmt.Printf("zCompress error: %v", err) + } + // do not compress if compressed data is larger than uncompressed data + // I intentionally miss 7 byte header in the buf; zCompress must compress more than 7 bytes. + if err != nil || buf.Len() >= uncompressedLen { + buf.Reset() + buf.Write(blankHeader) + buf.Write(payload) + uncompressedLen = 0 + } + } + + if n, err := c.writeCompressedPacket(buf.Bytes(), uncompressedLen); err != nil { + // To allow returning ErrBadConn when sending really 0 bytes, we sum + // up compressed bytes that is returned by underlying Write(). + return totalBytes - len(packets) + n, err + } + packets = packets[payloadLen:] + } + + return totalBytes, nil +} + +// writeCompressedPacket writes a compressed packet with header. +// data should start with 7 size space for header followed by payload. +func (c *compIO) writeCompressedPacket(data []byte, uncompressedLen int) (int, error) { + mc := c.mc + comprLength := len(data) - 7 + if debug { + fmt.Printf( + "writeCompressedPacket: comprLength=%v, uncompressedLen=%v, seq=%v\n", + comprLength, uncompressedLen, mc.compressSequence) + } + + // compression header + putUint24(data[0:3], comprLength) + data[3] = mc.compressSequence + putUint24(data[4:7], uncompressedLen) + + mc.compressSequence++ + return mc.writeWithTimeout(data) +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/compress_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/compress_test.go new file mode 100644 index 0000000..030deae --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/compress_test.go @@ -0,0 +1,119 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2024 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "crypto/rand" + "io" + "testing" +) + +func makeRandByteSlice(size int) []byte { + randBytes := make([]byte, size) + rand.Read(randBytes) + return randBytes +} + +// compressHelper compresses uncompressedPacket and checks state variables +func compressHelper(t *testing.T, mc *mysqlConn, uncompressedPacket []byte) []byte { + conn := new(mockConn) + mc.netConn = conn + + err := mc.writePacket(append(make([]byte, 4), uncompressedPacket...)) + if err != nil { + t.Fatal(err) + } + + return conn.written +} + +// uncompressHelper uncompresses compressedPacket and checks state variables +func uncompressHelper(t *testing.T, mc *mysqlConn, compressedPacket []byte) []byte { + // mocking out buf variable + conn := new(mockConn) + conn.data = compressedPacket + mc.netConn = conn + + uncompressedPacket, err := mc.readPacket() + if err != nil { + if err != io.EOF { + t.Fatalf("non-nil/non-EOF error when reading contents: %s", err.Error()) + } + } + return uncompressedPacket +} + +// roundtripHelper compresses then uncompresses uncompressedPacket and checks state variables +func roundtripHelper(t *testing.T, cSend *mysqlConn, cReceive *mysqlConn, uncompressedPacket []byte) []byte { + compressed := compressHelper(t, cSend, uncompressedPacket) + return uncompressHelper(t, cReceive, compressed) +} + +// TestRoundtrip tests two connections, where one is reading and the other is writing +func TestRoundtrip(t *testing.T) { + tests := []struct { + uncompressed []byte + desc string + }{ + {uncompressed: []byte("a"), + desc: "a"}, + {uncompressed: []byte("hello world"), + desc: "hello world"}, + {uncompressed: make([]byte, 100), + desc: "100 bytes"}, + {uncompressed: make([]byte, 32768), + desc: "32768 bytes"}, + {uncompressed: make([]byte, 330000), + desc: "33000 bytes"}, + {uncompressed: makeRandByteSlice(10), + desc: "10 rand bytes", + }, + {uncompressed: makeRandByteSlice(100), + desc: "100 rand bytes", + }, + {uncompressed: makeRandByteSlice(32768), + desc: "32768 rand bytes", + }, + {uncompressed: bytes.Repeat(makeRandByteSlice(100), 10000), + desc: "100 rand * 10000 repeat bytes", + }, + } + + _, cSend := newRWMockConn(0) + cSend.compress = true + cSend.compIO = newCompIO(cSend) + _, cReceive := newRWMockConn(0) + cReceive.compress = true + cReceive.compIO = newCompIO(cReceive) + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + cSend.resetSequence() + cReceive.resetSequence() + + uncompressed := roundtripHelper(t, cSend, cReceive, test.uncompressed) + if len(uncompressed) != len(test.uncompressed) { + t.Errorf("uncompressed size is unexpected. expected %d but got %d", + len(test.uncompressed), len(uncompressed)) + } + if !bytes.Equal(uncompressed, test.uncompressed) { + t.Errorf("roundtrip failed") + } + if cSend.sequence != cReceive.sequence { + t.Errorf("inconsistent sequence number: send=%v recv=%v", + cSend.sequence, cReceive.sequence) + } + if cSend.compressSequence != cReceive.compressSequence { + t.Errorf("inconsistent compress sequence number: send=%v recv=%v", + cSend.compressSequence, cReceive.compressSequence) + } + }) + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck.go new file mode 100644 index 0000000..f9c5cb6 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck.go @@ -0,0 +1,54 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2019 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build linux || darwin || dragonfly || freebsd || netbsd || openbsd || solaris || illumos + +package mysql + +import ( + "errors" + "io" + "net" + "syscall" +) + +var errUnexpectedRead = errors.New("unexpected read from socket") + +func connCheck(conn net.Conn) error { + var sysErr error + + sysConn, ok := conn.(syscall.Conn) + if !ok { + return nil + } + rawConn, err := sysConn.SyscallConn() + if err != nil { + return err + } + + err = rawConn.Read(func(fd uintptr) bool { + var buf [1]byte + n, err := syscall.Read(int(fd), buf[:]) + switch { + case n == 0 && err == nil: + sysErr = io.EOF + case n > 0: + sysErr = errUnexpectedRead + case err == syscall.EAGAIN || err == syscall.EWOULDBLOCK: + sysErr = nil + default: + sysErr = err + } + return true + }) + if err != nil { + return err + } + + return sysErr +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck_dummy.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck_dummy.go new file mode 100644 index 0000000..0ebf05c --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck_dummy.go @@ -0,0 +1,17 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2019 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build !linux && !darwin && !dragonfly && !freebsd && !netbsd && !openbsd && !solaris && !illumos + +package mysql + +import "net" + +func connCheck(conn net.Conn) error { + return nil +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck_test.go new file mode 100644 index 0000000..1c38fad --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/conncheck_test.go @@ -0,0 +1,38 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build linux || darwin || dragonfly || freebsd || netbsd || openbsd || solaris || illumos + +package mysql + +import ( + "testing" + "time" +) + +func TestStaleConnectionChecks(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + dbt.mustExec("SET @@SESSION.wait_timeout = 2") + + if err := dbt.db.Ping(); err != nil { + dbt.Fatal(err) + } + + // wait for MySQL to close our connection + time.Sleep(3 * time.Second) + + tx, err := dbt.db.Begin() + if err != nil { + dbt.Fatal(err) + } + + if err := tx.Rollback(); err != nil { + dbt.Fatal(err) + } + }) +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connection.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connection.go new file mode 100644 index 0000000..65204e2 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connection.go @@ -0,0 +1,816 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "io" + "net" + "runtime" + "strconv" + "strings" + "sync/atomic" + "time" +) + +type mysqlConn struct { + buf buffer + netConn net.Conn + rawConn net.Conn // underlying connection when netConn is TLS connection. + result mysqlResult // managed by clearResult() and handleOkPacket(). + compIO *compIO + cfg *Config + connector *connector + maxAllowedPacket int + maxWriteSize int + capabilities capabilityFlag + extCapabilities extendedCapabilityFlag + status statusFlag + sequence uint8 + compressSequence uint8 + parseTime bool + compress bool + + // for context support (Go 1.8+) + watching bool + watcher chan<- context.Context + closech chan struct{} + finished chan<- struct{} + canceled atomicError // set non-nil if conn is canceled + closed atomic.Bool // set when conn is closed, before closech is closed +} + +// Helper function to call per-connection logger. +func (mc *mysqlConn) log(v ...any) { + _, filename, lineno, ok := runtime.Caller(1) + if ok { + pos := strings.LastIndexByte(filename, '/') + if pos != -1 { + filename = filename[pos+1:] + } + prefix := fmt.Sprintf("%s:%d ", filename, lineno) + v = append([]any{prefix}, v...) + } + + mc.cfg.Logger.Print(v...) +} + +func (mc *mysqlConn) readWithTimeout(b []byte) (int, error) { + to := mc.cfg.ReadTimeout + if to > 0 { + if err := mc.netConn.SetReadDeadline(time.Now().Add(to)); err != nil { + return 0, err + } + } + return mc.netConn.Read(b) +} + +func (mc *mysqlConn) writeWithTimeout(b []byte) (int, error) { + to := mc.cfg.WriteTimeout + if to > 0 { + if err := mc.netConn.SetWriteDeadline(time.Now().Add(to)); err != nil { + return 0, err + } + } + return mc.netConn.Write(b) +} + +func (mc *mysqlConn) resetSequence() { + mc.sequence = 0 + mc.compressSequence = 0 +} + +// syncSequence must be called when finished writing some packet and before start reading. +func (mc *mysqlConn) syncSequence() { + // Syncs compressionSequence to sequence. + // This is not documented but done in `net_flush()` in MySQL and MariaDB. + // https://github.com/mariadb-corporation/mariadb-connector-c/blob/8228164f850b12353da24df1b93a1e53cc5e85e9/libmariadb/ma_net.c#L170-L171 + // https://github.com/mysql/mysql-server/blob/824e2b4064053f7daf17d7f3f84b7a3ed92e5fb4/sql-common/net_serv.cc#L293 + if mc.compress { + mc.sequence = mc.compressSequence + mc.compIO.reset() + } +} + +// Handles parameters set in DSN after the connection is established +func (mc *mysqlConn) handleParams() (err error) { + var cmdSet strings.Builder + + for param, val := range mc.cfg.Params { + if cmdSet.Len() == 0 { + // Heuristic: 29 chars for each other key=value to reduce reallocations + cmdSet.Grow(4 + len(param) + 3 + len(val) + 30*(len(mc.cfg.Params)-1)) + cmdSet.WriteString("SET ") + } else { + cmdSet.WriteString(", ") + } + cmdSet.WriteString(param) + cmdSet.WriteString(" = ") + cmdSet.WriteString(val) + } + + if cmdSet.Len() > 0 { + err = mc.exec(cmdSet.String()) + } + + return +} + +// markBadConn replaces errBadConnNoWrite with driver.ErrBadConn. +// This function is used to return driver.ErrBadConn only when safe to retry. +func (mc *mysqlConn) markBadConn(err error) error { + if err == errBadConnNoWrite { + return driver.ErrBadConn + } + return err +} + +func (mc *mysqlConn) Begin() (driver.Tx, error) { + return mc.begin(false) +} + +func (mc *mysqlConn) begin(readOnly bool) (driver.Tx, error) { + if mc.closed.Load() { + return nil, driver.ErrBadConn + } + var q string + if readOnly { + q = "START TRANSACTION READ ONLY" + } else { + q = "START TRANSACTION" + } + err := mc.exec(q) + if err == nil { + return &mysqlTx{mc}, err + } + return nil, mc.markBadConn(err) +} + +func (mc *mysqlConn) Close() (err error) { + // Makes Close idempotent + if !mc.closed.Load() { + err = mc.writeCommandPacket(comQuit) + } + mc.close() + return +} + +// close closes the network connection and clear results without sending COM_QUIT. +func (mc *mysqlConn) close() { + mc.cleanup() + mc.clearResult() +} + +// Closes the network connection and unsets internal variables. Do not call this +// function after successful authentication, call Close instead. This function +// is called before auth or on auth failure because MySQL will have already +// closed the network connection. +func (mc *mysqlConn) cleanup() { + if mc.closed.Swap(true) { + return + } + + // Makes cleanup idempotent + close(mc.closech) + conn := mc.rawConn + if conn == nil { + return + } + if err := conn.Close(); err != nil { + mc.log("closing connection:", err) + } + // This function can be called from multiple goroutines. + // So we can not mc.clearResult() here. + // Caller should do it if they are in safe goroutine. +} + +func (mc *mysqlConn) error() error { + if mc.closed.Load() { + if err := mc.canceled.Value(); err != nil { + return err + } + return ErrInvalidConn + } + return nil +} + +func (mc *mysqlConn) Prepare(query string) (driver.Stmt, error) { + if mc.closed.Load() { + return nil, driver.ErrBadConn + } + // Send command + err := mc.writeCommandPacketStr(comStmtPrepare, query) + if err != nil { + // STMT_PREPARE is safe to retry. So we can return ErrBadConn here. + mc.log(err) + return nil, driver.ErrBadConn + } + + stmt := &mysqlStmt{ + mc: mc, + } + + // Read Result + columnCount, err := stmt.readPrepareResultPacket() + if err == nil { + if stmt.paramCount > 0 { + if err = mc.skipColumns(stmt.paramCount); err != nil { + return nil, err + } + } + + if columnCount > 0 { + if mc.extCapabilities&clientCacheMetadata != 0 { + if stmt.columns, err = mc.readColumns(int(columnCount), nil); err != nil { + return nil, err + } + } else { + if err = mc.skipColumns(int(columnCount)); err != nil { + return nil, err + } + } + } + } + + return stmt, err +} + +func (mc *mysqlConn) interpolateParams(query string, args []driver.Value) (string, error) { + noBackslashEscapes := (mc.status & statusNoBackslashEscapes) != 0 + const ( + stateNormal = iota + stateString + stateEscape + stateEOLComment + stateSlashStarComment + stateBacktick + ) + + const ( + QUOTE_BYTE = byte('\'') + DBL_QUOTE_BYTE = byte('"') + BACKSLASH_BYTE = byte('\\') + QUESTION_MARK_BYTE = byte('?') + SLASH_BYTE = byte('/') + STAR_BYTE = byte('*') + HASH_BYTE = byte('#') + MINUS_BYTE = byte('-') + LINE_FEED_BYTE = byte('\n') + BACKTICK_BYTE = byte('`') + ) + + buf, err := mc.buf.takeCompleteBuffer() + if err != nil { + mc.cleanup() + return "", driver.ErrBadConn + } + buf = buf[:0] + state := stateNormal + singleQuotes := false + lastChar := byte(0) + argPos := 0 + lenQuery := len(query) + lastIdx := 0 + + for i := range lenQuery { + currentChar := query[i] + if state == stateEscape && !((currentChar == QUOTE_BYTE && singleQuotes) || (currentChar == DBL_QUOTE_BYTE && !singleQuotes)) { + state = stateString + lastChar = currentChar + continue + } + switch currentChar { + case STAR_BYTE: + if state == stateNormal && lastChar == SLASH_BYTE { + state = stateSlashStarComment + } + case SLASH_BYTE: + if state == stateSlashStarComment && lastChar == STAR_BYTE { + state = stateNormal + // Clear lastChar so the '/' that closed the comment isn't + // reused to start a new comment with a following '*'. + lastChar = 0 + continue + } + case HASH_BYTE: + if state == stateNormal { + state = stateEOLComment + } + case MINUS_BYTE: + if state == stateNormal && lastChar == MINUS_BYTE { + // -- only starts a comment if followed by whitespace or control char + if i+1 < lenQuery { + nextChar := query[i+1] + if nextChar == ' ' || nextChar == '\t' || nextChar == '\n' || nextChar == '\r' { + state = stateEOLComment + } + } else { + state = stateEOLComment + } + } + case LINE_FEED_BYTE: + if state == stateEOLComment { + state = stateNormal + } + case DBL_QUOTE_BYTE: + if state == stateNormal { + state = stateString + singleQuotes = false + } else if state == stateString && !singleQuotes { + state = stateNormal + } else if state == stateEscape { + state = stateString + } + case QUOTE_BYTE: + if state == stateNormal { + state = stateString + singleQuotes = true + } else if state == stateString && singleQuotes { + state = stateNormal + } else if state == stateEscape { + state = stateString + } + case BACKSLASH_BYTE: + if state == stateString && !noBackslashEscapes { + state = stateEscape + } + case QUESTION_MARK_BYTE: + if state == stateNormal { + if argPos >= len(args) { + return "", driver.ErrSkip + } + buf = append(buf, query[lastIdx:i]...) + arg := args[argPos] + argPos++ + + if arg == nil { + buf = append(buf, "NULL"...) + lastIdx = i + 1 + break + } + + switch v := arg.(type) { + case int64: + buf = strconv.AppendInt(buf, v, 10) + case uint64: + buf = strconv.AppendUint(buf, v, 10) + case float64: + buf = strconv.AppendFloat(buf, v, 'g', -1, 64) + case bool: + if v { + buf = append(buf, '1') + } else { + buf = append(buf, '0') + } + case time.Time: + if v.IsZero() { + buf = append(buf, "'0000-00-00'"...) + } else { + buf = append(buf, '\'') + buf, err = appendDateTime(buf, v.In(mc.cfg.Loc), mc.cfg.timeTruncate) + if err != nil { + return "", err + } + buf = append(buf, '\'') + } + case json.RawMessage: + if noBackslashEscapes { + buf = escapeBytesQuotes(buf, v, false) + } else { + buf = escapeBytesBackslash(buf, v, false) + } + case []byte: + if v == nil { + buf = append(buf, "NULL"...) + } else { + if noBackslashEscapes { + buf = escapeBytesQuotes(buf, v, true) + } else { + buf = escapeBytesBackslash(buf, v, true) + } + } + case string: + if noBackslashEscapes { + buf = escapeStringQuotes(buf, v) + } else { + buf = escapeStringBackslash(buf, v) + } + default: + return "", driver.ErrSkip + } + + if len(buf)+4 > mc.maxAllowedPacket { + return "", driver.ErrSkip + } + lastIdx = i + 1 + } + case BACKTICK_BYTE: + if state == stateBacktick { + state = stateNormal + } else if state == stateNormal { + state = stateBacktick + } + } + lastChar = currentChar + } + buf = append(buf, query[lastIdx:]...) + if argPos != len(args) { + return "", driver.ErrSkip + } + return string(buf), nil +} + +func (mc *mysqlConn) Exec(query string, args []driver.Value) (driver.Result, error) { + if mc.closed.Load() { + return nil, driver.ErrBadConn + } + if len(args) != 0 { + if !mc.cfg.InterpolateParams { + return nil, driver.ErrSkip + } + // try to interpolate the parameters to save extra roundtrips for preparing and closing a statement + prepared, err := mc.interpolateParams(query, args) + if err != nil { + return nil, err + } + query = prepared + } + + err := mc.exec(query) + if err == nil { + copied := mc.result + return &copied, err + } + return nil, mc.markBadConn(err) +} + +// Internal function to execute commands +func (mc *mysqlConn) exec(query string) error { + handleOk := mc.clearResult() + // Send command + if err := mc.writeCommandPacketStr(comQuery, query); err != nil { + return mc.markBadConn(err) + } + + // Read Result + resLen, _, err := handleOk.readResultSetHeaderPacket() + if err != nil { + return err + } + + if resLen > 0 { + // columns + if err := mc.skipColumns(resLen); err != nil { + return err + } + + // rows + if err := mc.skipRows(); err != nil { + return err + } + } + + return handleOk.discardResults() +} + +func (mc *mysqlConn) Query(query string, args []driver.Value) (driver.Rows, error) { + return mc.query(query, args) +} + +func (mc *mysqlConn) query(query string, args []driver.Value) (*textRows, error) { + handleOk := mc.clearResult() + + if mc.closed.Load() { + return nil, driver.ErrBadConn + } + if len(args) != 0 { + if !mc.cfg.InterpolateParams { + return nil, driver.ErrSkip + } + // try client-side prepare to reduce roundtrip + prepared, err := mc.interpolateParams(query, args) + if err != nil { + return nil, err + } + query = prepared + } + // Send command + err := mc.writeCommandPacketStr(comQuery, query) + if err != nil { + return nil, mc.markBadConn(err) + } + + // Read Result + var resLen int + resLen, _, err = handleOk.readResultSetHeaderPacket() + if err != nil { + return nil, err + } + + rows := new(textRows) + rows.mc = mc + + if resLen == 0 { + rows.rs.done = true + + switch err := rows.NextResultSet(); err { + case nil, io.EOF: + return rows, nil + default: + return nil, err + } + } + + // Columns + rows.rs.columns, err = mc.readColumns(resLen, nil) + return rows, err +} + +// Gets the value of the given MySQL System Variable +func (mc *mysqlConn) getSystemVar(name string) (string, error) { + // Send command + handleOk := mc.clearResult() + if err := mc.writeCommandPacketStr(comQuery, "SELECT @@"+name); err != nil { + return "", err + } + + // Read Result + resLen, _, err := handleOk.readResultSetHeaderPacket() + if err == nil { + rows := new(textRows) + rows.mc = mc + rows.rs.columns = []mysqlField{{fieldType: fieldTypeVarChar}} + + if resLen > 0 { + // Columns + if err := mc.skipColumns(resLen); err != nil { + return "", err + } + } + + dest := make([]driver.Value, resLen) + if err = rows.readRow(dest); err == nil { + // Convert to string before skipRows, which may + // overwrite the read buffer that dest[0] points into. + val := string(dest[0].([]byte)) + return val, mc.skipRows() + } + } + return "", err +} + +// cancel is called when the query has canceled. +func (mc *mysqlConn) cancel(err error) { + mc.canceled.Set(err) + mc.cleanup() +} + +// finish is called when the query has succeeded. +func (mc *mysqlConn) finish() { + if !mc.watching || mc.finished == nil { + return + } + select { + case mc.finished <- struct{}{}: + mc.watching = false + case <-mc.closech: + } +} + +// Ping implements driver.Pinger interface +func (mc *mysqlConn) Ping(ctx context.Context) (err error) { + if mc.closed.Load() { + return driver.ErrBadConn + } + + if err = mc.watchCancel(ctx); err != nil { + return + } + defer mc.finish() + + handleOk := mc.clearResult() + if err = mc.writeCommandPacket(comPing); err != nil { + return mc.markBadConn(err) + } + + return handleOk.readResultOK() +} + +// BeginTx implements driver.ConnBeginTx interface +func (mc *mysqlConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { + if mc.closed.Load() { + return nil, driver.ErrBadConn + } + + if err := mc.watchCancel(ctx); err != nil { + return nil, err + } + defer mc.finish() + + if sql.IsolationLevel(opts.Isolation) != sql.LevelDefault { + level, err := mapIsolationLevel(opts.Isolation) + if err != nil { + return nil, err + } + err = mc.exec("SET TRANSACTION ISOLATION LEVEL " + level) + if err != nil { + return nil, err + } + } + + return mc.begin(opts.ReadOnly) +} + +func (mc *mysqlConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + dargs, err := namedValueToValue(args) + if err != nil { + return nil, err + } + + if err := mc.watchCancel(ctx); err != nil { + return nil, err + } + + rows, err := mc.query(query, dargs) + if err != nil { + mc.finish() + return nil, err + } + rows.finish = mc.finish + return rows, err +} + +func (mc *mysqlConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + dargs, err := namedValueToValue(args) + if err != nil { + return nil, err + } + + if err := mc.watchCancel(ctx); err != nil { + return nil, err + } + defer mc.finish() + + return mc.Exec(query, dargs) +} + +func (mc *mysqlConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) { + if err := mc.watchCancel(ctx); err != nil { + return nil, err + } + + stmt, err := mc.Prepare(query) + mc.finish() + if err != nil { + return nil, err + } + + select { + default: + case <-ctx.Done(): + stmt.Close() + return nil, ctx.Err() + } + return stmt, nil +} + +func (stmt *mysqlStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) { + dargs, err := namedValueToValue(args) + if err != nil { + return nil, err + } + + if err := stmt.mc.watchCancel(ctx); err != nil { + return nil, err + } + + rows, err := stmt.query(dargs) + if err != nil { + stmt.mc.finish() + return nil, err + } + rows.finish = stmt.mc.finish + return rows, err +} + +func (stmt *mysqlStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) { + dargs, err := namedValueToValue(args) + if err != nil { + return nil, err + } + + if err := stmt.mc.watchCancel(ctx); err != nil { + return nil, err + } + defer stmt.mc.finish() + + return stmt.Exec(dargs) +} + +func (mc *mysqlConn) watchCancel(ctx context.Context) error { + if mc.watching { + // Reach here if canceled, + // so the connection is already invalid + mc.cleanup() + return nil + } + // When ctx is already cancelled, don't watch it. + if err := ctx.Err(); err != nil { + return err + } + // When ctx is not cancellable, don't watch it. + if ctx.Done() == nil { + return nil + } + // When watcher is not alive, can't watch it. + if mc.watcher == nil { + return nil + } + + mc.watching = true + mc.watcher <- ctx + return nil +} + +func (mc *mysqlConn) startWatcher() { + watcher := make(chan context.Context, 1) + mc.watcher = watcher + finished := make(chan struct{}) + mc.finished = finished + go func() { + for { + var ctx context.Context + select { + case ctx = <-watcher: + case <-mc.closech: + return + } + + select { + case <-ctx.Done(): + mc.cancel(ctx.Err()) + case <-finished: + case <-mc.closech: + return + } + } + }() +} + +func (mc *mysqlConn) CheckNamedValue(nv *driver.NamedValue) (err error) { + nv.Value, err = converter{}.ConvertValue(nv.Value) + return +} + +// ResetSession implements driver.SessionResetter. +// (From Go 1.10) +func (mc *mysqlConn) ResetSession(ctx context.Context) error { + if mc.closed.Load() || mc.buf.busy() { + return driver.ErrBadConn + } + + // Perform a stale connection check. We only perform this check for + // the first query on a connection that has been checked out of the + // connection pool: a fresh connection from the pool is more likely + // to be stale, and it has not performed any previous writes that + // could cause data corruption, so it's safe to return ErrBadConn + // if the check fails. + if mc.cfg.CheckConnLiveness { + conn := mc.netConn + if mc.rawConn != nil { + conn = mc.rawConn + } + var err error + if mc.cfg.ReadTimeout != 0 { + err = conn.SetReadDeadline(time.Now().Add(mc.cfg.ReadTimeout)) + } + if err == nil { + err = connCheck(conn) + } + if err != nil { + mc.log("closing bad idle connection: ", err) + return driver.ErrBadConn + } + } + + return nil +} + +// IsValid implements driver.Validator interface +// (From Go 1.15) +func (mc *mysqlConn) IsValid() bool { + return !mc.closed.Load() && !mc.buf.busy() +} + +var _ driver.SessionResetter = &mysqlConn{} +var _ driver.Validator = &mysqlConn{} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connection_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connection_test.go new file mode 100644 index 0000000..2827fd0 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connection_test.go @@ -0,0 +1,362 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2016 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "net" + "testing" + "time" +) + +func TestInterpolateParams(t *testing.T) { + mc := &mysqlConn{ + buf: newBuffer(), + maxAllowedPacket: maxPacketSize, + cfg: &Config{ + InterpolateParams: true, + }, + } + + q, err := mc.interpolateParams("SELECT ?+?", []driver.Value{int64(42), "gopher"}) + if err != nil { + t.Errorf("Expected err=nil, got %#v", err) + return + } + expected := `SELECT 42+'gopher'` + if q != expected { + t.Errorf("Expected: %q\nGot: %q", expected, q) + } +} + +func TestInterpolateParamsJSONRawMessage(t *testing.T) { + mc := &mysqlConn{ + buf: newBuffer(), + maxAllowedPacket: maxPacketSize, + cfg: &Config{ + InterpolateParams: true, + }, + } + + buf, err := json.Marshal(struct { + Value int `json:"value"` + }{Value: 42}) + if err != nil { + t.Errorf("Expected err=nil, got %#v", err) + return + } + q, err := mc.interpolateParams("SELECT ?", []driver.Value{json.RawMessage(buf)}) + if err != nil { + t.Errorf("Expected err=nil, got %#v", err) + return + } + expected := `SELECT '{\"value\":42}'` + if q != expected { + t.Errorf("Expected: %q\nGot: %q", expected, q) + } +} + +func TestInterpolateParamsTooManyPlaceholders(t *testing.T) { + mc := &mysqlConn{ + buf: newBuffer(), + maxAllowedPacket: maxPacketSize, + cfg: &Config{ + InterpolateParams: true, + }, + } + + q, err := mc.interpolateParams("SELECT ?+?", []driver.Value{int64(42)}) + if err != driver.ErrSkip { + t.Errorf("Expected err=driver.ErrSkip, got err=%#v, q=%#v", err, q) + } +} + +func TestInterpolateParamsUint64(t *testing.T) { + mc := &mysqlConn{ + buf: newBuffer(), + maxAllowedPacket: maxPacketSize, + cfg: &Config{ + InterpolateParams: true, + }, + } + + q, err := mc.interpolateParams("SELECT ?", []driver.Value{uint64(42)}) + if err != nil { + t.Errorf("Expected err=nil, got err=%#v, q=%#v", err, q) + } + if q != "SELECT 42" { + t.Errorf("Expected uint64 interpolation to work, got q=%#v", q) + } +} + +func TestCheckNamedValue(t *testing.T) { + value := driver.NamedValue{Value: ^uint64(0)} + mc := &mysqlConn{} + err := mc.CheckNamedValue(&value) + + if err != nil { + t.Fatal("uint64 high-bit not convertible", err) + } + + if value.Value != ^uint64(0) { + t.Fatalf("uint64 high-bit converted, got %#v %T", value.Value, value.Value) + } +} + +// TestCleanCancel tests passed context is cancelled at start. +// No packet should be sent. Connection should keep current status. +func TestCleanCancel(t *testing.T) { + mc := &mysqlConn{ + closech: make(chan struct{}), + } + mc.startWatcher() + defer mc.cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + for range 3 { // Repeat same behavior + err := mc.Ping(ctx) + if err != context.Canceled { + t.Errorf("expected context.Canceled, got %#v", err) + } + + if mc.closed.Load() { + t.Error("expected mc is not closed, closed actually") + } + + if mc.watching { + t.Error("expected watching is false, but true") + } + } +} + +func TestPingMarkBadConnection(t *testing.T) { + nc := badConnection{err: errors.New("boom")} + mc := &mysqlConn{ + netConn: nc, + buf: newBuffer(), + maxAllowedPacket: defaultMaxAllowedPacket, + closech: make(chan struct{}), + cfg: NewConfig(), + } + + err := mc.Ping(context.Background()) + + if err != driver.ErrBadConn { + t.Errorf("expected driver.ErrBadConn, got %#v", err) + } +} + +func TestPingErrInvalidConn(t *testing.T) { + nc := badConnection{err: errors.New("failed to write"), n: 10} + mc := &mysqlConn{ + netConn: nc, + buf: newBuffer(), + maxAllowedPacket: defaultMaxAllowedPacket, + closech: make(chan struct{}), + cfg: NewConfig(), + } + + err := mc.Ping(context.Background()) + + if err != nc.err { + t.Errorf("expected %#v, got %#v", nc.err, err) + } +} + +type badConnection struct { + n int + err error + net.Conn +} + +func (bc badConnection) Write(b []byte) (n int, err error) { + return bc.n, bc.err +} + +func (bc badConnection) Close() error { + return nil +} + +func TestInterpolateParamsWithComments(t *testing.T) { + mc := &mysqlConn{ + buf: newBuffer(), + maxAllowedPacket: maxPacketSize, + cfg: &Config{ + InterpolateParams: true, + }, + } + + tests := []struct { + query string + args []driver.Value + expected string + shouldSkip bool + }{ + // ? in single-line comment (--) should not be replaced + {"SELECT 1 -- ?\n, ?", []driver.Value{int64(42)}, "SELECT 1 -- ?\n, 42", false}, + // ? in single-line comment (#) should not be replaced + {"SELECT 1 # ?\n, ?", []driver.Value{int64(42)}, "SELECT 1 # ?\n, 42", false}, + // ? in multi-line comment should not be replaced + {"SELECT /* ? */ ?", []driver.Value{int64(42)}, "SELECT /* ? */ 42", false}, + // ? in string literal should not be replaced + {"SELECT '?', ?", []driver.Value{int64(42)}, "SELECT '?', 42", false}, + // ? in backtick identifier should not be replaced + {"SELECT `?`, ?", []driver.Value{int64(42)}, "SELECT `?`, 42", false}, + // ? in backslash-escaped string literal should not be replaced + {"SELECT 'C:\\path\\?x.txt', ?", []driver.Value{int64(42)}, "SELECT 'C:\\path\\?x.txt', 42", false}, + // ? in backslash-escaped string literal should not be replaced + {"SELECT '\\'?', col FROM tbl WHERE id = ? AND desc = 'foo\\'bar?'", []driver.Value{int64(42)}, "SELECT '\\'?', col FROM tbl WHERE id = 42 AND desc = 'foo\\'bar?'", false}, + // Multiple comments and real placeholders + {"SELECT ? -- comment ?\n, ? /* ? */ , ? # ?\n, ?", []driver.Value{int64(1), int64(2), int64(3)}, "SELECT 1 -- comment ?\n, 2 /* ? */ , 3 # ?\n, ?", true}, + // 2--1: -- followed by digit is NOT a comment (it's the number 2 minus minus 1) + {"SELECT ?--1", []driver.Value{int64(2)}, "SELECT 2--1", false}, + // /* */*: After closing block comment, */* should NOT start a new comment + {"SELECT /* comment */* ?, ?", []driver.Value{int64(1), int64(2)}, "SELECT /* comment */* 1, 2", false}, + // /* */*: More complex case with actual comment after + {"SELECT /* c1 */*/* c2 */ ?, ?", []driver.Value{int64(1), int64(2)}, "SELECT /* c1 */*/* c2 */ 1, 2", false}, + } + + for i, test := range tests { + + q, err := mc.interpolateParams(test.query, test.args) + if test.shouldSkip { + if err != driver.ErrSkip { + t.Errorf("Test %d: Expected driver.ErrSkip, got err=%#v, q=%#v", i, err, q) + } + continue + } + if err != nil { + t.Errorf("Test %d: Expected err=nil, got %#v", i, err) + continue + } + if q != test.expected { + t.Errorf("Test %d: Expected: %q\nGot: %q", i, test.expected, q) + } + } +} + +// chunkedConn is a net.Conn that serves pre-built data chunks, one per Read +// call. This simulates the behavior seen with TLS connections, where the +// server's TLS library typically produces a separate TLS record per write +// and Go's crypto/tls.Read returns one record at a time. +type chunkedConn struct { + chunks [][]byte + idx int // current chunk index + off int // offset within current chunk +} + +func (c *chunkedConn) Read(b []byte) (int, error) { + if c.idx >= len(c.chunks) { + return 0, errors.New("no more data") + } + n := copy(b, c.chunks[c.idx][c.off:]) + c.off += n + if c.off >= len(c.chunks[c.idx]) { + c.idx++ + c.off = 0 + } + return n, nil +} + +func (c *chunkedConn) Write(b []byte) (int, error) { return len(b), nil } // swallow writes (e.g. COM_QUERY) +func (c *chunkedConn) Close() error { return nil } +func (c *chunkedConn) LocalAddr() net.Addr { return nil } +func (c *chunkedConn) RemoteAddr() net.Addr { return nil } +func (c *chunkedConn) SetDeadline(_ time.Time) error { return nil } +func (c *chunkedConn) SetReadDeadline(_ time.Time) error { return nil } +func (c *chunkedConn) SetWriteDeadline(_ time.Time) error { return nil } + +var _ net.Conn = (*chunkedConn)(nil) + +// makePacket wraps a payload in a MySQL protocol packet header. +func makePacket(seq byte, payload []byte) []byte { + pkt := make([]byte, 4+len(payload)) + pkt[0] = byte(len(payload)) + pkt[1] = byte(len(payload) >> 8) + pkt[2] = byte(len(payload) >> 16) + pkt[3] = seq + copy(pkt[4:], payload) + return pkt +} + +// TestGetSystemVarBufferReuse verifies that getSystemVar returns a value that +// is not corrupted by the subsequent skipRows call. +// +// The row value returned by readRow points into the read buffer. skipRows may +// call fill(), which overwrites that memory. The test feeds each protocol +// packet as a separate Read call via chunkedConn (mimicking TLS record +// boundaries), guaranteeing that fill() is called for the trailing EOF. +func TestGetSystemVarBufferReuse(t *testing.T) { + // Protocol response for: SELECT @@max_allowed_packet → "67108864" + // + // Sequence numbers start at 1 (client sent COM_QUERY as seq 0). + // + // seq 1: column count = 1 + // seq 2: column definition (minimal valid) + // seq 3: EOF (end of column defs) + // seq 4: row data — length-encoded string "67108864" + // seq 5: EOF (end of rows) + + colCountPkt := makePacket(1, []byte{0x01}) + + colDef := []byte{ + 0x03, 'd', 'e', 'f', // catalog = "def" + 0x00, // schema = "" + 0x00, // table = "" + 0x00, // org_table = "" + 0x14, // name length = 20 + '@', '@', 'm', 'a', 'x', '_', 'a', 'l', 'l', 'o', + 'w', 'e', 'd', '_', 'p', 'a', 'c', 'k', 'e', 't', + 0x00, // org_name = "" + 0x0c, // length of fixed fields + 0x3f, 0x00, // charset = 63 (binary) + 0x14, 0x00, 0x00, 0x00, // column_length = 20 + 0x0f, // type = FIELD_TYPE_VARCHAR + 0x00, 0x00, // flags + 0x00, // decimals + 0x00, 0x00, // filler + } + colDefPkt := makePacket(2, colDef) + + eof1 := makePacket(3, []byte{0xfe, 0x00, 0x00, 0x02, 0x00}) + + // Row: length-encoded string "67108864" (8 bytes → length prefix 0x08) + rowPkt := makePacket(4, []byte{0x08, '6', '7', '1', '0', '8', '8', '6', '4'}) + + eof2 := makePacket(5, []byte{0xfe, 0x00, 0x00, 0x02, 0x00}) + + // Each packet arrives in its own Read call, simulating TLS record + // boundaries where each server Write becomes a separate TLS record + // and each client Read returns exactly one record. + conn := &chunkedConn{chunks: [][]byte{colCountPkt, colDefPkt, eof1, rowPkt, eof2}} + + mc := &mysqlConn{ + netConn: conn, + buf: newBuffer(), + cfg: NewConfig(), + closech: make(chan struct{}), + maxAllowedPacket: defaultMaxAllowedPacket, + sequence: 1, // after COM_QUERY (seq 0) + } + + val, err := mc.getSystemVar("max_allowed_packet") + if err != nil { + t.Fatalf("getSystemVar failed: %v", err) + } + + const expected = "67108864" + if val != expected { + t.Fatalf("getSystemVar(max_allowed_packet) = %q, want %q", val, expected) + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connector.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connector.go new file mode 100644 index 0000000..3d37604 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connector.go @@ -0,0 +1,229 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2018 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "context" + "database/sql/driver" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +type connector struct { + cfg *Config // immutable private copy. + encodedAttributes string // Encoded connection attributes. +} + +func encodeConnectionAttributes(cfg *Config) string { + connAttrsBuf := make([]byte, 0) + + // default connection attributes + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrClientName) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrClientNameValue) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrOS) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrOSValue) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrPlatform) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrPlatformValue) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrPid) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, strconv.Itoa(os.Getpid())) + serverHost, _, _ := net.SplitHostPort(cfg.Addr) + if serverHost != "" { + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrServerHost) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, serverHost) + } + + // user-defined connection attributes + for connAttr := range strings.SplitSeq(cfg.ConnectionAttributes, ",") { + k, v, found := strings.Cut(connAttr, ":") + if !found { + continue + } + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, k) + connAttrsBuf = appendLengthEncodedString(connAttrsBuf, v) + } + + return string(connAttrsBuf) +} + +func newConnector(cfg *Config) *connector { + encodedAttributes := encodeConnectionAttributes(cfg) + return &connector{ + cfg: cfg, + encodedAttributes: encodedAttributes, + } +} + +// Connect implements driver.Connector interface. +// Connect returns a connection to the database. +func (c *connector) Connect(ctx context.Context) (driver.Conn, error) { + var err error + + // Invoke beforeConnect if present, with a copy of the configuration + cfg := c.cfg + if c.cfg.beforeConnect != nil { + cfg = c.cfg.Clone() + err = c.cfg.beforeConnect(ctx, cfg) + if err != nil { + return nil, err + } + } + + // New mysqlConn + mc := &mysqlConn{ + maxAllowedPacket: maxPacketSize, + maxWriteSize: maxPacketSize - 1, + closech: make(chan struct{}), + cfg: cfg, + connector: c, + } + mc.parseTime = mc.cfg.ParseTime + + // Connect to Server + dctx := ctx + if mc.cfg.Timeout > 0 { + var cancel context.CancelFunc + dctx, cancel = context.WithTimeout(ctx, c.cfg.Timeout) + defer cancel() + } + + if c.cfg.DialFunc != nil { + mc.netConn, err = c.cfg.DialFunc(dctx, mc.cfg.Net, mc.cfg.Addr) + } else { + dialsLock.RLock() + dial, ok := dials[mc.cfg.Net] + dialsLock.RUnlock() + if ok { + mc.netConn, err = dial(dctx, mc.cfg.Addr) + } else { + nd := net.Dialer{} + mc.netConn, err = nd.DialContext(dctx, mc.cfg.Net, mc.cfg.Addr) + } + } + if err != nil { + return nil, err + } + mc.rawConn = mc.netConn + + // Enable TCP Keepalives on TCP connections + if tc, ok := mc.netConn.(*net.TCPConn); ok { + if err := tc.SetKeepAlive(true); err != nil { + c.cfg.Logger.Print(err) + } + } + + // Call startWatcher for context support (From Go 1.8) + mc.startWatcher() + if err := mc.watchCancel(ctx); err != nil { + mc.cleanup() + return nil, err + } + defer mc.finish() + + mc.buf = newBuffer() + + // Reading Handshake Initialization Packet + authData, serverCapabilities, serverExtCapabilities, plugin, err := mc.readHandshakePacket() + if err != nil { + mc.cleanup() + return nil, err + } + + if plugin == "" { + plugin = defaultAuthPlugin + } + + // Send Client Authentication Packet + authResp, err := mc.auth(authData, plugin) + if err != nil { + // try the default auth plugin, if using the requested plugin failed + c.cfg.Logger.Print("could not use requested auth plugin '"+plugin+"': ", err.Error()) + plugin = defaultAuthPlugin + authResp, err = mc.auth(authData, plugin) + if err != nil { + mc.cleanup() + return nil, err + } + } + mc.initCapabilities(serverCapabilities, serverExtCapabilities, mc.cfg) + if err = mc.writeHandshakeResponsePacket(authResp, plugin); err != nil { + mc.cleanup() + return nil, err + } + + // Handle response to auth packet, switch methods if possible + if err = mc.handleAuthResult(authData, plugin); err != nil { + // Authentication failed and MySQL has already closed the connection + // (https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html#sect_protocol_connection_phase_fast_path_fails). + // Do not send COM_QUIT, just cleanup and return the error. + mc.cleanup() + return nil, err + } + + // compression is enabled after auth, not right after sending handshake response. + if mc.capabilities&clientCompress > 0 { + mc.compress = true + mc.compIO = newCompIO(mc) + } + if mc.cfg.MaxAllowedPacket > 0 { + mc.maxAllowedPacket = mc.cfg.MaxAllowedPacket + } else { + // Get max allowed packet size + maxap, err := mc.getSystemVar("max_allowed_packet") + if err != nil { + mc.Close() + return nil, err + } + n, err := strconv.Atoi(maxap) + if err != nil { + mc.Close() + return nil, fmt.Errorf("invalid max_allowed_packet value (%q): %w", maxap, err) + } + mc.maxAllowedPacket = n - 1 + } + if mc.maxAllowedPacket < maxPacketSize { + mc.maxWriteSize = mc.maxAllowedPacket + } + + // Charset: character_set_connection, character_set_client, character_set_results + if len(mc.cfg.charsets) > 0 { + for _, cs := range mc.cfg.charsets { + // ignore errors here - a charset may not exist + if mc.cfg.Collation != "" { + err = mc.exec("SET NAMES " + cs + " COLLATE " + mc.cfg.Collation) + } else { + err = mc.exec("SET NAMES " + cs) + } + if err == nil { + break + } + } + if err != nil { + mc.Close() + return nil, err + } + } + + // Handle DSN Params + err = mc.handleParams() + if err != nil { + mc.Close() + return nil, err + } + + return mc, nil +} + +// Driver implements driver.Connector interface. +// Driver returns &MySQLDriver{}. +func (c *connector) Driver() driver.Driver { + return &MySQLDriver{} +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connector_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connector_test.go new file mode 100644 index 0000000..82d8c59 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/connector_test.go @@ -0,0 +1,30 @@ +package mysql + +import ( + "context" + "net" + "testing" + "time" +) + +func TestConnectorReturnsTimeout(t *testing.T) { + connector := newConnector(&Config{ + Net: "tcp", + Addr: "1.1.1.1:1234", + Timeout: 10 * time.Millisecond, + }) + + _, err := connector.Connect(context.Background()) + if err == nil { + t.Fatal("error expected") + } + + if nerr, ok := err.(*net.OpError); ok { + expected := "dial tcp 1.1.1.1:1234: i/o timeout" + if nerr.Error() != expected { + t.Fatalf("expected %q, got %q", expected, nerr.Error()) + } + } else { + t.Fatalf("expected %T, got %T", nerr, err) + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/const.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/const.go new file mode 100644 index 0000000..6f0cdf3 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/const.go @@ -0,0 +1,205 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import "runtime" + +const ( + debug = false // for debugging. Set true only in development. + + defaultAuthPlugin = "mysql_native_password" + defaultMaxAllowedPacket = 64 << 20 // 64 MiB. See https://github.com/go-sql-driver/mysql/issues/1355 + minProtocolVersion = 10 + maxPacketSize = 1<<24 - 1 + timeFormat = "2006-01-02 15:04:05.999999" + + // Connection attributes + // See https://dev.mysql.com/doc/refman/8.0/en/performance-schema-connection-attribute-tables.html#performance-schema-connection-attributes-available + connAttrClientName = "_client_name" + connAttrClientNameValue = "Go-MySQL-Driver" + connAttrOS = "_os" + connAttrOSValue = runtime.GOOS + connAttrPlatform = "_platform" + connAttrPlatformValue = runtime.GOARCH + connAttrPid = "_pid" + connAttrServerHost = "_server_host" +) + +// MySQL constants documentation: +// https://dev.mysql.com/doc/dev/mysql-server/latest/PAGE_PROTOCOL.html + +const ( + iOK byte = 0x00 + iAuthMoreData byte = 0x01 + iLocalInFile byte = 0xfb + iEOF byte = 0xfe + iERR byte = 0xff +) + +// https://dev.mysql.com/doc/dev/mysql-server/latest/group__group__cs__capabilities__flags.html +// https://mariadb.com/kb/en/connection/#capabilities +type capabilityFlag uint32 + +const ( + clientMySQL capabilityFlag = 1 << iota + clientFoundRows + clientLongFlag + clientConnectWithDB + clientNoSchema + clientCompress + clientODBC + clientLocalFiles + clientIgnoreSpace + clientProtocol41 + clientInteractive + clientSSL + clientIgnoreSIGPIPE + clientTransactions + clientReserved + clientSecureConn + clientMultiStatements + clientMultiResults + clientPSMultiResults + clientPluginAuth + clientConnectAttrs + clientPluginAuthLenEncClientData + clientCanHandleExpiredPasswords + clientSessionTrack + clientDeprecateEOF +) + +// https://mariadb.com/kb/en/connection/#capabilities +type extendedCapabilityFlag uint32 + +const ( + progressIndicator extendedCapabilityFlag = 1 << iota + clientComMulti + clientStmtBulkOperations + clientExtendedMetadata + clientCacheMetadata + clientUnitBulkResult +) + +const ( + comQuit byte = iota + 1 + comInitDB + comQuery + comFieldList + comCreateDB + comDropDB + comRefresh + comShutdown + comStatistics + comProcessInfo + comConnect + comProcessKill + comDebug + comPing + comTime + comDelayedInsert + comChangeUser + comBinlogDump + comTableDump + comConnectOut + comRegisterSlave + comStmtPrepare + comStmtExecute + comStmtSendLongData + comStmtClose + comStmtReset + comSetOption + comStmtFetch +) + +// https://dev.mysql.com/doc/internals/en/com-query-response.html#packet-Protocol::ColumnType +type fieldType byte + +const ( + fieldTypeDecimal fieldType = iota + fieldTypeTiny + fieldTypeShort + fieldTypeLong + fieldTypeFloat + fieldTypeDouble + fieldTypeNULL + fieldTypeTimestamp + fieldTypeLongLong + fieldTypeInt24 + fieldTypeDate + fieldTypeTime + fieldTypeDateTime + fieldTypeYear + fieldTypeNewDate + fieldTypeVarChar + fieldTypeBit +) +const ( + fieldTypeVector fieldType = iota + 0xf2 + fieldTypeInvalid + fieldTypeBool + fieldTypeJSON + fieldTypeNewDecimal + fieldTypeEnum + fieldTypeSet + fieldTypeTinyBLOB + fieldTypeMediumBLOB + fieldTypeLongBLOB + fieldTypeBLOB + fieldTypeVarString + fieldTypeString + fieldTypeGeometry +) + +type fieldFlag uint16 + +const ( + flagNotNULL fieldFlag = 1 << iota + flagPriKey + flagUniqueKey + flagMultipleKey + flagBLOB + flagUnsigned + flagZeroFill + flagBinary + flagEnum + flagAutoIncrement + flagTimestamp + flagSet + flagUnknown1 + flagUnknown2 + flagUnknown3 + flagUnknown4 +) + +// http://dev.mysql.com/doc/internals/en/status-flags.html +type statusFlag uint16 + +const ( + statusInTrans statusFlag = 1 << iota + statusInAutocommit + statusReserved // Not in documentation + statusMoreResultsExists + statusNoGoodIndexUsed + statusNoIndexUsed + statusCursorExists + statusLastRowSent + statusDbDropped + statusNoBackslashEscapes + statusMetadataChanged + statusQueryWasSlow + statusPsOutParams + statusInTransReadonly + statusSessionStateChanged +) + +const ( + cachingSha2PasswordRequestPublicKey = 2 + cachingSha2PasswordFastAuthSuccess = 3 + cachingSha2PasswordPerformFullAuthentication = 4 +) diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/driver.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/driver.go new file mode 100644 index 0000000..105316b --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/driver.go @@ -0,0 +1,118 @@ +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package mysql provides a MySQL driver for Go's database/sql package. +// +// The driver should be used via the database/sql package: +// +// import "database/sql" +// import _ "github.com/go-sql-driver/mysql" +// +// db, err := sql.Open("mysql", "user:password@/dbname") +// +// See https://github.com/go-sql-driver/mysql#usage for details +package mysql + +import ( + "context" + "database/sql" + "database/sql/driver" + "net" + "sync" +) + +// MySQLDriver is exported to make the driver directly accessible. +// In general the driver is used via the database/sql package. +type MySQLDriver struct{} + +// DialFunc is a function which can be used to establish the network connection. +// Custom dial functions must be registered with RegisterDial +// +// Deprecated: users should register a DialContextFunc instead +type DialFunc func(addr string) (net.Conn, error) + +// DialContextFunc is a function which can be used to establish the network connection. +// Custom dial functions must be registered with RegisterDialContext +type DialContextFunc func(ctx context.Context, addr string) (net.Conn, error) + +var ( + dialsLock sync.RWMutex + dials map[string]DialContextFunc +) + +// RegisterDialContext registers a custom dial function. It can then be used by the +// network address mynet(addr), where mynet is the registered new network. +// The current context for the connection and its address is passed to the dial function. +func RegisterDialContext(net string, dial DialContextFunc) { + dialsLock.Lock() + defer dialsLock.Unlock() + if dials == nil { + dials = make(map[string]DialContextFunc) + } + dials[net] = dial +} + +// DeregisterDialContext removes the custom dial function registered with the given net. +func DeregisterDialContext(net string) { + dialsLock.Lock() + defer dialsLock.Unlock() + if dials != nil { + delete(dials, net) + } +} + +// RegisterDial registers a custom dial function. It can then be used by the +// network address mynet(addr), where mynet is the registered new network. +// addr is passed as a parameter to the dial function. +// +// Deprecated: users should call RegisterDialContext instead +func RegisterDial(network string, dial DialFunc) { + RegisterDialContext(network, func(_ context.Context, addr string) (net.Conn, error) { + return dial(addr) + }) +} + +// Open new Connection. +// See https://github.com/go-sql-driver/mysql#dsn-data-source-name for how +// the DSN string is formatted +func (d MySQLDriver) Open(dsn string) (driver.Conn, error) { + cfg, err := ParseDSN(dsn) + if err != nil { + return nil, err + } + c := newConnector(cfg) + return c.Connect(context.Background()) +} + +// This variable can be replaced with -ldflags like below: +// go build "-ldflags=-X github.com/go-sql-driver/mysql.driverName=custom" +var driverName = "mysql" + +func init() { + if driverName != "" { + sql.Register(driverName, &MySQLDriver{}) + } +} + +// NewConnector returns new driver.Connector. +func NewConnector(cfg *Config) (driver.Connector, error) { + cfg = cfg.Clone() + // normalize the contents of cfg so calls to NewConnector have the same + // behavior as MySQLDriver.OpenConnector + if err := cfg.normalize(); err != nil { + return nil, err + } + return newConnector(cfg), nil +} + +// OpenConnector implements driver.DriverContext. +func (d MySQLDriver) OpenConnector(dsn string) (driver.Connector, error) { + cfg, err := ParseDSN(dsn) + if err != nil { + return nil, err + } + return newConnector(cfg), nil +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/driver_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/driver_test.go new file mode 100644 index 0000000..f2aaf35 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/driver_test.go @@ -0,0 +1,3652 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "io" + "log" + "math" + mrand "math/rand" + "net" + "net/url" + "os" + "reflect" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// This variable can be replaced with -ldflags like below: +// go test "-ldflags=-X github.com/go-sql-driver/mysql.driverNameTest=custom" +var driverNameTest string + +func init() { + if driverNameTest == "" { + driverNameTest = driverName + } +} + +// Ensure that all the driver interfaces are implemented +var ( + _ driver.Rows = &binaryRows{} + _ driver.Rows = &textRows{} +) + +var ( + user string + pass string + prot string + addr string + dbname string + dsn string + netAddr string + available bool +) + +var ( + tDate = time.Date(2012, 6, 14, 0, 0, 0, 0, time.UTC) + sDate = "2012-06-14" + tDateTime = time.Date(2011, 11, 20, 21, 27, 37, 0, time.UTC) + sDateTime = "2011-11-20 21:27:37" + tDate0 = time.Time{} + sDate0 = "0000-00-00" + sDateTime0 = "0000-00-00 00:00:00" +) + +// See https://github.com/go-sql-driver/mysql/wiki/Testing +func init() { + // get environment variables + env := func(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue + } + user = env("MYSQL_TEST_USER", "root") + pass = env("MYSQL_TEST_PASS", "") + prot = env("MYSQL_TEST_PROT", "tcp") + addr = env("MYSQL_TEST_ADDR", "localhost:3306") + dbname = env("MYSQL_TEST_DBNAME", "gotest") + netAddr = fmt.Sprintf("%s(%s)", prot, addr) + dsn = fmt.Sprintf("%s:%s@%s/%s?timeout=30s", user, pass, netAddr, dbname) + c, err := net.Dial(prot, addr) + if err == nil { + available = true + c.Close() + } +} + +type DBTest struct { + testing.TB + db *sql.DB +} + +type netErrorMock struct { + temporary bool + timeout bool +} + +func (e netErrorMock) Temporary() bool { + return e.temporary +} + +func (e netErrorMock) Timeout() bool { + return e.timeout +} + +func (e netErrorMock) Error() string { + return fmt.Sprintf("mock net error. Temporary: %v, Timeout %v", e.temporary, e.timeout) +} + +func runTestsWithMultiStatement(t *testing.T, dsn string, tests ...func(dbt *DBTest)) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + dsn += "&multiStatements=true" + var db *sql.DB + if _, err := ParseDSN(dsn); err != errInvalidDSNUnsafeCollation { + db, err = sql.Open(driverNameTest, dsn) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + defer db.Close() + } + // Previous test may be skipped without dropping the test table + db.Exec("DROP TABLE IF EXISTS test") + + dbt := &DBTest{t, db} + for _, test := range tests { + test(dbt) + dbt.db.Exec("DROP TABLE IF EXISTS test") + } +} + +func runTests(t *testing.T, dsn string, tests ...func(dbt *DBTest)) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + db, err := sql.Open(driverNameTest, dsn) + if err != nil { + t.Fatalf("connecting %q: %s", dsn, err) + } + defer db.Close() + if err = db.Ping(); err != nil { + t.Fatalf("connecting %q: %s", dsn, err) + } + + dsn2 := dsn + "&interpolateParams=true" + var db2 *sql.DB + if _, err := ParseDSN(dsn2); err != errInvalidDSNUnsafeCollation { + db2, err = sql.Open(driverNameTest, dsn2) + if err != nil { + t.Fatalf("connecting %q: %s", dsn2, err) + } + defer db2.Close() + } + + dsn3 := dsn + "&compress=true" + var db3 *sql.DB + db3, err = sql.Open(driverNameTest, dsn3) + if err != nil { + t.Fatalf("connecting %q: %s", dsn3, err) + } + defer db3.Close() + + cleanupSql := "DROP TABLE IF EXISTS test" + + for _, test := range tests { + t.Run("default", func(t *testing.T) { + dbt := &DBTest{t, db} + t.Cleanup(func() { + db.Exec(cleanupSql) + }) + test(dbt) + }) + if db2 != nil { + t.Run("interpolateParams", func(t *testing.T) { + dbt2 := &DBTest{t, db2} + t.Cleanup(func() { + db2.Exec(cleanupSql) + }) + test(dbt2) + }) + } + t.Run("compress", func(t *testing.T) { + dbt3 := &DBTest{t, db3} + t.Cleanup(func() { + db3.Exec(cleanupSql) + }) + test(dbt3) + }) + } +} + +// runTestsParallel runs the tests in parallel with a separate database connection for each test. +func runTestsParallel(t *testing.T, dsn string, tests ...func(dbt *DBTest, tableName string)) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + newTableName := func(t *testing.T) string { + t.Helper() + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + t.Fatal(err) + } + return fmt.Sprintf("test_%x", buf[:]) + } + + t.Parallel() + for _, test := range tests { + + t.Run("default", func(t *testing.T) { + t.Parallel() + + tableName := newTableName(t) + db, err := sql.Open("mysql", dsn) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + t.Cleanup(func() { + db.Exec("DROP TABLE IF EXISTS " + tableName) + db.Close() + }) + + dbt := &DBTest{t, db} + test(dbt, tableName) + }) + + dsn2 := dsn + "&interpolateParams=true" + if _, err := ParseDSN(dsn2); err == errInvalidDSNUnsafeCollation { + t.Run("interpolateParams", func(t *testing.T) { + t.Parallel() + + tableName := newTableName(t) + db, err := sql.Open("mysql", dsn2) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + t.Cleanup(func() { + db.Exec("DROP TABLE IF EXISTS " + tableName) + db.Close() + }) + + dbt := &DBTest{t, db} + test(dbt, tableName) + }) + } + } +} + +func (dbt *DBTest) fail(method, query string, err error) { + dbt.Helper() + if len(query) > 300 { + query = "[query too large to print]" + } + dbt.Fatalf("error on %s %s: %s", method, query, err.Error()) +} + +func (dbt *DBTest) mustExec(query string, args ...any) (res sql.Result) { + dbt.Helper() + res, err := dbt.db.Exec(query, args...) + if err != nil { + dbt.fail("exec", query, err) + } + return res +} + +func (dbt *DBTest) mustQuery(query string, args ...any) (rows *sql.Rows) { + dbt.Helper() + rows, err := dbt.db.Query(query, args...) + if err != nil { + dbt.fail("query", query, err) + } + return rows +} + +func maybeSkip(t *testing.T, err error, skipErrno uint16) { + mySQLErr, ok := err.(*MySQLError) + if !ok { + return + } + + if mySQLErr.Number == skipErrno { + t.Skipf("skipping test for error: %v", err) + } +} + +func TestEmptyQuery(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + // just a comment, no query + rows := dbt.mustQuery("--") + defer rows.Close() + // will hang before #255 + if rows.Next() { + dbt.Errorf("next on rows must be false") + } + }) +} + +func TestCRUD(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + // Create Table + dbt.mustExec("CREATE TABLE " + tbl + " (value BOOL)") + + // Test for unexpected data + var out bool + rows := dbt.mustQuery("SELECT * FROM " + tbl) + if rows.Next() { + dbt.Error("unexpected data in empty table") + } + rows.Close() + + // Create Data + res := dbt.mustExec("INSERT INTO " + tbl + " VALUES (1)") + count, err := res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 1 { + dbt.Fatalf("expected 1 affected row, got %d", count) + } + + id, err := res.LastInsertId() + if err != nil { + dbt.Fatalf("res.LastInsertId() returned error: %s", err.Error()) + } + if id != 0 { + dbt.Fatalf("expected InsertId 0, got %d", id) + } + + // Read + rows = dbt.mustQuery("SELECT value FROM " + tbl) + if rows.Next() { + rows.Scan(&out) + if true != out { + dbt.Errorf("true != %t", out) + } + + if rows.Next() { + dbt.Error("unexpected data") + } + } else { + dbt.Error("no data") + } + rows.Close() + + // Update + res = dbt.mustExec("UPDATE "+tbl+" SET value = ? WHERE value = ?", false, true) + count, err = res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 1 { + dbt.Fatalf("expected 1 affected row, got %d", count) + } + + // Check Update + rows = dbt.mustQuery("SELECT value FROM " + tbl) + if rows.Next() { + rows.Scan(&out) + if false != out { + dbt.Errorf("false != %t", out) + } + + if rows.Next() { + dbt.Error("unexpected data") + } + } else { + dbt.Error("no data") + } + rows.Close() + + // Delete + res = dbt.mustExec("DELETE FROM "+tbl+" WHERE value = ?", false) + count, err = res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 1 { + dbt.Fatalf("expected 1 affected row, got %d", count) + } + + // Check for unexpected rows + res = dbt.mustExec("DELETE FROM " + tbl) + count, err = res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 0 { + dbt.Fatalf("expected 0 affected row, got %d", count) + } + }) +} + +// TestNumbers test that selecting numeric columns. +// Both of textRows and binaryRows should return same type and value. +func TestNumbersToAny(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (id INT PRIMARY KEY, b BOOL, i8 TINYINT, " + + "i16 SMALLINT, i32 INT, i64 BIGINT, f32 FLOAT, f64 DOUBLE, iu32 INT UNSIGNED)") + dbt.mustExec("INSERT INTO " + tbl + " VALUES (1, true, 127, 32767, 2147483647, 9223372036854775807, 1.25, 2.5, 4294967295)") + + // Use binaryRows for interpolateParams=false and textRows for interpolateParams=true. + rows := dbt.mustQuery("SELECT b, i8, i16, i32, i64, f32, f64, iu32 FROM "+tbl+" WHERE id=?", 1) + if !rows.Next() { + dbt.Fatal("no data") + } + var b, i8, i16, i32, i64, f32, f64, iu32 any + err := rows.Scan(&b, &i8, &i16, &i32, &i64, &f32, &f64, &iu32) + if err != nil { + dbt.Fatal(err) + } + if b.(int64) != 1 { + dbt.Errorf("b != 1") + } + if i8.(int64) != 127 { + dbt.Errorf("i8 != 127") + } + if i16.(int64) != 32767 { + dbt.Errorf("i16 != 32767") + } + if i32.(int64) != 2147483647 { + dbt.Errorf("i32 != 2147483647") + } + if i64.(int64) != 9223372036854775807 { + dbt.Errorf("i64 != 9223372036854775807") + } + if f32.(float32) != 1.25 { + dbt.Errorf("f32 != 1.25") + } + if f64.(float64) != 2.5 { + dbt.Errorf("f64 != 2.5") + } + if iu32.(int64) != 4294967295 { + dbt.Errorf("iu32 != 4294967295") + } + }) +} + +func TestMultiQuery(t *testing.T) { + runTestsWithMultiStatement(t, dsn, func(dbt *DBTest) { + // Create Table + dbt.mustExec("CREATE TABLE `test` (`id` int(11) NOT NULL, `value` int(11) NOT NULL) ") + + // Create Data + res := dbt.mustExec("INSERT INTO test VALUES (1, 1)") + count, err := res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 1 { + dbt.Fatalf("expected 1 affected row, got %d", count) + } + + // Update + res = dbt.mustExec("UPDATE test SET value = 3 WHERE id = 1; UPDATE test SET value = 4 WHERE id = 1; UPDATE test SET value = 5 WHERE id = 1;") + count, err = res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 1 { + dbt.Fatalf("expected 1 affected row, got %d", count) + } + + // Read + var out int + rows := dbt.mustQuery("SELECT value FROM test WHERE id=1;") + if rows.Next() { + rows.Scan(&out) + if out != 5 { + dbt.Errorf("expected 5, got %d", out) + } + + if rows.Next() { + dbt.Error("unexpected data") + } + } else { + dbt.Error("no data") + } + rows.Close() + + }) +} + +func TestInt(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + types := [5]string{"TINYINT", "SMALLINT", "MEDIUMINT", "INT", "BIGINT"} + in := int64(42) + var out int64 + var rows *sql.Rows + + // SIGNED + for _, v := range types { + dbt.mustExec("CREATE TABLE " + tbl + " (value " + v + ")") + + dbt.mustExec("INSERT INTO "+tbl+" VALUES (?)", in) + + rows = dbt.mustQuery("SELECT value FROM " + tbl) + if rows.Next() { + rows.Scan(&out) + if in != out { + dbt.Errorf("%s: %d != %d", v, in, out) + } + } else { + dbt.Errorf("%s: no data", v) + } + rows.Close() + + dbt.mustExec("DROP TABLE IF EXISTS " + tbl) + } + + // UNSIGNED ZEROFILL + for _, v := range types { + dbt.mustExec("CREATE TABLE " + tbl + " (value " + v + " ZEROFILL)") + + dbt.mustExec("INSERT INTO "+tbl+" VALUES (?)", in) + + rows = dbt.mustQuery("SELECT value FROM " + tbl) + if rows.Next() { + rows.Scan(&out) + if in != out { + dbt.Errorf("%s ZEROFILL: %d != %d", v, in, out) + } + } else { + dbt.Errorf("%s ZEROFILL: no data", v) + } + rows.Close() + + dbt.mustExec("DROP TABLE IF EXISTS " + tbl) + } + }) +} + +func TestFloat32(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + types := [2]string{"FLOAT", "DOUBLE"} + in := float32(42.23) + var out float32 + var rows *sql.Rows + for _, v := range types { + dbt.mustExec("CREATE TABLE " + tbl + " (value " + v + ")") + dbt.mustExec("INSERT INTO "+tbl+" VALUES (?)", in) + rows = dbt.mustQuery("SELECT value FROM " + tbl) + if rows.Next() { + rows.Scan(&out) + if in != out { + dbt.Errorf("%s: %g != %g", v, in, out) + } + } else { + dbt.Errorf("%s: no data", v) + } + rows.Close() + dbt.mustExec("DROP TABLE IF EXISTS " + tbl) + } + }) +} + +func TestFloat64(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + types := [2]string{"FLOAT", "DOUBLE"} + var expected float64 = 42.23 + var out float64 + var rows *sql.Rows + for _, v := range types { + dbt.mustExec("CREATE TABLE " + tbl + " (value " + v + ")") + dbt.mustExec("INSERT INTO " + tbl + " VALUES (42.23)") + rows = dbt.mustQuery("SELECT value FROM " + tbl) + if rows.Next() { + rows.Scan(&out) + if expected != out { + dbt.Errorf("%s: %g != %g", v, expected, out) + } + } else { + dbt.Errorf("%s: no data", v) + } + rows.Close() + dbt.mustExec("DROP TABLE IF EXISTS " + tbl) + } + }) +} + +func TestFloat64Placeholder(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + types := [2]string{"FLOAT", "DOUBLE"} + var expected float64 = 42.23 + var out float64 + var rows *sql.Rows + for _, v := range types { + dbt.mustExec("CREATE TABLE " + tbl + " (id int, value " + v + ")") + dbt.mustExec("INSERT INTO " + tbl + " VALUES (1, 42.23)") + rows = dbt.mustQuery("SELECT value FROM "+tbl+" WHERE id = ?", 1) + if rows.Next() { + rows.Scan(&out) + if expected != out { + dbt.Errorf("%s: %g != %g", v, expected, out) + } + } else { + dbt.Errorf("%s: no data", v) + } + rows.Close() + dbt.mustExec("DROP TABLE IF EXISTS " + tbl) + } + }) +} + +func TestString(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + types := [6]string{"CHAR(255)", "VARCHAR(255)", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"} + in := "κόσμε üöäßñóùéàâÿœ'îë Árvíztűrő いろはにほへとちりぬるを イロハニホヘト דג סקרן чащах น่าฟังเอย" + var out string + var rows *sql.Rows + + for _, v := range types { + dbt.mustExec("CREATE TABLE " + tbl + " (value " + v + ") CHARACTER SET utf8") + + dbt.mustExec("INSERT INTO "+tbl+" VALUES (?)", in) + + rows = dbt.mustQuery("SELECT value FROM " + tbl) + if rows.Next() { + rows.Scan(&out) + if in != out { + dbt.Errorf("%s: %s != %s", v, in, out) + } + } else { + dbt.Errorf("%s: no data", v) + } + rows.Close() + + dbt.mustExec("DROP TABLE IF EXISTS " + tbl) + } + + // BLOB + dbt.mustExec("CREATE TABLE " + tbl + " (id int, value BLOB) CHARACTER SET utf8") + + id := 2 + in = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, " + + "sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, " + + "sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. " + + "Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. " + + "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, " + + "sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, " + + "sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. " + + "Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + dbt.mustExec("INSERT INTO "+tbl+" VALUES (?, ?)", id, in) + + err := dbt.db.QueryRow("SELECT value FROM "+tbl+" WHERE id = ?", id).Scan(&out) + if err != nil { + dbt.Fatalf("Error on BLOB-Query: %s", err.Error()) + } else if out != in { + dbt.Errorf("BLOB: %s != %s", in, out) + } + }) +} + +func TestRawBytes(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + v1 := []byte("aaa") + v2 := []byte("bbb") + rows := dbt.mustQuery("SELECT ?, ?", v1, v2) + defer rows.Close() + if rows.Next() { + var o1, o2 sql.RawBytes + if err := rows.Scan(&o1, &o2); err != nil { + dbt.Errorf("Got error: %v", err) + } + if !bytes.Equal(v1, o1) { + dbt.Errorf("expected %v, got %v", v1, o1) + } + if !bytes.Equal(v2, o2) { + dbt.Errorf("expected %v, got %v", v2, o2) + } + // https://github.com/go-sql-driver/mysql/issues/765 + // Appending to RawBytes shouldn't overwrite next RawBytes. + o1 = append(o1, "xyzzy"...) + if !bytes.Equal(v2, o2) { + dbt.Errorf("expected %v, got %v", v2, o2) + } + } else { + dbt.Errorf("no data") + } + }) +} + +func TestRawMessage(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + v1 := json.RawMessage("{}") + v2 := json.RawMessage("[]") + rows := dbt.mustQuery("SELECT ?, ?", v1, v2) + defer rows.Close() + if rows.Next() { + var o1, o2 json.RawMessage + if err := rows.Scan(&o1, &o2); err != nil { + dbt.Errorf("Got error: %v", err) + } + if !bytes.Equal(v1, o1) { + dbt.Errorf("expected %v, got %v", v1, o1) + } + if !bytes.Equal(v2, o2) { + dbt.Errorf("expected %v, got %v", v2, o2) + } + } else { + dbt.Errorf("no data") + } + }) +} + +type testValuer struct { + value string +} + +func (tv testValuer) Value() (driver.Value, error) { + return tv.value, nil +} + +func TestValuer(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + in := testValuer{"a_value"} + var out string + var rows *sql.Rows + + dbt.mustExec("CREATE TABLE " + tbl + " (value VARCHAR(255)) CHARACTER SET utf8") + dbt.mustExec("INSERT INTO "+tbl+" VALUES (?)", in) + rows = dbt.mustQuery("SELECT value FROM " + tbl) + if rows.Next() { + rows.Scan(&out) + if in.value != out { + dbt.Errorf("Valuer: %v != %s", in, out) + } + } else { + dbt.Errorf("Valuer: no data") + } + rows.Close() + }) +} + +type testValuerWithValidation struct { + value string +} + +func (tv testValuerWithValidation) Value() (driver.Value, error) { + if len(tv.value) == 0 { + return nil, fmt.Errorf("Invalid string valuer. Value must not be empty") + } + + return tv.value, nil +} + +func TestValuerWithValidation(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + in := testValuerWithValidation{"a_value"} + var out string + var rows *sql.Rows + + dbt.mustExec("CREATE TABLE " + tbl + " (value VARCHAR(255)) CHARACTER SET utf8") + dbt.mustExec("INSERT INTO "+tbl+" VALUES (?)", in) + + rows = dbt.mustQuery("SELECT value FROM " + tbl) + defer rows.Close() + + if rows.Next() { + rows.Scan(&out) + if in.value != out { + dbt.Errorf("Valuer: %v != %s", in, out) + } + } else { + dbt.Errorf("Valuer: no data") + } + + if _, err := dbt.db.Exec("INSERT INTO "+tbl+" VALUES (?)", testValuerWithValidation{""}); err == nil { + dbt.Errorf("Failed to check valuer error") + } + + if _, err := dbt.db.Exec("INSERT INTO "+tbl+" VALUES (?)", nil); err != nil { + dbt.Errorf("Failed to check nil") + } + + if _, err := dbt.db.Exec("INSERT INTO "+tbl+" VALUES (?)", map[string]bool{}); err == nil { + dbt.Errorf("Failed to check not valuer") + } + }) +} + +type timeTests struct { + dbtype string + tlayout string + tests []timeTest +} + +type timeTest struct { + s string // leading "!": do not use t as value in queries + t time.Time +} + +type timeMode byte + +func (t timeMode) String() string { + switch t { + case binaryString: + return "binary:string" + case binaryTime: + return "binary:time.Time" + case textString: + return "text:string" + } + panic("unsupported timeMode") +} + +func (t timeMode) Binary() bool { + switch t { + case binaryString, binaryTime: + return true + } + return false +} + +const ( + binaryString timeMode = iota + binaryTime + textString +) + +func (t timeTest) genQuery(dbtype string, mode timeMode) string { + var inner string + if mode.Binary() { + inner = "?" + } else { + inner = `"%s"` + } + return `SELECT cast(` + inner + ` as ` + dbtype + `)` +} + +func (t timeTest) run(dbt *DBTest, dbtype, tlayout string, mode timeMode) { + var rows *sql.Rows + query := t.genQuery(dbtype, mode) + switch mode { + case binaryString: + rows = dbt.mustQuery(query, t.s) + case binaryTime: + rows = dbt.mustQuery(query, t.t) + case textString: + query = fmt.Sprintf(query, t.s) + rows = dbt.mustQuery(query) + default: + panic("unsupported mode") + } + defer rows.Close() + var err error + if !rows.Next() { + err = rows.Err() + if err == nil { + err = fmt.Errorf("no data") + } + dbt.Errorf("%s [%s]: %s", dbtype, mode, err) + return + } + var dst any + err = rows.Scan(&dst) + if err != nil { + dbt.Errorf("%s [%s]: %s", dbtype, mode, err) + return + } + switch val := dst.(type) { + case []uint8: + str := string(val) + if str == t.s { + return + } + if mode.Binary() && dbtype == "DATETIME" && len(str) == 26 && str[:19] == t.s { + // a fix mainly for TravisCI: + // accept full microsecond resolution in result for DATETIME columns + // where the binary protocol was used + return + } + dbt.Errorf("%s [%s] to string: expected %q, got %q", + dbtype, mode, + t.s, str, + ) + case time.Time: + if val == t.t { + return + } + dbt.Errorf("%s [%s] to string: expected %q, got %q", + dbtype, mode, + t.s, val.Format(tlayout), + ) + default: + fmt.Printf("%#v\n", []any{dbtype, tlayout, mode, t.s, t.t}) + dbt.Errorf("%s [%s]: unhandled type %T (is '%v')", + dbtype, mode, + val, val, + ) + } +} + +func TestDateTime(t *testing.T) { + afterTime := func(t time.Time, d string) time.Time { + dur, err := time.ParseDuration(d) + if err != nil { + panic(err) + } + return t.Add(dur) + } + // NOTE: MySQL rounds DATETIME(x) up - but that's not included in the tests + format := "2006-01-02 15:04:05.999999" + t0 := time.Time{} + tstr0 := "0000-00-00 00:00:00.000000" + testcases := []timeTests{ + {"DATE", format[:10], []timeTest{ + {t: time.Date(2011, 11, 20, 0, 0, 0, 0, time.UTC)}, + {t: t0, s: tstr0[:10]}, + }}, + {"DATETIME", format[:19], []timeTest{ + {t: time.Date(2011, 11, 20, 21, 27, 37, 0, time.UTC)}, + {t: t0, s: tstr0[:19]}, + }}, + {"DATETIME(0)", format[:21], []timeTest{ + {t: time.Date(2011, 11, 20, 21, 27, 37, 0, time.UTC)}, + {t: t0, s: tstr0[:19]}, + }}, + {"DATETIME(1)", format[:21], []timeTest{ + {t: time.Date(2011, 11, 20, 21, 27, 37, 100000000, time.UTC)}, + {t: t0, s: tstr0[:21]}, + }}, + {"DATETIME(6)", format, []timeTest{ + {t: time.Date(2011, 11, 20, 21, 27, 37, 123456000, time.UTC)}, + {t: t0, s: tstr0}, + }}, + {"TIME", format[11:19], []timeTest{ + {t: afterTime(t0, "12345s")}, + {s: "!-12:34:56"}, + {s: "!-838:59:59"}, + {s: "!838:59:59"}, + {t: t0, s: tstr0[11:19]}, + }}, + {"TIME(0)", format[11:19], []timeTest{ + {t: afterTime(t0, "12345s")}, + {s: "!-12:34:56"}, + {s: "!-838:59:59"}, + {s: "!838:59:59"}, + {t: t0, s: tstr0[11:19]}, + }}, + {"TIME(1)", format[11:21], []timeTest{ + {t: afterTime(t0, "12345600ms")}, + {s: "!-12:34:56.7"}, + {s: "!-838:59:58.9"}, + {s: "!838:59:58.9"}, + {t: t0, s: tstr0[11:21]}, + }}, + {"TIME(6)", format[11:], []timeTest{ + {t: afterTime(t0, "1234567890123000ns")}, + {s: "!-12:34:56.789012"}, + {s: "!-838:59:58.999999"}, + {s: "!838:59:58.999999"}, + {t: t0, s: tstr0[11:]}, + }}, + } + dsns := []string{ + dsn + "&parseTime=true", + dsn + "&parseTime=false", + } + for _, testdsn := range dsns { + runTests(t, testdsn, func(dbt *DBTest) { + microsecsSupported := false + zeroDateSupported := false + var rows *sql.Rows + var err error + rows, err = dbt.db.Query(`SELECT cast("00:00:00.1" as TIME(1)) = "00:00:00.1"`) + if err == nil { + if rows.Next() { + rows.Scan(µsecsSupported) + } + rows.Close() + } + rows, err = dbt.db.Query(`SELECT cast("0000-00-00" as DATE) = "0000-00-00"`) + if err == nil { + if rows.Next() { + rows.Scan(&zeroDateSupported) + } + rows.Close() + } + for _, setups := range testcases { + if t := setups.dbtype; !microsecsSupported && t[len(t)-1:] == ")" { + // skip fractional second tests if unsupported by server + continue + } + for _, setup := range setups.tests { + allowBinTime := true + if setup.s == "" { + // fill time string wherever Go can reliable produce it + setup.s = setup.t.Format(setups.tlayout) + } else if setup.s[0] == '!' { + // skip tests using setup.t as source in queries + allowBinTime = false + // fix setup.s - remove the "!" + setup.s = setup.s[1:] + } + if !zeroDateSupported && setup.s == tstr0[:len(setup.s)] { + // skip disallowed 0000-00-00 date + continue + } + setup.run(dbt, setups.dbtype, setups.tlayout, textString) + setup.run(dbt, setups.dbtype, setups.tlayout, binaryString) + if allowBinTime { + setup.run(dbt, setups.dbtype, setups.tlayout, binaryTime) + } + } + } + }) + } +} + +func TestTimestampMicros(t *testing.T) { + format := "2006-01-02 15:04:05.999999" + f0 := format[:19] + f1 := format[:21] + f6 := format[:26] + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + // check if microseconds are supported. + // Do not use timestamp(x) for that check - before 5.5.6, x would mean display width + // and not precision. + // Se last paragraph at http://dev.mysql.com/doc/refman/5.6/en/fractional-seconds.html + microsecsSupported := false + if rows, err := dbt.db.Query(`SELECT cast("00:00:00.1" as TIME(1)) = "00:00:00.1"`); err == nil { + rows.Scan(µsecsSupported) + rows.Close() + } + if !microsecsSupported { + // skip test + return + } + _, err := dbt.db.Exec(` + CREATE TABLE ` + tbl + ` ( + value0 TIMESTAMP NOT NULL DEFAULT '` + f0 + `', + value1 TIMESTAMP(1) NOT NULL DEFAULT '` + f1 + `', + value6 TIMESTAMP(6) NOT NULL DEFAULT '` + f6 + `' + )`, + ) + if err != nil { + dbt.Error(err) + } + defer dbt.mustExec("DROP TABLE IF EXISTS " + tbl) + dbt.mustExec("INSERT INTO "+tbl+" SET value0=?, value1=?, value6=?", f0, f1, f6) + var res0, res1, res6 string + rows := dbt.mustQuery("SELECT * FROM " + tbl) + defer rows.Close() + if !rows.Next() { + dbt.Errorf("test contained no selectable values") + } + err = rows.Scan(&res0, &res1, &res6) + if err != nil { + dbt.Error(err) + } + if res0 != f0 { + dbt.Errorf("expected %q, got %q", f0, res0) + } + if res1 != f1 { + dbt.Errorf("expected %q, got %q", f1, res1) + } + if res6 != f6 { + dbt.Errorf("expected %q, got %q", f6, res6) + } + }) +} + +func TestNULL(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + nullStmt, err := dbt.db.Prepare("SELECT NULL") + if err != nil { + dbt.Fatal(err) + } + defer nullStmt.Close() + + nonNullStmt, err := dbt.db.Prepare("SELECT 1") + if err != nil { + dbt.Fatal(err) + } + defer nonNullStmt.Close() + + // NullBool + var nb sql.NullBool + // Invalid + if err = nullStmt.QueryRow().Scan(&nb); err != nil { + dbt.Fatal(err) + } + if nb.Valid { + dbt.Error("valid NullBool which should be invalid") + } + // Valid + if err = nonNullStmt.QueryRow().Scan(&nb); err != nil { + dbt.Fatal(err) + } + if !nb.Valid { + dbt.Error("invalid NullBool which should be valid") + } else if nb.Bool != true { + dbt.Errorf("Unexpected NullBool value: %t (should be true)", nb.Bool) + } + + // NullFloat64 + var nf sql.NullFloat64 + // Invalid + if err = nullStmt.QueryRow().Scan(&nf); err != nil { + dbt.Fatal(err) + } + if nf.Valid { + dbt.Error("valid NullFloat64 which should be invalid") + } + // Valid + if err = nonNullStmt.QueryRow().Scan(&nf); err != nil { + dbt.Fatal(err) + } + if !nf.Valid { + dbt.Error("invalid NullFloat64 which should be valid") + } else if nf.Float64 != float64(1) { + dbt.Errorf("unexpected NullFloat64 value: %f (should be 1.0)", nf.Float64) + } + + // NullInt64 + var ni sql.NullInt64 + // Invalid + if err = nullStmt.QueryRow().Scan(&ni); err != nil { + dbt.Fatal(err) + } + if ni.Valid { + dbt.Error("valid NullInt64 which should be invalid") + } + // Valid + if err = nonNullStmt.QueryRow().Scan(&ni); err != nil { + dbt.Fatal(err) + } + if !ni.Valid { + dbt.Error("invalid NullInt64 which should be valid") + } else if ni.Int64 != int64(1) { + dbt.Errorf("unexpected NullInt64 value: %d (should be 1)", ni.Int64) + } + + // NullString + var ns sql.NullString + // Invalid + if err = nullStmt.QueryRow().Scan(&ns); err != nil { + dbt.Fatal(err) + } + if ns.Valid { + dbt.Error("valid NullString which should be invalid") + } + // Valid + if err = nonNullStmt.QueryRow().Scan(&ns); err != nil { + dbt.Fatal(err) + } + if !ns.Valid { + dbt.Error("invalid NullString which should be valid") + } else if ns.String != `1` { + dbt.Error("unexpected NullString value:" + ns.String + " (should be `1`)") + } + + // nil-bytes + var b []byte + // Read nil + if err = nullStmt.QueryRow().Scan(&b); err != nil { + dbt.Fatal(err) + } + if b != nil { + dbt.Error("non-nil []byte which should be nil") + } + // Read non-nil + if err = nonNullStmt.QueryRow().Scan(&b); err != nil { + dbt.Fatal(err) + } + if b == nil { + dbt.Error("nil []byte which should be non-nil") + } + // Insert nil + b = nil + success := false + if err = dbt.db.QueryRow("SELECT ? IS NULL", b).Scan(&success); err != nil { + dbt.Fatal(err) + } + if !success { + dbt.Error("inserting []byte(nil) as NULL failed") + } + // Check input==output with input==nil + b = nil + if err = dbt.db.QueryRow("SELECT ?", b).Scan(&b); err != nil { + dbt.Fatal(err) + } + if b != nil { + dbt.Error("non-nil echo from nil input") + } + // Check input==output with input!=nil + b = []byte("") + if err = dbt.db.QueryRow("SELECT ?", b).Scan(&b); err != nil { + dbt.Fatal(err) + } + if b == nil { + dbt.Error("nil echo from non-nil input") + } + + // Insert NULL + dbt.mustExec("CREATE TABLE " + tbl + " (dummmy1 int, value int, dummy2 int)") + + dbt.mustExec("INSERT INTO "+tbl+" VALUES (?, ?, ?)", 1, nil, 2) + + var out any + rows := dbt.mustQuery("SELECT * FROM " + tbl) + defer rows.Close() + if rows.Next() { + rows.Scan(&out) + if out != nil { + dbt.Errorf("%v != nil", out) + } + } else { + dbt.Error("no data") + } + }) +} + +func TestUint64(t *testing.T) { + const ( + u0 = uint64(0) + uall = ^u0 + uhigh = uall >> 1 + utop = ^uhigh + s0 = int64(0) + sall = ^s0 + shigh = int64(uhigh) + stop = ^shigh + ) + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + stmt, err := dbt.db.Prepare(`SELECT ?, ?, ? ,?, ?, ?, ?, ?`) + if err != nil { + dbt.Fatal(err) + } + defer stmt.Close() + row := stmt.QueryRow( + u0, uhigh, utop, uall, + s0, shigh, stop, sall, + ) + + var ua, ub, uc, ud uint64 + var sa, sb, sc, sd int64 + + err = row.Scan(&ua, &ub, &uc, &ud, &sa, &sb, &sc, &sd) + if err != nil { + dbt.Fatal(err) + } + switch { + case ua != u0, + ub != uhigh, + uc != utop, + ud != uall, + sa != s0, + sb != shigh, + sc != stop, + sd != sall: + dbt.Fatal("unexpected result value") + } + }) +} + +func TestLongData(t *testing.T) { + runTests(t, dsn+"&maxAllowedPacket=0", func(dbt *DBTest) { + var maxAllowedPacketSize int + err := dbt.db.QueryRow("select @@max_allowed_packet").Scan(&maxAllowedPacketSize) + if err != nil { + dbt.Fatal(err) + } + maxAllowedPacketSize-- + + // don't get too ambitious + if maxAllowedPacketSize > 1<<25 { + maxAllowedPacketSize = 1 << 25 + } + + dbt.mustExec("CREATE TABLE test (value LONGBLOB)") + + in := strings.Repeat(`a`, maxAllowedPacketSize+1) + var out string + var rows *sql.Rows + + // Long text data + inS := in[:maxAllowedPacketSize-100] + dbt.mustExec("INSERT INTO test VALUES('" + inS + "')") + rows = dbt.mustQuery("SELECT value FROM test") + defer rows.Close() + if rows.Next() { + rows.Scan(&out) + if inS != out { + dbt.Fatalf("LONGBLOB: length in: %d, length out: %d", len(inS), len(out)) + } + if rows.Next() { + dbt.Error("LONGBLOB: unexpected row") + } + } else { + dbt.Fatalf("LONGBLOB: no data") + } + + // Empty table + dbt.mustExec("TRUNCATE TABLE test") + + // Long binary data + dbt.mustExec("INSERT INTO test VALUES(?)", in) + rows = dbt.mustQuery("SELECT value FROM test WHERE 1=?", 1) + defer rows.Close() + if rows.Next() { + rows.Scan(&out) + if in != out { + dbt.Fatalf("LONGBLOB: length in: %d, length out: %d", len(in), len(out)) + } + if rows.Next() { + dbt.Error("LONGBLOB: unexpected row") + } + } else { + if err = rows.Err(); err != nil { + dbt.Fatalf("LONGBLOB: no data (err: %s)", err.Error()) + } else { + dbt.Fatal("LONGBLOB: no data (err: )") + } + } + }) +} + +func TestLoadData(t *testing.T) { + runTests(t, dsn, func(dbt *DBTest) { + verifyLoadDataResult := func() { + rows, err := dbt.db.Query("SELECT * FROM test") + if err != nil { + dbt.Fatal(err.Error()) + } + + i := 0 + values := [4]string{ + "a string", + "a string containing a \t", + "a string containing a \n", + "a string containing both \t\n", + } + + var id int + var value string + + for rows.Next() { + i++ + err = rows.Scan(&id, &value) + if err != nil { + dbt.Fatal(err.Error()) + } + if i != id { + dbt.Fatalf("%d != %d", i, id) + } + if values[i-1] != value { + dbt.Fatalf("%q != %q", values[i-1], value) + } + } + err = rows.Err() + if err != nil { + dbt.Fatal(err.Error()) + } + + if i != 4 { + dbt.Fatalf("rows count mismatch. Got %d, want 4", i) + } + } + + dbt.db.Exec("DROP TABLE IF EXISTS test") + dbt.mustExec("CREATE TABLE test (id INT NOT NULL PRIMARY KEY, value TEXT NOT NULL) CHARACTER SET utf8") + + // Local File + file, err := os.CreateTemp("", "gotest") + defer os.Remove(file.Name()) + if err != nil { + dbt.Fatal(err) + } + RegisterLocalFile(file.Name()) + + // Try first with empty file + dbt.mustExec(fmt.Sprintf("LOAD DATA LOCAL INFILE %q INTO TABLE test", file.Name())) + var count int + err = dbt.db.QueryRow("SELECT COUNT(*) FROM test").Scan(&count) + if err != nil { + dbt.Fatal(err.Error()) + } + if count != 0 { + dbt.Fatalf("unexpected row count: got %d, want 0", count) + } + + // Then fill File with data and try to load it + file.WriteString("1\ta string\n2\ta string containing a \\t\n3\ta string containing a \\n\n4\ta string containing both \\t\\n\n") + file.Close() + dbt.mustExec(fmt.Sprintf("LOAD DATA LOCAL INFILE %q INTO TABLE test", file.Name())) + verifyLoadDataResult() + + // Try with non-existing file + _, err = dbt.db.Exec("LOAD DATA LOCAL INFILE 'doesnotexist' INTO TABLE test") + if err == nil { + dbt.Fatal("load non-existent file didn't fail") + } else if err.Error() != "local file 'doesnotexist' is not registered" { + dbt.Fatal(err.Error()) + } + + // Empty table + dbt.mustExec("TRUNCATE TABLE test") + + // Reader + RegisterReaderHandler("test", func() io.Reader { + file, err = os.Open(file.Name()) + if err != nil { + dbt.Fatal(err) + } + return file + }) + dbt.mustExec("LOAD DATA LOCAL INFILE 'Reader::test' INTO TABLE test") + verifyLoadDataResult() + // negative test + _, err = dbt.db.Exec("LOAD DATA LOCAL INFILE 'Reader::doesnotexist' INTO TABLE test") + if err == nil { + dbt.Fatal("load non-existent Reader didn't fail") + } else if err.Error() != "reader 'doesnotexist' is not registered" { + dbt.Fatal(err.Error()) + } + }) +} + +func TestFoundRows1(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (id INT NOT NULL ,data INT NOT NULL)") + dbt.mustExec("INSERT INTO " + tbl + " (id, data) VALUES (0, 0),(0, 0),(1, 0),(1, 0),(1, 1)") + + res := dbt.mustExec("UPDATE " + tbl + " SET data = 1 WHERE id = 0") + count, err := res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 2 { + dbt.Fatalf("Expected 2 affected rows, got %d", count) + } + res = dbt.mustExec("UPDATE " + tbl + " SET data = 1 WHERE id = 1") + count, err = res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 2 { + dbt.Fatalf("Expected 2 affected rows, got %d", count) + } + }) +} + +func TestFoundRows2(t *testing.T) { + runTestsParallel(t, dsn+"&clientFoundRows=true", func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (id INT NOT NULL ,data INT NOT NULL)") + dbt.mustExec("INSERT INTO " + tbl + " (id, data) VALUES (0, 0),(0, 0),(1, 0),(1, 0),(1, 1)") + + res := dbt.mustExec("UPDATE " + tbl + " SET data = 1 WHERE id = 0") + count, err := res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 2 { + dbt.Fatalf("Expected 2 matched rows, got %d", count) + } + res = dbt.mustExec("UPDATE " + tbl + " SET data = 1 WHERE id = 1") + count, err = res.RowsAffected() + if err != nil { + dbt.Fatalf("res.RowsAffected() returned error: %s", err.Error()) + } + if count != 3 { + dbt.Fatalf("Expected 3 matched rows, got %d", count) + } + }) +} + +func TestTLS(t *testing.T) { + tlsTestReq := func(dbt *DBTest) { + if err := dbt.db.Ping(); err != nil { + if err == ErrNoTLS { + dbt.Skip("server does not support TLS") + } else { + dbt.Fatalf("error on Ping: %s", err.Error()) + } + } + + rows := dbt.mustQuery("SHOW STATUS LIKE 'Ssl_cipher'") + defer rows.Close() + + var variable, value *sql.RawBytes + for rows.Next() { + if err := rows.Scan(&variable, &value); err != nil { + dbt.Fatal(err.Error()) + } + + if len(*value) == 0 { + dbt.Fatalf("no Cipher") + } else { + dbt.Logf("Cipher: %s", *value) + } + } + } + tlsTestOpt := func(dbt *DBTest) { + if err := dbt.db.Ping(); err != nil { + dbt.Fatalf("error on Ping: %s", err.Error()) + } + } + + runTests(t, dsn+"&tls=preferred", tlsTestOpt) + runTests(t, dsn+"&tls=skip-verify", tlsTestReq) + + // Verify that registering / using a custom cfg works + RegisterTLSConfig("custom-skip-verify", &tls.Config{ + InsecureSkipVerify: true, + }) + runTests(t, dsn+"&tls=custom-skip-verify", tlsTestReq) +} + +func TestReuseClosedConnection(t *testing.T) { + // this test does not use sql.database, it uses the driver directly + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + md := &MySQLDriver{} + conn, err := md.Open(dsn) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + stmt, err := conn.Prepare("DO 1") + if err != nil { + t.Fatalf("error preparing statement: %s", err.Error()) + } + //lint:ignore SA1019 this is a test + _, err = stmt.Exec(nil) + if err != nil { + t.Fatalf("error executing statement: %s", err.Error()) + } + err = conn.Close() + if err != nil { + t.Fatalf("error closing connection: %s", err.Error()) + } + + defer func() { + if err := recover(); err != nil { + t.Errorf("panic after reusing a closed connection: %v", err) + } + }() + //lint:ignore SA1019 this is a test + _, err = stmt.Exec(nil) + if err != nil && err != driver.ErrBadConn { + t.Errorf("unexpected error '%s', expected '%s'", + err.Error(), driver.ErrBadConn.Error()) + } +} + +func TestCharset(t *testing.T) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + mustSetCharset := func(charsetParam, expected string) { + runTests(t, dsn+"&"+charsetParam, func(dbt *DBTest) { + rows := dbt.mustQuery("SELECT @@character_set_connection") + defer rows.Close() + + if !rows.Next() { + dbt.Fatalf("error getting connection charset: %s", rows.Err()) + } + + var got string + rows.Scan(&got) + + if got != expected { + dbt.Fatalf("expected connection charset %s but got %s", expected, got) + } + }) + } + + // non utf8 test + mustSetCharset("charset=ascii", "ascii") + + // when the first charset is invalid, use the second + mustSetCharset("charset=none,utf8mb4", "utf8mb4") + + // when the first charset is valid, use it + mustSetCharset("charset=ascii,utf8mb4", "ascii") + mustSetCharset("charset=utf8mb4,ascii", "utf8mb4") +} + +func TestFailingCharset(t *testing.T) { + runTestsParallel(t, dsn+"&charset=none", func(dbt *DBTest, _ string) { + // run query to really establish connection... + _, err := dbt.db.Exec("SELECT 1") + if err == nil { + dbt.db.Close() + t.Fatalf("connection must not succeed without a valid charset") + } + }) +} + +func TestCollation(t *testing.T) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + // MariaDB may override collation specified by handshake with `character_set_collations` variable. + // https://mariadb.com/kb/en/setting-character-sets-and-collations/#changing-default-collation + // https://mariadb.com/kb/en/server-system-variables/#character_set_collations + // utf8mb4_general_ci, utf8mb3_general_ci will be overridden by default MariaDB. + // Collations other than charasets default are not overridden. So utf8mb4_unicode_ci is safe. + testCollations := []string{ + "latin1_general_ci", + "binary", + "utf8mb4_unicode_ci", + "cp1257_bin", + } + + for _, collation := range testCollations { + t.Run(collation, func(t *testing.T) { + tdsn := dsn + "&collation=" + collation + expected := collation + + runTests(t, tdsn, func(dbt *DBTest) { + var got string + if err := dbt.db.QueryRow("SELECT @@collation_connection").Scan(&got); err != nil { + dbt.Fatal(err) + } + if got != expected { + dbt.Fatalf("expected connection collation %s but got %s", expected, got) + } + }) + }) + } +} + +func TestColumnsWithAlias(t *testing.T) { + runTestsParallel(t, dsn+"&columnsWithAlias=true", func(dbt *DBTest, _ string) { + rows := dbt.mustQuery("SELECT 1 AS A") + defer rows.Close() + cols, _ := rows.Columns() + if len(cols) != 1 { + t.Fatalf("expected 1 column, got %d", len(cols)) + } + if cols[0] != "A" { + t.Fatalf("expected column name \"A\", got \"%s\"", cols[0]) + } + + rows = dbt.mustQuery("SELECT * FROM (SELECT 1 AS one) AS A") + defer rows.Close() + cols, _ = rows.Columns() + if len(cols) != 1 { + t.Fatalf("expected 1 column, got %d", len(cols)) + } + if cols[0] != "A.one" { + t.Fatalf("expected column name \"A.one\", got \"%s\"", cols[0]) + } + }) +} + +func TestRawBytesResultExceedsBuffer(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + // defaultBufSize from buffer.go + expected := strings.Repeat("abc", defaultBufSize) + + rows := dbt.mustQuery("SELECT '" + expected + "'") + defer rows.Close() + if !rows.Next() { + dbt.Error("expected result, got none") + } + var result sql.RawBytes + rows.Scan(&result) + if expected != string(result) { + dbt.Error("result did not match expected value") + } + }) +} + +func TestTimezoneConversion(t *testing.T) { + zones := []string{"UTC", "America/New_York", "Asia/Hong_Kong", "Local"} + + // Regression test for timezone handling + tzTest := func(dbt *DBTest) { + // Create table + dbt.mustExec("CREATE TABLE test (ts TIMESTAMP)") + + // Insert local time into database (should be converted) + newYorkTz, _ := time.LoadLocation("America/New_York") + reftime := time.Date(2014, 05, 30, 18, 03, 17, 0, time.UTC).In(newYorkTz) + dbt.mustExec("INSERT INTO test VALUE (?)", reftime) + + // Retrieve time from DB + rows := dbt.mustQuery("SELECT ts FROM test") + defer rows.Close() + if !rows.Next() { + dbt.Fatal("did not get any rows out") + } + + var dbTime time.Time + err := rows.Scan(&dbTime) + if err != nil { + dbt.Fatal("Err", err) + } + + // Check that dates match + if reftime.Unix() != dbTime.Unix() { + dbt.Errorf("times do not match.\n") + dbt.Errorf(" Now(%v)=%v\n", newYorkTz, reftime) + dbt.Errorf(" Now(UTC)=%v\n", dbTime) + } + } + + for _, tz := range zones { + runTests(t, dsn+"&parseTime=true&loc="+url.QueryEscape(tz), tzTest) + } +} + +// Special cases + +func TestRowsClose(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + rows, err := dbt.db.Query("SELECT 1") + if err != nil { + dbt.Fatal(err) + } + + err = rows.Close() + if err != nil { + dbt.Fatal(err) + } + + if rows.Next() { + dbt.Fatal("unexpected row after rows.Close()") + } + + err = rows.Err() + if err != nil { + dbt.Fatal(err) + } + }) +} + +// dangling statements +// http://code.google.com/p/go/issues/detail?id=3865 +func TestCloseStmtBeforeRows(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + stmt, err := dbt.db.Prepare("SELECT 1") + if err != nil { + dbt.Fatal(err) + } + + rows, err := stmt.Query() + if err != nil { + stmt.Close() + dbt.Fatal(err) + } + defer rows.Close() + + err = stmt.Close() + if err != nil { + dbt.Fatal(err) + } + + if !rows.Next() { + dbt.Fatal("getting row failed") + } else { + err = rows.Err() + if err != nil { + dbt.Fatal(err) + } + + var out bool + err = rows.Scan(&out) + if err != nil { + dbt.Fatalf("error on rows.Scan(): %s", err.Error()) + } + if out != true { + dbt.Errorf("true != %t", out) + } + } + }) +} + +// It is valid to have multiple Rows for the same Stmt +// http://code.google.com/p/go/issues/detail?id=3734 +func TestStmtMultiRows(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + stmt, err := dbt.db.Prepare("SELECT 1 UNION SELECT 0") + if err != nil { + dbt.Fatal(err) + } + + rows1, err := stmt.Query() + if err != nil { + stmt.Close() + dbt.Fatal(err) + } + defer rows1.Close() + + rows2, err := stmt.Query() + if err != nil { + stmt.Close() + dbt.Fatal(err) + } + defer rows2.Close() + + var out bool + + // 1 + if !rows1.Next() { + dbt.Fatal("first rows1.Next failed") + } else { + err = rows1.Err() + if err != nil { + dbt.Fatal(err) + } + + err = rows1.Scan(&out) + if err != nil { + dbt.Fatalf("error on rows.Scan(): %s", err.Error()) + } + if out != true { + dbt.Errorf("true != %t", out) + } + } + + if !rows2.Next() { + dbt.Fatal("first rows2.Next failed") + } else { + err = rows2.Err() + if err != nil { + dbt.Fatal(err) + } + + err = rows2.Scan(&out) + if err != nil { + dbt.Fatalf("error on rows.Scan(): %s", err.Error()) + } + if out != true { + dbt.Errorf("true != %t", out) + } + } + + // 2 + if !rows1.Next() { + dbt.Fatal("second rows1.Next failed") + } else { + err = rows1.Err() + if err != nil { + dbt.Fatal(err) + } + + err = rows1.Scan(&out) + if err != nil { + dbt.Fatalf("error on rows.Scan(): %s", err.Error()) + } + if out != false { + dbt.Errorf("false != %t", out) + } + + if rows1.Next() { + dbt.Fatal("unexpected row on rows1") + } + err = rows1.Close() + if err != nil { + dbt.Fatal(err) + } + } + + if !rows2.Next() { + dbt.Fatal("second rows2.Next failed") + } else { + err = rows2.Err() + if err != nil { + dbt.Fatal(err) + } + + err = rows2.Scan(&out) + if err != nil { + dbt.Fatalf("error on rows.Scan(): %s", err.Error()) + } + if out != false { + dbt.Errorf("false != %t", out) + } + + if rows2.Next() { + dbt.Fatal("unexpected row on rows2") + } + err = rows2.Close() + if err != nil { + dbt.Fatal(err) + } + } + }) +} + +// Regression test for +// * more than 32 NULL parameters (issue 209) +// * more parameters than fit into the buffer (issue 201) +// * parameters * 64 > max_allowed_packet (issue 734) +func TestPreparedManyCols(t *testing.T) { + numParams := 65535 + runTests(t, dsn, func(dbt *DBTest) { + query := "SELECT ?" + strings.Repeat(",?", numParams-1) + stmt, err := dbt.db.Prepare(query) + if err != nil { + dbt.Fatal(err) + } + defer stmt.Close() + + // create more parameters than fit into the buffer + // which will take nil-values + params := make([]any, numParams) + rows, err := stmt.Query(params...) + if err != nil { + dbt.Fatal(err) + } + rows.Close() + + // Create 0byte string which we can't send via STMT_LONG_DATA. + for i := range numParams { + params[i] = "" + } + rows, err = stmt.Query(params...) + if err != nil { + dbt.Fatal(err) + } + rows.Close() + }) +} + +func TestConcurrent(t *testing.T) { + if enabled, _ := readBool(os.Getenv("MYSQL_TEST_CONCURRENT")); !enabled { + t.Skip("MYSQL_TEST_CONCURRENT env var not set") + } + + runTests(t, dsn, func(dbt *DBTest) { + // var version string + // if err := dbt.db.QueryRow("SELECT @@version").Scan(&version); err != nil { + // dbt.Fatal(err) + // } + // if strings.Contains(strings.ToLower(version), "mariadb") { + // t.Skip(`TODO: "fix commands out of sync. Did you run multiple statements at once?" on MariaDB`) + // } + + var max int + err := dbt.db.QueryRow("SELECT @@max_connections").Scan(&max) + if err != nil { + dbt.Fatalf("%s", err.Error()) + } + dbt.Logf("testing up to %d concurrent connections \r\n", max) + + var remaining, succeeded int32 = int32(max), 0 + + var wg sync.WaitGroup + wg.Add(max) + + var fatalError string + var once sync.Once + fatalf := func(s string, vals ...any) { + once.Do(func() { + fatalError = fmt.Sprintf(s, vals...) + }) + } + + for i := range max { + go func(id int) { + defer wg.Done() + + tx, err := dbt.db.Begin() + + if err != nil { + if err.Error() != "Error 1040: Too many connections" { + fatalf("error on conn %d: %s", id, err.Error()) + } + return + } + + // keep the connection busy until all connections are open + for atomic.AddInt32(&remaining, -1) > 0 { + if _, err = tx.Exec("DO 1"); err != nil { + fatalf("error on conn %d: %s", id, err.Error()) + return + } + } + + if err = tx.Commit(); err != nil { + fatalf("error on conn %d: %s", id, err.Error()) + return + } + + // everything went fine with this connection + atomic.AddInt32(&succeeded, 1) + }(i) + } + + // wait until all connections are open + wg.Wait() + + if fatalError != "" { + dbt.Fatal(fatalError) + } + + dbt.Logf("reached %d concurrent connections\r\n", succeeded) + }) +} + +func testDialError(t *testing.T, dialErr error, expectErr error) { + RegisterDialContext("mydial", func(ctx context.Context, addr string) (net.Conn, error) { + return nil, dialErr + }) + + db, err := sql.Open(driverNameTest, fmt.Sprintf("%s:%s@mydial(%s)/%s?timeout=30s", user, pass, addr, dbname)) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + defer db.Close() + + _, err = db.Exec("DO 1") + if err != expectErr { + t.Fatalf("was expecting %s. Got: %s", dialErr, err) + } +} + +func TestDialUnknownError(t *testing.T) { + testErr := fmt.Errorf("test") + testDialError(t, testErr, testErr) +} + +func TestDialNonRetryableNetErr(t *testing.T) { + testErr := netErrorMock{} + testDialError(t, testErr, testErr) +} + +func TestDialTemporaryNetErr(t *testing.T) { + testErr := netErrorMock{temporary: true} + testDialError(t, testErr, testErr) +} + +// Tests custom dial functions +func TestCustomDial(t *testing.T) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + // our custom dial function which just wraps net.Dial here + RegisterDialContext("mydial", func(ctx context.Context, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, prot, addr) + }) + + db, err := sql.Open(driverNameTest, fmt.Sprintf("%s:%s@mydial(%s)/%s?timeout=30s", user, pass, addr, dbname)) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + defer db.Close() + + if _, err = db.Exec("DO 1"); err != nil { + t.Fatalf("connection failed: %s", err.Error()) + } +} + +func TestBeforeConnect(t *testing.T) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + // dbname is set in the BeforeConnect handle + cfg, err := ParseDSN(fmt.Sprintf("%s:%s@%s/%s?timeout=30s", user, pass, netAddr, "_")) + if err != nil { + t.Fatalf("error parsing DSN: %v", err) + } + + cfg.Apply(BeforeConnect(func(ctx context.Context, c *Config) error { + c.DBName = dbname + return nil + })) + + connector, err := NewConnector(cfg) + if err != nil { + t.Fatalf("error creating connector: %v", err) + } + + db := sql.OpenDB(connector) + defer db.Close() + + var connectedDb string + err = db.QueryRow("SELECT DATABASE();").Scan(&connectedDb) + if err != nil { + t.Fatalf("error executing query: %v", err) + } + if connectedDb != dbname { + t.Fatalf("expected to connect to DB %s, but connected to %s instead", dbname, connectedDb) + } +} + +func TestSQLInjection(t *testing.T) { + createTest := func(arg string) func(dbt *DBTest) { + return func(dbt *DBTest) { + dbt.mustExec("CREATE TABLE test (v INTEGER)") + dbt.mustExec("INSERT INTO test VALUES (?)", 1) + + var v int + // NULL can't be equal to anything, the idea here is to inject query so it returns row + // This test verifies that escapeQuotes and escapeBackslash are working properly + err := dbt.db.QueryRow("SELECT v FROM test WHERE NULL = ?", arg).Scan(&v) + if err == sql.ErrNoRows { + return // success, sql injection failed + } else if err == nil { + dbt.Errorf("sql injection successful with arg: %s", arg) + } else { + dbt.Errorf("error running query with arg: %s; err: %s", arg, err.Error()) + } + } + } + + dsns := []string{ + dsn, + dsn + "&sql_mode='NO_BACKSLASH_ESCAPES'", + } + for _, testdsn := range dsns { + runTests(t, testdsn, createTest("1 OR 1=1")) + runTests(t, testdsn, createTest("' OR '1'='1")) + } +} + +// Test if inserted data is correctly retrieved after being escaped +func TestInsertRetrieveEscapedData(t *testing.T) { + testData := func(dbt *DBTest) { + dbt.mustExec("CREATE TABLE test (v VARCHAR(255))") + + // All sequences that are escaped by escapeQuotes and escapeBackslash + v := "foo \x00\n\r\x1a\"'\\" + dbt.mustExec("INSERT INTO test VALUES (?)", v) + + var out string + err := dbt.db.QueryRow("SELECT v FROM test").Scan(&out) + if err != nil { + dbt.Fatalf("%s", err.Error()) + } + + if out != v { + dbt.Errorf("%q != %q", out, v) + } + } + + dsns := []string{ + dsn, + dsn + "&sql_mode='NO_BACKSLASH_ESCAPES'", + } + for _, testdsn := range dsns { + runTests(t, testdsn, testData) + } +} + +func TestUnixSocketAuthFail(t *testing.T) { + runTests(t, dsn, func(dbt *DBTest) { + // Save the current logger so we can restore it. + oldLogger := defaultLogger + + // Set a new logger so we can capture its output. + buffer := bytes.NewBuffer(make([]byte, 0, 64)) + newLogger := log.New(buffer, "prefix: ", 0) + SetLogger(newLogger) + + // Restore the logger. + defer SetLogger(oldLogger) + + // Make a new DSN that uses the MySQL socket file and a bad password, which + // we can make by simply appending any character to the real password. + badPass := pass + "x" + socket := "" + if prot == "unix" { + socket = addr + } else { + // Get socket file from MySQL. + err := dbt.db.QueryRow("SELECT @@socket").Scan(&socket) + if err != nil { + t.Fatalf("error on SELECT @@socket: %s", err.Error()) + } + } + t.Logf("socket: %s", socket) + badDSN := fmt.Sprintf("%s:%s@unix(%s)/%s?timeout=30s", user, badPass, socket, dbname) + db, err := sql.Open(driverNameTest, badDSN) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + defer db.Close() + + // Connect to MySQL for real. This will cause an auth failure. + err = db.Ping() + if err == nil { + t.Error("expected Ping() to return an error") + } + + // The driver should not log anything. + if actual := buffer.String(); actual != "" { + t.Errorf("expected no output, got %q", actual) + } + }) +} + +// See Issue #422 +func TestInterruptBySignal(t *testing.T) { + runTestsWithMultiStatement(t, dsn, func(dbt *DBTest) { + dbt.mustExec(` + DROP PROCEDURE IF EXISTS test_signal; + CREATE PROCEDURE test_signal(ret INT) + BEGIN + SELECT ret; + SIGNAL SQLSTATE + '45001' + SET + MESSAGE_TEXT = "an error", + MYSQL_ERRNO = 45001; + END + `) + defer dbt.mustExec("DROP PROCEDURE test_signal") + + var val int + + // text protocol + rows, err := dbt.db.Query("CALL test_signal(42)") + if err != nil { + dbt.Fatalf("error on text query: %s", err.Error()) + } + for rows.Next() { + if err := rows.Scan(&val); err != nil { + dbt.Error(err) + } else if val != 42 { + dbt.Errorf("expected val to be 42") + } + } + rows.Close() + + // binary protocol + rows, err = dbt.db.Query("CALL test_signal(?)", 42) + if err != nil { + dbt.Fatalf("error on binary query: %s", err.Error()) + } + for rows.Next() { + if err := rows.Scan(&val); err != nil { + dbt.Error(err) + } else if val != 42 { + dbt.Errorf("expected val to be 42") + } + } + rows.Close() + }) +} + +func TestColumnsReusesSlice(t *testing.T) { + rows := mysqlRows{ + rs: resultSet{ + columns: []mysqlField{ + { + tableName: "test", + name: "A", + }, + { + tableName: "test", + name: "B", + }, + }, + }, + } + + allocs := testing.AllocsPerRun(1, func() { + cols := rows.Columns() + + if len(cols) != 2 { + t.Fatalf("expected 2 columns, got %d", len(cols)) + } + }) + + if allocs != 0 { + t.Fatalf("expected 0 allocations, got %d", int(allocs)) + } + + if rows.rs.columnNames == nil { + t.Fatalf("expected columnNames to be set, got nil") + } +} + +func TestRejectReadOnly(t *testing.T) { + runTests(t, dsn, func(dbt *DBTest) { + // Create Table + dbt.mustExec("CREATE TABLE test (value BOOL)") + // Set the session to read-only. We didn't set the `rejectReadOnly` + // option, so any writes after this should fail. + _, err := dbt.db.Exec("SET SESSION TRANSACTION READ ONLY") + // Error 1193: Unknown system variable 'TRANSACTION' => skip test, + // MySQL server version is too old + maybeSkip(t, err, 1193) + if _, err := dbt.db.Exec("DROP TABLE test"); err == nil { + t.Fatalf("writing to DB in read-only session without " + + "rejectReadOnly did not error") + } + // Set the session back to read-write so runTests() can properly clean + // up the table `test`. + dbt.mustExec("SET SESSION TRANSACTION READ WRITE") + }) + + // Enable the `rejectReadOnly` option. + runTests(t, dsn+"&rejectReadOnly=true", func(dbt *DBTest) { + // Create Table + dbt.mustExec("CREATE TABLE test (value BOOL)") + // Set the session to read only. Any writes after this should error on + // a driver.ErrBadConn, and cause `database/sql` to initiate a new + // connection. + dbt.mustExec("SET SESSION TRANSACTION READ ONLY") + // This would error, but `database/sql` should automatically retry on a + // new connection which is not read-only, and eventually succeed. + dbt.mustExec("DROP TABLE test") + }) +} + +func TestPing(t *testing.T) { + ctx := context.Background() + runTests(t, dsn, func(dbt *DBTest) { + if err := dbt.db.Ping(); err != nil { + dbt.fail("Ping", "Ping", err) + } + }) + + runTests(t, dsn, func(dbt *DBTest) { + conn, err := dbt.db.Conn(ctx) + if err != nil { + dbt.fail("db", "Conn", err) + } + + // Check that affectedRows and insertIds are cleared after each call. + conn.Raw(func(conn any) error { + c := conn.(*mysqlConn) + + // Issue a query that sets affectedRows and insertIds. + q, err := c.Query(`SELECT 1`, nil) + if err != nil { + dbt.fail("Conn", "Query", err) + } + if got, want := c.result.affectedRows, []int64{0}; !reflect.DeepEqual(got, want) { + dbt.Fatalf("bad affectedRows: got %v, want=%v", got, want) + } + if got, want := c.result.insertIds, []int64{0}; !reflect.DeepEqual(got, want) { + dbt.Fatalf("bad insertIds: got %v, want=%v", got, want) + } + q.Close() + + // Verify that Ping() clears both fields. + for range 2 { + if err := c.Ping(ctx); err != nil { + dbt.fail("Pinger", "Ping", err) + } + if got, want := c.result.affectedRows, []int64(nil); !reflect.DeepEqual(got, want) { + t.Errorf("bad affectedRows: got %v, want=%v", got, want) + } + if got, want := c.result.insertIds, []int64(nil); !reflect.DeepEqual(got, want) { + t.Errorf("bad affectedRows: got %v, want=%v", got, want) + } + } + return nil + }) + }) +} + +// See Issue #799 +func TestEmptyPassword(t *testing.T) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + dsn := fmt.Sprintf("%s:%s@%s/%s?timeout=30s", user, "", netAddr, dbname) + db, err := sql.Open(driverNameTest, dsn) + if err == nil { + defer db.Close() + err = db.Ping() + } + + if pass == "" { + if err != nil { + t.Fatal(err.Error()) + } + } else { + if err == nil { + t.Fatal("expected authentication error") + } + if !strings.HasPrefix(err.Error(), "Error 1045") { + t.Fatal(err.Error()) + } + } +} + +// static interface implementation checks of mysqlConn +var ( + _ driver.ConnBeginTx = &mysqlConn{} + _ driver.ConnPrepareContext = &mysqlConn{} + _ driver.ExecerContext = &mysqlConn{} + _ driver.Pinger = &mysqlConn{} + _ driver.QueryerContext = &mysqlConn{} +) + +// static interface implementation checks of mysqlStmt +var ( + _ driver.StmtExecContext = &mysqlStmt{} + _ driver.StmtQueryContext = &mysqlStmt{} +) + +// Ensure that all the driver interfaces are implemented +var ( + // _ driver.RowsColumnTypeLength = &binaryRows{} + // _ driver.RowsColumnTypeLength = &textRows{} + _ driver.RowsColumnTypeDatabaseTypeName = &binaryRows{} + _ driver.RowsColumnTypeDatabaseTypeName = &textRows{} + _ driver.RowsColumnTypeNullable = &binaryRows{} + _ driver.RowsColumnTypeNullable = &textRows{} + _ driver.RowsColumnTypePrecisionScale = &binaryRows{} + _ driver.RowsColumnTypePrecisionScale = &textRows{} + _ driver.RowsColumnTypeScanType = &binaryRows{} + _ driver.RowsColumnTypeScanType = &textRows{} + _ driver.RowsNextResultSet = &binaryRows{} + _ driver.RowsNextResultSet = &textRows{} +) + +func TestMultiResultSet(t *testing.T) { + type result struct { + values [][]int + columns []string + } + + // checkRows is a helper test function to validate rows containing 3 result + // sets with specific values and columns. The basic query would look like this: + // + // SELECT 1 AS col1, 2 AS col2 UNION SELECT 3, 4; + // SELECT 0 UNION SELECT 1; + // SELECT 1 AS col1, 2 AS col2, 3 AS col3 UNION SELECT 4, 5, 6; + // + // to distinguish test cases the first string argument is put in front of + // every error or fatal message. + checkRows := func(desc string, rows *sql.Rows, dbt *DBTest) { + expected := []result{ + { + values: [][]int{{1, 2}, {3, 4}}, + columns: []string{"col1", "col2"}, + }, + { + values: [][]int{{1, 2, 3}, {4, 5, 6}}, + columns: []string{"col1", "col2", "col3"}, + }, + } + + var res1 result + for rows.Next() { + var res [2]int + if err := rows.Scan(&res[0], &res[1]); err != nil { + dbt.Fatal(err) + } + res1.values = append(res1.values, res[:]) + } + + cols, err := rows.Columns() + if err != nil { + dbt.Fatal(desc, err) + } + res1.columns = cols + + if !reflect.DeepEqual(expected[0], res1) { + dbt.Error(desc, "want =", expected[0], "got =", res1) + } + + if !rows.NextResultSet() { + dbt.Fatal(desc, "expected next result set") + } + + // ignoring one result set + + if !rows.NextResultSet() { + dbt.Fatal(desc, "expected next result set") + } + + var res2 result + cols, err = rows.Columns() + if err != nil { + dbt.Fatal(desc, err) + } + res2.columns = cols + + for rows.Next() { + var res [3]int + if err := rows.Scan(&res[0], &res[1], &res[2]); err != nil { + dbt.Fatal(desc, err) + } + res2.values = append(res2.values, res[:]) + } + + if !reflect.DeepEqual(expected[1], res2) { + dbt.Error(desc, "want =", expected[1], "got =", res2) + } + + if rows.NextResultSet() { + dbt.Error(desc, "unexpected next result set") + } + + if err := rows.Err(); err != nil { + dbt.Error(desc, err) + } + } + + runTestsWithMultiStatement(t, dsn, func(dbt *DBTest) { + rows := dbt.mustQuery(`DO 1; + SELECT 1 AS col1, 2 AS col2 UNION SELECT 3, 4; + DO 1; + SELECT 0 UNION SELECT 1; + SELECT 1 AS col1, 2 AS col2, 3 AS col3 UNION SELECT 4, 5, 6;`) + defer rows.Close() + checkRows("query: ", rows, dbt) + }) + + runTestsWithMultiStatement(t, dsn, func(dbt *DBTest) { + queries := []string{ + ` + DROP PROCEDURE IF EXISTS test_mrss; + CREATE PROCEDURE test_mrss() + BEGIN + DO 1; + SELECT 1 AS col1, 2 AS col2 UNION SELECT 3, 4; + DO 1; + SELECT 0 UNION SELECT 1; + SELECT 1 AS col1, 2 AS col2, 3 AS col3 UNION SELECT 4, 5, 6; + END + `, + ` + DROP PROCEDURE IF EXISTS test_mrss; + CREATE PROCEDURE test_mrss() + BEGIN + SELECT 1 AS col1, 2 AS col2 UNION SELECT 3, 4; + SELECT 0 UNION SELECT 1; + SELECT 1 AS col1, 2 AS col2, 3 AS col3 UNION SELECT 4, 5, 6; + END + `, + } + + defer dbt.mustExec("DROP PROCEDURE IF EXISTS test_mrss") + + for i, query := range queries { + dbt.mustExec(query) + + stmt, err := dbt.db.Prepare("CALL test_mrss()") + if err != nil { + dbt.Fatalf("%v (i=%d)", err, i) + } + defer stmt.Close() + + for j := range 2 { + rows, err := stmt.Query() + if err != nil { + dbt.Fatalf("%v (i=%d) (j=%d)", err, i, j) + } + checkRows(fmt.Sprintf("prepared stmt query (i=%d) (j=%d): ", i, j), rows, dbt) + } + } + }) +} + +func TestMultiResultSetNoSelect(t *testing.T) { + runTestsWithMultiStatement(t, dsn, func(dbt *DBTest) { + rows := dbt.mustQuery("DO 1; DO 2;") + defer rows.Close() + + if rows.Next() { + dbt.Error("unexpected row") + } + + if rows.NextResultSet() { + dbt.Error("unexpected next result set") + } + + if err := rows.Err(); err != nil { + dbt.Error("expected nil; got ", err) + } + }) +} + +func TestExecMultipleResults(t *testing.T) { + ctx := context.Background() + runTestsWithMultiStatement(t, dsn, func(dbt *DBTest) { + dbt.mustExec(` + CREATE TABLE test ( + id INT NOT NULL AUTO_INCREMENT, + value VARCHAR(255), + PRIMARY KEY (id) + )`) + conn, err := dbt.db.Conn(ctx) + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + conn.Raw(func(conn any) error { + //lint:ignore SA1019 this is a test + ex := conn.(driver.Execer) + res, err := ex.Exec(` + INSERT INTO test (value) VALUES ('a'), ('b'); + INSERT INTO test (value) VALUES ('c'), ('d'), ('e'); + `, nil) + if err != nil { + t.Fatalf("insert statements failed: %v", err) + } + mres := res.(Result) + if got, want := mres.AllRowsAffected(), []int64{2, 3}; !reflect.DeepEqual(got, want) { + t.Errorf("bad AllRowsAffected: got %v, want=%v", got, want) + } + // For INSERTs containing multiple rows, LAST_INSERT_ID() returns the + // first inserted ID, not the last. + if got, want := mres.AllLastInsertIds(), []int64{1, 3}; !reflect.DeepEqual(got, want) { + t.Errorf("bad AllLastInsertIds: got %v, want %v", got, want) + } + return nil + }) + }) +} + +// tests if rows are set in a proper state if some results were ignored before +// calling rows.NextResultSet. +func TestSkipResults(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + rows := dbt.mustQuery("SELECT 1, 2") + defer rows.Close() + + if !rows.Next() { + dbt.Error("expected row") + } + + if rows.NextResultSet() { + dbt.Error("unexpected next result set") + } + + if err := rows.Err(); err != nil { + dbt.Error("expected nil; got ", err) + } + }) +} + +func TestQueryMultipleResults(t *testing.T) { + ctx := context.Background() + runTestsWithMultiStatement(t, dsn, func(dbt *DBTest) { + dbt.mustExec(` + CREATE TABLE test ( + id INT NOT NULL AUTO_INCREMENT, + value VARCHAR(255), + PRIMARY KEY (id) + )`) + conn, err := dbt.db.Conn(ctx) + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + conn.Raw(func(conn any) error { + //lint:ignore SA1019 this is a test + qr := conn.(driver.Queryer) + c := conn.(*mysqlConn) + + // Demonstrate that repeated queries reset the affectedRows + for range 2 { + _, err := qr.Query(` + INSERT INTO test (value) VALUES ('a'), ('b'); + INSERT INTO test (value) VALUES ('c'), ('d'), ('e'); + `, nil) + if err != nil { + t.Fatalf("insert statements failed: %v", err) + } + if got, want := c.result.affectedRows, []int64{2, 3}; !reflect.DeepEqual(got, want) { + t.Errorf("bad affectedRows: got %v, want=%v", got, want) + } + } + return nil + }) + }) +} + +func TestPingContext(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := dbt.db.PingContext(ctx); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + }) +} + +func TestContextCancelExec(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (v INTEGER)") + ctx, cancel := context.WithCancel(context.Background()) + + // Delay execution for just a bit until db.ExecContext has begun. + defer time.AfterFunc(250*time.Millisecond, cancel).Stop() + + // This query will be canceled. + startTime := time.Now() + if _, err := dbt.db.ExecContext(ctx, "INSERT INTO "+tbl+" VALUES (SLEEP(1))"); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + if d := time.Since(startTime); d > 500*time.Millisecond { + dbt.Errorf("too long execution time: %s", d) + } + + // Wait for the INSERT query to be done. + time.Sleep(time.Second) + + // Check how many times the query is executed. + var v int + if err := dbt.db.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&v); err != nil { + dbt.Fatalf("%s", err.Error()) + } + if v != 1 { // TODO: need to kill the query, and v should be 0. + dbt.Skipf("[WARN] expected val to be 1, got %d", v) + } + + // Context is already canceled, so error should come before execution. + if _, err := dbt.db.ExecContext(ctx, "INSERT INTO "+tbl+" VALUES (1)"); err == nil { + dbt.Error("expected error") + } else if err.Error() != "context canceled" { + dbt.Fatalf("unexpected error: %s", err) + } + + // The second insert query will fail, so the table has no changes. + if err := dbt.db.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&v); err != nil { + dbt.Fatalf("%s", err.Error()) + } + if v != 1 { + dbt.Skipf("[WARN] expected val to be 1, got %d", v) + } + }) +} + +func TestContextCancelQuery(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (v INTEGER)") + ctx, cancel := context.WithCancel(context.Background()) + + // Delay execution for just a bit until db.ExecContext has begun. + defer time.AfterFunc(250*time.Millisecond, cancel).Stop() + + // This query will be canceled. + startTime := time.Now() + if _, err := dbt.db.QueryContext(ctx, "INSERT INTO "+tbl+" VALUES (SLEEP(1))"); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + if d := time.Since(startTime); d > 500*time.Millisecond { + dbt.Errorf("too long execution time: %s", d) + } + + // Wait for the INSERT query to be done. + time.Sleep(time.Second) + + // Check how many times the query is executed. + var v int + if err := dbt.db.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&v); err != nil { + dbt.Fatalf("%s", err.Error()) + } + if v != 1 { // TODO: need to kill the query, and v should be 0. + dbt.Skipf("[WARN] expected val to be 1, got %d", v) + } + + // Context is already canceled, so error should come before execution. + if _, err := dbt.db.QueryContext(ctx, "INSERT INTO "+tbl+" VALUES (1)"); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + + // The second insert query will fail, so the table has no changes. + if err := dbt.db.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&v); err != nil { + dbt.Fatalf("%s", err.Error()) + } + if v != 1 { + dbt.Skipf("[WARN] expected val to be 1, got %d", v) + } + }) +} + +func TestContextCancelQueryRow(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (v INTEGER)") + dbt.mustExec("INSERT INTO " + tbl + " VALUES (1), (2), (3)") + ctx, cancel := context.WithCancel(context.Background()) + + rows, err := dbt.db.QueryContext(ctx, "SELECT v FROM "+tbl) + if err != nil { + dbt.Fatalf("%s", err.Error()) + } + + // the first row will be succeed. + var v int + if !rows.Next() { + dbt.Fatalf("unexpected end") + } + if err := rows.Scan(&v); err != nil { + dbt.Fatalf("%s", err.Error()) + } + + cancel() + // make sure the driver receives the cancel request. + time.Sleep(100 * time.Millisecond) + + if rows.Next() { + dbt.Errorf("expected end, but not") + } + if err := rows.Err(); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + }) +} + +func TestContextCancelPrepare(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, _ string) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := dbt.db.PrepareContext(ctx, "SELECT 1"); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + }) +} + +func TestContextCancelStmtExec(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (v INTEGER)") + ctx, cancel := context.WithCancel(context.Background()) + stmt, err := dbt.db.PrepareContext(ctx, "INSERT INTO "+tbl+" VALUES (SLEEP(1))") + if err != nil { + dbt.Fatalf("unexpected error: %v", err) + } + + // Delay execution for just a bit until db.ExecContext has begun. + defer time.AfterFunc(250*time.Millisecond, cancel).Stop() + + // This query will be canceled. + startTime := time.Now() + if _, err := stmt.ExecContext(ctx); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + if d := time.Since(startTime); d > 500*time.Millisecond { + dbt.Errorf("too long execution time: %s", d) + } + + // Wait for the INSERT query to be done. + time.Sleep(time.Second) + + // Check how many times the query is executed. + var v int + if err := dbt.db.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&v); err != nil { + dbt.Fatalf("%s", err.Error()) + } + if v != 1 { // TODO: need to kill the query, and v should be 0. + dbt.Skipf("[WARN] expected val to be 1, got %d", v) + } + }) +} + +func TestContextCancelStmtQuery(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (v INTEGER)") + ctx, cancel := context.WithCancel(context.Background()) + stmt, err := dbt.db.PrepareContext(ctx, "INSERT INTO "+tbl+" VALUES (SLEEP(1))") + if err != nil { + dbt.Fatalf("unexpected error: %v", err) + } + + // Delay execution for just a bit until db.ExecContext has begun. + defer time.AfterFunc(250*time.Millisecond, cancel).Stop() + + // This query will be canceled. + startTime := time.Now() + if _, err := stmt.QueryContext(ctx); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + if d := time.Since(startTime); d > 500*time.Millisecond { + dbt.Errorf("too long execution time: %s", d) + } + + // Wait for the INSERT query has done. + time.Sleep(time.Second) + + // Check how many times the query is executed. + var v int + if err := dbt.db.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&v); err != nil { + dbt.Fatalf("%s", err.Error()) + } + if v != 1 { // TODO: need to kill the query, and v should be 0. + dbt.Skipf("[WARN] expected val to be 1, got %d", v) + } + }) +} + +func TestContextCancelBegin(t *testing.T) { + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + t.Skip(`FIXME: it sometime fails with "expected driver.ErrBadConn, got sql: connection is already closed" on windows and macOS`) + } + + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (v INTEGER)") + ctx, cancel := context.WithCancel(context.Background()) + conn, err := dbt.db.Conn(ctx) + if err != nil { + dbt.Fatal(err) + } + defer conn.Close() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + dbt.Fatal(err) + } + + // Delay execution for just a bit until db.ExecContext has begun. + defer time.AfterFunc(100*time.Millisecond, cancel).Stop() + + // This query will be canceled. + startTime := time.Now() + if _, err := tx.ExecContext(ctx, "INSERT INTO "+tbl+" VALUES (SLEEP(1))"); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + if d := time.Since(startTime); d > 500*time.Millisecond { + dbt.Errorf("too long execution time: %s", d) + } + + // Transaction is canceled, so expect an error. + switch err := tx.Commit(); err { + case sql.ErrTxDone: + // because the transaction has already been rollbacked. + // the database/sql package watches ctx + // and rollbacks when ctx is canceled. + case context.Canceled: + // the database/sql package rollbacks on another goroutine, + // so the transaction may not be rollbacked depending on goroutine scheduling. + default: + dbt.Errorf("expected sql.ErrTxDone or context.Canceled, got %v", err) + } + + // The connection is now in an inoperable state - so performing other + // operations should fail with ErrBadConn + // Important to exercise isolation level too - it runs SET TRANSACTION ISOLATION + // LEVEL XXX first, which needs to return ErrBadConn if the connection's context + // is cancelled + _, err = conn.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != driver.ErrBadConn { + dbt.Errorf("expected driver.ErrBadConn, got %v", err) + } + + // cannot begin a transaction (on a different conn) with a canceled context + if _, err := dbt.db.BeginTx(ctx, nil); err != context.Canceled { + dbt.Errorf("expected context.Canceled, got %v", err) + } + }) +} + +func TestContextBeginIsolationLevel(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (v INTEGER)") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tx1, err := dbt.db.BeginTx(ctx, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, + }) + if err != nil { + dbt.Fatal(err) + } + + tx2, err := dbt.db.BeginTx(ctx, &sql.TxOptions{ + Isolation: sql.LevelReadCommitted, + }) + if err != nil { + dbt.Fatal(err) + } + + _, err = tx1.ExecContext(ctx, "INSERT INTO "+tbl+" VALUES (1)") + if err != nil { + dbt.Fatal(err) + } + + var v int + row := tx2.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl) + if err := row.Scan(&v); err != nil { + dbt.Fatal(err) + } + // Because writer transaction wasn't committed yet, it should be available + if v != 0 { + dbt.Errorf("expected val to be 0, got %d", v) + } + + err = tx1.Commit() + if err != nil { + dbt.Fatal(err) + } + + row = tx2.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl) + if err := row.Scan(&v); err != nil { + dbt.Fatal(err) + } + // Data written by writer transaction is already committed, it should be selectable + if v != 1 { + dbt.Errorf("expected val to be 1, got %d", v) + } + tx2.Commit() + }) +} + +func TestContextBeginReadOnly(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (v INTEGER)") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tx, err := dbt.db.BeginTx(ctx, &sql.TxOptions{ + ReadOnly: true, + }) + if _, ok := err.(*MySQLError); ok { + dbt.Skip("It seems that your MySQL does not support READ ONLY transactions") + return + } else if err != nil { + dbt.Fatal(err) + } + + // INSERT queries fail in a READ ONLY transaction. + _, err = tx.ExecContext(ctx, "INSERT INTO "+tbl+" VALUES (1)") + if _, ok := err.(*MySQLError); !ok { + dbt.Errorf("expected MySQLError, got %v", err) + } + + // SELECT queries can be executed. + var v int + row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl) + if err := row.Scan(&v); err != nil { + dbt.Fatal(err) + } + if v != 0 { + dbt.Errorf("expected val to be 0, got %d", v) + } + + if err := tx.Commit(); err != nil { + dbt.Fatal(err) + } + }) +} + +func TestRowsColumnTypes(t *testing.T) { + niNULL := sql.NullInt64{Int64: 0, Valid: false} + ni0 := sql.NullInt64{Int64: 0, Valid: true} + ni1 := sql.NullInt64{Int64: 1, Valid: true} + ni42 := sql.NullInt64{Int64: 42, Valid: true} + nfNULL := sql.NullFloat64{Float64: 0.0, Valid: false} + nf0 := sql.NullFloat64{Float64: 0.0, Valid: true} + nf1337 := sql.NullFloat64{Float64: 13.37, Valid: true} + nt0 := sql.NullTime{Time: time.Date(2006, 01, 02, 15, 04, 05, 0, time.UTC), Valid: true} + nt1 := sql.NullTime{Time: time.Date(2006, 01, 02, 15, 04, 05, 100000000, time.UTC), Valid: true} + nt2 := sql.NullTime{Time: time.Date(2006, 01, 02, 15, 04, 05, 110000000, time.UTC), Valid: true} + nt6 := sql.NullTime{Time: time.Date(2006, 01, 02, 15, 04, 05, 111111000, time.UTC), Valid: true} + nd1 := sql.NullTime{Time: time.Date(2006, 01, 02, 0, 0, 0, 0, time.UTC), Valid: true} + nd2 := sql.NullTime{Time: time.Date(2006, 03, 04, 0, 0, 0, 0, time.UTC), Valid: true} + ndNULL := sql.NullTime{Time: time.Time{}, Valid: false} + bNULL := []byte(nil) + nsNULL := sql.NullString{String: "", Valid: false} + // Helper function to build NullString from string literal. + ns := func(s string) sql.NullString { return sql.NullString{String: s, Valid: true} } + ns0 := ns("0") + b0 := []byte("0") + b42 := []byte("42") + nsTest := ns("Test") + bTest := []byte("Test") + b0pad4 := []byte("0\x00\x00\x00") // BINARY right-pads values with 0x00 + bx0 := []byte("\x00") + bx42 := []byte("\x42") + + var columns = []struct { + name string + fieldType string // type used when creating table schema + databaseTypeName string // actual type used by MySQL + scanType reflect.Type + nullable bool + precision int64 // 0 if not ok + scale int64 + valuesIn [3]string + valuesOut [3]any + }{ + {"bit8null", "BIT(8)", "BIT", scanTypeBytes, true, 0, 0, [3]string{"0x0", "NULL", "0x42"}, [3]any{bx0, bNULL, bx42}}, + {"boolnull", "BOOL", "TINYINT", scanTypeNullInt, true, 0, 0, [3]string{"NULL", "true", "0"}, [3]any{niNULL, ni1, ni0}}, + {"bool", "BOOL NOT NULL", "TINYINT", scanTypeInt8, false, 0, 0, [3]string{"1", "0", "FALSE"}, [3]any{int8(1), int8(0), int8(0)}}, + {"intnull", "INTEGER", "INT", scanTypeNullInt, true, 0, 0, [3]string{"0", "NULL", "42"}, [3]any{ni0, niNULL, ni42}}, + {"smallint", "SMALLINT NOT NULL", "SMALLINT", scanTypeInt16, false, 0, 0, [3]string{"0", "-32768", "32767"}, [3]any{int16(0), int16(-32768), int16(32767)}}, + {"smallintnull", "SMALLINT", "SMALLINT", scanTypeNullInt, true, 0, 0, [3]string{"0", "NULL", "42"}, [3]any{ni0, niNULL, ni42}}, + {"int3null", "INT(3)", "INT", scanTypeNullInt, true, 0, 0, [3]string{"0", "NULL", "42"}, [3]any{ni0, niNULL, ni42}}, + {"int7", "INT(7) NOT NULL", "INT", scanTypeInt32, false, 0, 0, [3]string{"0", "-1337", "42"}, [3]any{int32(0), int32(-1337), int32(42)}}, + {"mediumintnull", "MEDIUMINT", "MEDIUMINT", scanTypeNullInt, true, 0, 0, [3]string{"0", "42", "NULL"}, [3]any{ni0, ni42, niNULL}}, + {"bigint", "BIGINT NOT NULL", "BIGINT", scanTypeInt64, false, 0, 0, [3]string{"0", "65535", "-42"}, [3]any{int64(0), int64(65535), int64(-42)}}, + {"bigintnull", "BIGINT", "BIGINT", scanTypeNullInt, true, 0, 0, [3]string{"NULL", "1", "42"}, [3]any{niNULL, ni1, ni42}}, + {"tinyuint", "TINYINT UNSIGNED NOT NULL", "UNSIGNED TINYINT", scanTypeUint8, false, 0, 0, [3]string{"0", "255", "42"}, [3]any{uint8(0), uint8(255), uint8(42)}}, + {"smalluint", "SMALLINT UNSIGNED NOT NULL", "UNSIGNED SMALLINT", scanTypeUint16, false, 0, 0, [3]string{"0", "65535", "42"}, [3]any{uint16(0), uint16(65535), uint16(42)}}, + {"biguint", "BIGINT UNSIGNED NOT NULL", "UNSIGNED BIGINT", scanTypeUint64, false, 0, 0, [3]string{"0", "65535", "42"}, [3]any{uint64(0), uint64(65535), uint64(42)}}, + {"mediumuint", "MEDIUMINT UNSIGNED NOT NULL", "UNSIGNED MEDIUMINT", scanTypeUint32, false, 0, 0, [3]string{"0", "16777215", "42"}, [3]any{uint32(0), uint32(16777215), uint32(42)}}, + {"uint13", "INT(13) UNSIGNED NOT NULL", "UNSIGNED INT", scanTypeUint32, false, 0, 0, [3]string{"0", "1337", "42"}, [3]any{uint32(0), uint32(1337), uint32(42)}}, + {"float", "FLOAT NOT NULL", "FLOAT", scanTypeFloat32, false, math.MaxInt64, math.MaxInt64, [3]string{"0", "42", "13.37"}, [3]any{float32(0), float32(42), float32(13.37)}}, + {"floatnull", "FLOAT", "FLOAT", scanTypeNullFloat, true, math.MaxInt64, math.MaxInt64, [3]string{"0", "NULL", "13.37"}, [3]any{nf0, nfNULL, nf1337}}, + {"float74null", "FLOAT(7,4)", "FLOAT", scanTypeNullFloat, true, math.MaxInt64, 4, [3]string{"0", "NULL", "13.37"}, [3]any{nf0, nfNULL, nf1337}}, + {"double", "DOUBLE NOT NULL", "DOUBLE", scanTypeFloat64, false, math.MaxInt64, math.MaxInt64, [3]string{"0", "42", "13.37"}, [3]any{float64(0), float64(42), float64(13.37)}}, + {"doublenull", "DOUBLE", "DOUBLE", scanTypeNullFloat, true, math.MaxInt64, math.MaxInt64, [3]string{"0", "NULL", "13.37"}, [3]any{nf0, nfNULL, nf1337}}, + {"decimal1", "DECIMAL(10,6) NOT NULL", "DECIMAL", scanTypeString, false, 10, 6, [3]string{"0", "13.37", "1234.123456"}, [3]any{"0.000000", "13.370000", "1234.123456"}}, + {"decimal1null", "DECIMAL(10,6)", "DECIMAL", scanTypeNullString, true, 10, 6, [3]string{"0", "NULL", "1234.123456"}, [3]any{ns("0.000000"), nsNULL, ns("1234.123456")}}, + {"decimal2", "DECIMAL(8,4) NOT NULL", "DECIMAL", scanTypeString, false, 8, 4, [3]string{"0", "13.37", "1234.123456"}, [3]any{"0.0000", "13.3700", "1234.1235"}}, + {"decimal2null", "DECIMAL(8,4)", "DECIMAL", scanTypeNullString, true, 8, 4, [3]string{"0", "NULL", "1234.123456"}, [3]any{ns("0.0000"), nsNULL, ns("1234.1235")}}, + {"decimal3", "DECIMAL(5,0) NOT NULL", "DECIMAL", scanTypeString, false, 5, 0, [3]string{"0", "13.37", "-12345.123456"}, [3]any{"0", "13", "-12345"}}, + {"decimal3null", "DECIMAL(5,0)", "DECIMAL", scanTypeNullString, true, 5, 0, [3]string{"0", "NULL", "-12345.123456"}, [3]any{ns0, nsNULL, ns("-12345")}}, + {"char25null", "CHAR(25)", "CHAR", scanTypeNullString, true, 0, 0, [3]string{"0", "NULL", "'Test'"}, [3]any{ns0, nsNULL, nsTest}}, + {"varchar42", "VARCHAR(42) NOT NULL", "VARCHAR", scanTypeString, false, 0, 0, [3]string{"0", "'Test'", "42"}, [3]any{"0", "Test", "42"}}, + {"binary4null", "BINARY(4)", "BINARY", scanTypeBytes, true, 0, 0, [3]string{"0", "NULL", "'Test'"}, [3]any{b0pad4, bNULL, bTest}}, + {"varbinary42", "VARBINARY(42) NOT NULL", "VARBINARY", scanTypeBytes, false, 0, 0, [3]string{"0", "'Test'", "42"}, [3]any{b0, bTest, b42}}, + {"tinyblobnull", "TINYBLOB", "BLOB", scanTypeBytes, true, 0, 0, [3]string{"0", "NULL", "'Test'"}, [3]any{b0, bNULL, bTest}}, + {"tinytextnull", "TINYTEXT", "TEXT", scanTypeNullString, true, 0, 0, [3]string{"0", "NULL", "'Test'"}, [3]any{ns0, nsNULL, nsTest}}, + {"blobnull", "BLOB", "BLOB", scanTypeBytes, true, 0, 0, [3]string{"0", "NULL", "'Test'"}, [3]any{b0, bNULL, bTest}}, + {"textnull", "TEXT", "TEXT", scanTypeNullString, true, 0, 0, [3]string{"0", "NULL", "'Test'"}, [3]any{ns0, nsNULL, nsTest}}, + {"mediumblob", "MEDIUMBLOB NOT NULL", "BLOB", scanTypeBytes, false, 0, 0, [3]string{"0", "'Test'", "42"}, [3]any{b0, bTest, b42}}, + {"mediumtext", "MEDIUMTEXT NOT NULL", "TEXT", scanTypeString, false, 0, 0, [3]string{"0", "'Test'", "42"}, [3]any{"0", "Test", "42"}}, + {"longblob", "LONGBLOB NOT NULL", "BLOB", scanTypeBytes, false, 0, 0, [3]string{"0", "'Test'", "42"}, [3]any{b0, bTest, b42}}, + {"longtext", "LONGTEXT NOT NULL", "TEXT", scanTypeString, false, 0, 0, [3]string{"0", "'Test'", "42"}, [3]any{"0", "Test", "42"}}, + {"datetime", "DATETIME", "DATETIME", scanTypeNullTime, true, 0, 0, [3]string{"'2006-01-02 15:04:05'", "'2006-01-02 15:04:05.1'", "'2006-01-02 15:04:05.111111'"}, [3]any{nt0, nt0, nt0}}, + {"datetime2", "DATETIME(2)", "DATETIME", scanTypeNullTime, true, 2, 2, [3]string{"'2006-01-02 15:04:05'", "'2006-01-02 15:04:05.1'", "'2006-01-02 15:04:05.111111'"}, [3]any{nt0, nt1, nt2}}, + {"datetime6", "DATETIME(6)", "DATETIME", scanTypeNullTime, true, 6, 6, [3]string{"'2006-01-02 15:04:05'", "'2006-01-02 15:04:05.1'", "'2006-01-02 15:04:05.111111'"}, [3]any{nt0, nt1, nt6}}, + {"date", "DATE", "DATE", scanTypeNullTime, true, 0, 0, [3]string{"'2006-01-02'", "NULL", "'2006-03-04'"}, [3]any{nd1, ndNULL, nd2}}, + {"year", "YEAR NOT NULL", "YEAR", scanTypeUint16, false, 0, 0, [3]string{"2006", "2000", "1994"}, [3]any{uint16(2006), uint16(2000), uint16(1994)}}, + {"enum", "ENUM('', 'v1', 'v2')", "ENUM", scanTypeNullString, true, 0, 0, [3]string{"''", "'v1'", "'v2'"}, [3]any{ns(""), ns("v1"), ns("v2")}}, + {"set", "set('', 'v1', 'v2')", "SET", scanTypeNullString, true, 0, 0, [3]string{"''", "'v1'", "'v1,v2'"}, [3]any{ns(""), ns("v1"), ns("v1,v2")}}, + } + + schema := "" + values1 := "" + values2 := "" + values3 := "" + for _, column := range columns { + schema += fmt.Sprintf("`%s` %s, ", column.name, column.fieldType) + values1 += column.valuesIn[0] + ", " + values2 += column.valuesIn[1] + ", " + values3 += column.valuesIn[2] + ", " + } + schema = schema[:len(schema)-2] + values1 = values1[:len(values1)-2] + values2 = values2[:len(values2)-2] + values3 = values3[:len(values3)-2] + + runTests(t, dsn+"&parseTime=true", func(dbt *DBTest) { + dbt.mustExec("CREATE TABLE test (" + schema + ")") + dbt.mustExec("INSERT INTO test VALUES (" + values1 + "), (" + values2 + "), (" + values3 + ")") + + rows, err := dbt.db.Query("SELECT * FROM test") + if err != nil { + t.Fatalf("Query: %v", err) + } + + tt, err := rows.ColumnTypes() + if err != nil { + t.Fatalf("ColumnTypes: %v", err) + } + + if len(tt) != len(columns) { + t.Fatalf("unexpected number of columns: expected %d, got %d", len(columns), len(tt)) + } + + types := make([]reflect.Type, len(tt)) + for i, tp := range tt { + column := columns[i] + + // Name + name := tp.Name() + if name != column.name { + t.Errorf("column name mismatch %s != %s", name, column.name) + continue + } + + // DatabaseTypeName + databaseTypeName := tp.DatabaseTypeName() + if databaseTypeName != column.databaseTypeName { + t.Errorf("databasetypename name mismatch for column %q: %s != %s", name, databaseTypeName, column.databaseTypeName) + continue + } + + // ScanType + scanType := tp.ScanType() + if scanType != column.scanType { + if scanType == nil { + t.Errorf("scantype is null for column %q", name) + } else { + t.Errorf("scantype mismatch for column %q: %s != %s", name, scanType.Name(), column.scanType.Name()) + } + continue + } + types[i] = scanType + + // Nullable + nullable, ok := tp.Nullable() + if !ok { + t.Errorf("nullable not ok %q", name) + continue + } + if nullable != column.nullable { + t.Errorf("nullable mismatch for column %q: %t != %t", name, nullable, column.nullable) + } + + // Length + // length, ok := tp.Length() + // if length != column.length { + // if !ok { + // t.Errorf("length not ok for column %q", name) + // } else { + // t.Errorf("length mismatch for column %q: %d != %d", name, length, column.length) + // } + // continue + // } + + // Precision and Scale + precision, scale, ok := tp.DecimalSize() + if precision != column.precision { + if !ok { + t.Errorf("precision not ok for column %q", name) + } else { + t.Errorf("precision mismatch for column %q: %d != %d", name, precision, column.precision) + } + continue + } + if scale != column.scale { + if !ok { + t.Errorf("scale not ok for column %q", name) + } else { + t.Errorf("scale mismatch for column %q: %d != %d", name, scale, column.scale) + } + continue + } + } + // Avoid panic caused by nil scantype. + if t.Failed() { + return + } + values := make([]any, len(tt)) + for i := range values { + values[i] = reflect.New(types[i]).Interface() + } + i := 0 + for rows.Next() { + err = rows.Scan(values...) + if err != nil { + t.Fatalf("failed to scan values in %v", err) + } + for j, value := range values { + value := reflect.ValueOf(value).Elem().Interface() + if !reflect.DeepEqual(value, columns[j].valuesOut[i]) { + t.Errorf("row %d, column %d: %v != %v", i, j, value, columns[j].valuesOut[i]) + } + } + i++ + } + if i != 3 { + t.Errorf("expected 3 rows, got %d", i) + } + + if err := rows.Close(); err != nil { + t.Errorf("error closing rows: %s", err) + } + }) +} + +func TestValuerWithValueReceiverGivenNilValue(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (value VARCHAR(255))") + dbt.db.Exec("INSERT INTO "+tbl+" VALUES (?)", (*testValuer)(nil)) + // This test will panic on the INSERT if ConvertValue() does not check for typed nil before calling Value() + }) +} + +// TestRawBytesAreNotModified checks for a race condition that arises when a query context +// is canceled while a user is calling rows.Scan. This is a more stringent test than the one +// proposed in https://github.com/golang/go/issues/23519. Here we're explicitly using +// `sql.RawBytes` to check the contents of our internal buffers are not modified after an implicit +// call to `Rows.Close`, so Context cancellation should **not** invalidate the backing buffers. +func TestRawBytesAreNotModified(t *testing.T) { + const blob = "abcdefghijklmnop" + const contextRaceIterations = 20 + const blobSize = defaultBufSize * 3 / 4 // Second row overwrites first row. + const insertRows = 4 + + var sqlBlobs = [2]string{ + strings.Repeat(blob, blobSize/len(blob)), + strings.Repeat(strings.ToUpper(blob), blobSize/len(blob)), + } + + runTests(t, dsn, func(dbt *DBTest) { + dbt.mustExec("CREATE TABLE test (id int, value BLOB) CHARACTER SET utf8") + for i := range insertRows { + dbt.mustExec("INSERT INTO test VALUES (?, ?)", i+1, sqlBlobs[i&1]) + } + + for i := range contextRaceIterations { + func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + rows, err := dbt.db.QueryContext(ctx, `SELECT id, value FROM test`) + if err != nil { + dbt.Fatal(err) + } + defer rows.Close() + + var b int + var raw sql.RawBytes + if !rows.Next() { + dbt.Fatal("expected at least one row") + } + if err := rows.Scan(&b, &raw); err != nil { + dbt.Fatal(err) + } + + before := string(raw) + // Ensure cancelling the query does not corrupt the contents of `raw` + cancel() + time.Sleep(time.Microsecond * 100) + after := string(raw) + + if before != after { + dbt.Fatalf("the backing storage for sql.RawBytes has been modified (i=%v)", i) + } + }() + } + }) +} + +var _ driver.DriverContext = &MySQLDriver{} + +type dialCtxKey struct{} + +func TestConnectorObeysDialTimeouts(t *testing.T) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + RegisterDialContext("dialctxtest", func(ctx context.Context, addr string) (net.Conn, error) { + var d net.Dialer + if !ctx.Value(dialCtxKey{}).(bool) { + return nil, fmt.Errorf("test error: query context is not propagated to our dialer") + } + return d.DialContext(ctx, prot, addr) + }) + + db, err := sql.Open(driverNameTest, fmt.Sprintf("%s:%s@dialctxtest(%s)/%s?timeout=30s", user, pass, addr, dbname)) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + defer db.Close() + + ctx := context.WithValue(context.Background(), dialCtxKey{}, true) + + _, err = db.ExecContext(ctx, "DO 1") + if err != nil { + t.Fatal(err) + } +} + +func configForTests(t *testing.T) *Config { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + mycnf := NewConfig() + mycnf.User = user + mycnf.Passwd = pass + mycnf.Addr = addr + mycnf.Net = prot + mycnf.DBName = dbname + return mycnf +} + +func TestNewConnector(t *testing.T) { + mycnf := configForTests(t) + conn, err := NewConnector(mycnf) + if err != nil { + t.Fatal(err) + } + + db := sql.OpenDB(conn) + defer db.Close() + + if err := db.Ping(); err != nil { + t.Fatal(err) + } +} + +type slowConnection struct { + net.Conn + slowdown time.Duration +} + +func (sc *slowConnection) Read(b []byte) (int, error) { + time.Sleep(sc.slowdown) + return sc.Conn.Read(b) +} + +type connectorHijack struct { + driver.Connector + connErr error +} + +func (cw *connectorHijack) Connect(ctx context.Context) (driver.Conn, error) { + var conn driver.Conn + conn, cw.connErr = cw.Connector.Connect(ctx) + return conn, cw.connErr +} + +func TestConnectorTimeoutsDuringOpen(t *testing.T) { + RegisterDialContext("slowconn", func(ctx context.Context, addr string) (net.Conn, error) { + var d net.Dialer + conn, err := d.DialContext(ctx, prot, addr) + if err != nil { + return nil, err + } + return &slowConnection{Conn: conn, slowdown: 100 * time.Millisecond}, nil + }) + + mycnf := configForTests(t) + mycnf.Net = "slowconn" + + conn, err := NewConnector(mycnf) + if err != nil { + t.Fatal(err) + } + + hijack := &connectorHijack{Connector: conn} + + db := sql.OpenDB(hijack) + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err = db.ExecContext(ctx, "DO 1") + if err != context.DeadlineExceeded { + t.Fatalf("ExecContext should have timed out") + } + if hijack.connErr != context.DeadlineExceeded { + t.Fatalf("(*Connector).Connect should have timed out") + } +} + +// A connection which can only be closed. +type dummyConnection struct { + net.Conn + closed bool +} + +func (d *dummyConnection) Close() error { + d.closed = true + return nil +} + +func TestConnectorTimeoutsWatchCancel(t *testing.T) { + var ( + cancel func() // Used to cancel the context just after connecting. + created *dummyConnection // The created connection. + ) + + RegisterDialContext("TestConnectorTimeoutsWatchCancel", func(ctx context.Context, addr string) (net.Conn, error) { + // Canceling at this time triggers the watchCancel error branch in Connect(). + cancel() + created = &dummyConnection{} + return created, nil + }) + + mycnf := NewConfig() + mycnf.User = "root" + mycnf.Addr = "foo" + mycnf.Net = "TestConnectorTimeoutsWatchCancel" + + conn, err := NewConnector(mycnf) + if err != nil { + t.Fatal(err) + } + + db := sql.OpenDB(conn) + defer db.Close() + + var ctx context.Context + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + + if _, err := db.Conn(ctx); err != context.Canceled { + t.Errorf("got %v, want context.Canceled", err) + } + + if created == nil { + t.Fatal("no connection created") + } + if !created.closed { + t.Errorf("connection not closed") + } +} + +func TestConnectionAttributes(t *testing.T) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + + defaultAttrs := []string{ + connAttrClientName, + connAttrOS, + connAttrPlatform, + connAttrPid, + connAttrServerHost, + } + host, _, _ := net.SplitHostPort(addr) + defaultAttrValues := []string{ + connAttrClientNameValue, + connAttrOSValue, + connAttrPlatformValue, + strconv.Itoa(os.Getpid()), + host, + } + + customAttrs := []string{"attr1", "fo/o"} + customAttrValues := []string{"value1", "bo/o"} + + customAttrStrs := make([]string, len(customAttrs)) + for i := range customAttrs { + customAttrStrs[i] = fmt.Sprintf("%s:%s", customAttrs[i], customAttrValues[i]) + } + dsn += "&connectionAttributes=" + url.QueryEscape(strings.Join(customAttrStrs, ",")) + + var db *sql.DB + if _, err := ParseDSN(dsn); err != errInvalidDSNUnsafeCollation { + db, err = sql.Open(driverNameTest, dsn) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + defer db.Close() + } + + dbt := &DBTest{t, db} + + var varName string + var varValue string + err := dbt.db.QueryRow("SHOW VARIABLES LIKE 'performance_schema'").Scan(&varName, &varValue) + if err != nil { + t.Fatalf("error: %s", err.Error()) + } + if varValue != "ON" { + t.Skipf("Performance schema is not enabled. skipping") + } + queryString := "SELECT ATTR_NAME, ATTR_VALUE FROM performance_schema.session_account_connect_attrs WHERE PROCESSLIST_ID = CONNECTION_ID()" + rows := dbt.mustQuery(queryString) + defer rows.Close() + + rowsMap := make(map[string]string) + for rows.Next() { + var attrName, attrValue string + rows.Scan(&attrName, &attrValue) + rowsMap[attrName] = attrValue + } + + connAttrs := slices.Concat(defaultAttrs, customAttrs) + expectedAttrValues := slices.Concat(defaultAttrValues, customAttrValues) + for i := range connAttrs { + if gotValue := rowsMap[connAttrs[i]]; gotValue != expectedAttrValues[i] { + dbt.Errorf("expected %q, got %q", expectedAttrValues[i], gotValue) + } + } +} + +func TestErrorInMultiResult(t *testing.T) { + if !available { + t.Skipf("MySQL server not running on %s", netAddr) + } + // https://github.com/go-sql-driver/mysql/issues/1361 + var db *sql.DB + if _, err := ParseDSN(dsn); err != errInvalidDSNUnsafeCollation { + db, err = sql.Open("mysql", dsn) + if err != nil { + t.Fatalf("error connecting: %s", err.Error()) + } + defer db.Close() + } + + dbt := &DBTest{t, db} + query := ` +CREATE PROCEDURE test_proc1() +BEGIN + SELECT 1,2; + SELECT 3,4; + SIGNAL SQLSTATE '10000' SET MESSAGE_TEXT = "some error", MYSQL_ERRNO = 10000; +END; +` + runCallCommand(dbt, query, "test_proc1") +} + +func runCallCommand(dbt *DBTest, query, name string) { + dbt.mustExec(fmt.Sprintf("DROP PROCEDURE IF EXISTS %s", name)) + dbt.mustExec(query) + defer dbt.mustExec("DROP PROCEDURE " + name) + rows, err := dbt.db.Query(fmt.Sprintf("CALL %s", name)) + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + } + for rows.NextResultSet() { + for rows.Next() { + } + } +} + +func TestIssue1567(t *testing.T) { + // enable TLS. + runTests(t, dsn+"&tls=skip-verify", func(dbt *DBTest) { + var max int + err := dbt.db.QueryRow("SELECT @@max_connections").Scan(&max) + if err != nil { + dbt.Fatalf("%s", err.Error()) + } + + // disable connection pooling. + // data race happens when new connection is created. + dbt.db.SetMaxIdleConns(0) + + // estimate round trip time. + start := time.Now() + if err := dbt.db.PingContext(context.Background()); err != nil { + t.Fatal(err) + } + rtt := time.Since(start) + if rtt <= 0 { + // In some environments, rtt may become 0, so set it to at least 1ms. + rtt = time.Millisecond + } + + count := 1000 + if testing.Short() { + count = 10 + } + if count > max { + count = max + } + + for range count { + timeout := time.Duration(mrand.Int63n(int64(rtt))) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + dbt.db.PingContext(ctx) + cancel() + } + }) +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn.go new file mode 100644 index 0000000..491e10f --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn.go @@ -0,0 +1,700 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2016 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/tls" + "errors" + "fmt" + "maps" + "math/big" + "net" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +var ( + errInvalidDSNUnescaped = errors.New("invalid DSN: did you forget to escape a param value?") + errInvalidDSNAddr = errors.New("invalid DSN: network address not terminated (missing closing brace)") + errInvalidDSNNoSlash = errors.New("invalid DSN: missing the slash separating the database name") + errInvalidDSNUnsafeCollation = errors.New("invalid DSN: interpolateParams can not be used with unsafe collations") +) + +// Config is a configuration parsed from a DSN string. +// If a new Config is created instead of being parsed from a DSN string, +// the NewConfig function should be used, which sets default values. +type Config struct { + // non boolean fields + + User string // Username + Passwd string // Password (requires User) + Net string // Network (e.g. "tcp", "tcp6", "unix". default: "tcp") + Addr string // Address (default: "127.0.0.1:3306" for "tcp" and "/tmp/mysql.sock" for "unix") + DBName string // Database name + Params map[string]string // Connection parameters + ConnectionAttributes string // Connection Attributes, comma-delimited string of user-defined "key:value" pairs + Collation string // Connection collation. When set, this will be set in SET NAMES COLLATE query + Loc *time.Location // Location for time.Time values + MaxAllowedPacket int // Max packet size allowed + ServerPubKey string // Server public key name + TLSConfig string // TLS configuration name + TLS *tls.Config // TLS configuration, its priority is higher than TLSConfig + Timeout time.Duration // Dial timeout + ReadTimeout time.Duration // I/O read timeout + WriteTimeout time.Duration // I/O write timeout + Logger Logger // Logger + // DialFunc specifies the dial function for creating connections + DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + + // boolean fields + + AllowAllFiles bool // Allow all files to be used with LOAD DATA LOCAL INFILE + AllowCleartextPasswords bool // Allows the cleartext client side plugin + AllowFallbackToPlaintext bool // Allows fallback to unencrypted connection if server does not support TLS + AllowNativePasswords bool // Allows the native password authentication method + AllowOldPasswords bool // Allows the old insecure password method + CheckConnLiveness bool // Check connections for liveness before using them + ClientFoundRows bool // Return number of matching rows instead of rows changed + ColumnsWithAlias bool // Prepend table alias to column names + InterpolateParams bool // Interpolate placeholders into query string + MultiStatements bool // Allow multiple statements in one query + ParseTime bool // Parse time values to time.Time + RejectReadOnly bool // Reject read-only connections + + // unexported fields. new options should be come here. + // boolean first. alphabetical order. + + compress bool // Enable zlib compression + + beforeConnect func(context.Context, *Config) error // Invoked before a connection is established + pubKey *rsa.PublicKey // Server public key + timeTruncate time.Duration // Truncate time.Time values to the specified duration + charsets []string // Connection charset. When set, this will be set in SET NAMES query +} + +// Functional Options Pattern +// https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis +type Option func(*Config) error + +// NewConfig creates a new Config and sets default values. +func NewConfig() *Config { + cfg := &Config{ + Loc: time.UTC, + MaxAllowedPacket: defaultMaxAllowedPacket, + Logger: defaultLogger, + AllowNativePasswords: true, + CheckConnLiveness: true, + } + return cfg +} + +// Apply applies the given options to the Config object. +func (c *Config) Apply(opts ...Option) error { + for _, opt := range opts { + err := opt(c) + if err != nil { + return err + } + } + return nil +} + +// TimeTruncate sets the time duration to truncate time.Time values in +// query parameters. +func TimeTruncate(d time.Duration) Option { + return func(cfg *Config) error { + cfg.timeTruncate = d + return nil + } +} + +// BeforeConnect sets the function to be invoked before a connection is established. +func BeforeConnect(fn func(context.Context, *Config) error) Option { + return func(cfg *Config) error { + cfg.beforeConnect = fn + return nil + } +} + +// EnableCompress sets the compression mode. +func EnableCompression(yes bool) Option { + return func(cfg *Config) error { + cfg.compress = yes + return nil + } +} + +// Charset sets the connection charset and collation. +// +// charset is the connection charset. +// collation is the connection collation. It can be null or empty string. +// +// When collation is not specified, `SET NAMES ` command is sent when the connection is established. +// When collation is specified, `SET NAMES COLLATE ` command is sent when the connection is established. +func Charset(charset, collation string) Option { + return func(cfg *Config) error { + cfg.charsets = []string{charset} + cfg.Collation = collation + return nil + } +} + +func (cfg *Config) Clone() *Config { + cp := *cfg + if cp.TLS != nil { + cp.TLS = cfg.TLS.Clone() + } + if len(cp.Params) > 0 { + cp.Params = make(map[string]string, len(cfg.Params)) + maps.Copy(cp.Params, cfg.Params) + } + if cfg.pubKey != nil { + cp.pubKey = &rsa.PublicKey{ + N: new(big.Int).Set(cfg.pubKey.N), + E: cfg.pubKey.E, + } + } + return &cp +} + +func (cfg *Config) normalize() error { + if cfg.InterpolateParams && cfg.Collation != "" && unsafeCollations[cfg.Collation] { + return errInvalidDSNUnsafeCollation + } + + // Set default network if empty + if cfg.Net == "" { + cfg.Net = "tcp" + } + + // Set default address if empty + if cfg.Addr == "" { + switch cfg.Net { + case "tcp": + cfg.Addr = "127.0.0.1:3306" + case "unix": + cfg.Addr = "/tmp/mysql.sock" + default: + return errors.New("default addr for network '" + cfg.Net + "' unknown") + } + } else if cfg.Net == "tcp" { + cfg.Addr = ensureHavePort(cfg.Addr) + } + + if cfg.TLS == nil { + switch cfg.TLSConfig { + case "false", "": + // don't set anything + case "true": + cfg.TLS = &tls.Config{} + case "skip-verify": + cfg.TLS = &tls.Config{InsecureSkipVerify: true} + case "preferred": + cfg.TLS = &tls.Config{InsecureSkipVerify: true} + cfg.AllowFallbackToPlaintext = true + default: + cfg.TLS = getTLSConfigClone(cfg.TLSConfig) + if cfg.TLS == nil { + return errors.New("invalid value / unknown config name: " + cfg.TLSConfig) + } + } + } + + if cfg.TLS != nil && cfg.TLS.ServerName == "" && !cfg.TLS.InsecureSkipVerify { + host, _, err := net.SplitHostPort(cfg.Addr) + if err == nil { + cfg.TLS.ServerName = host + } + } + + if cfg.ServerPubKey != "" { + cfg.pubKey = getServerPubKey(cfg.ServerPubKey) + if cfg.pubKey == nil { + return errors.New("invalid value / unknown server pub key name: " + cfg.ServerPubKey) + } + } + + if cfg.Logger == nil { + cfg.Logger = defaultLogger + } + + return nil +} + +func writeDSNParam(buf *bytes.Buffer, hasParam *bool, name, value string) { + buf.Grow(1 + len(name) + 1 + len(value)) + if !*hasParam { + *hasParam = true + buf.WriteByte('?') + } else { + buf.WriteByte('&') + } + buf.WriteString(name) + buf.WriteByte('=') + buf.WriteString(value) +} + +// FormatDSN formats the given Config into a DSN string which can be passed to +// the driver. +// +// Note: use [NewConnector] and [database/sql.OpenDB] to open a connection from a [*Config]. +func (cfg *Config) FormatDSN() string { + var buf bytes.Buffer + + // [username[:password]@] + if len(cfg.User) > 0 { + buf.WriteString(cfg.User) + if len(cfg.Passwd) > 0 { + buf.WriteByte(':') + buf.WriteString(cfg.Passwd) + } + buf.WriteByte('@') + } + + // [protocol[(address)]] + if len(cfg.Net) > 0 { + buf.WriteString(cfg.Net) + if len(cfg.Addr) > 0 { + buf.WriteByte('(') + buf.WriteString(cfg.Addr) + buf.WriteByte(')') + } + } + + // /dbname + buf.WriteByte('/') + buf.WriteString(url.PathEscape(cfg.DBName)) + + // [?param1=value1&...¶mN=valueN] + hasParam := false + + if cfg.AllowAllFiles { + hasParam = true + buf.WriteString("?allowAllFiles=true") + } + + if cfg.AllowCleartextPasswords { + writeDSNParam(&buf, &hasParam, "allowCleartextPasswords", "true") + } + + if cfg.AllowFallbackToPlaintext { + writeDSNParam(&buf, &hasParam, "allowFallbackToPlaintext", "true") + } + + if !cfg.AllowNativePasswords { + writeDSNParam(&buf, &hasParam, "allowNativePasswords", "false") + } + + if cfg.AllowOldPasswords { + writeDSNParam(&buf, &hasParam, "allowOldPasswords", "true") + } + + if !cfg.CheckConnLiveness { + writeDSNParam(&buf, &hasParam, "checkConnLiveness", "false") + } + + if cfg.ClientFoundRows { + writeDSNParam(&buf, &hasParam, "clientFoundRows", "true") + } + + if charsets := cfg.charsets; len(charsets) > 0 { + writeDSNParam(&buf, &hasParam, "charset", strings.Join(charsets, ",")) + } + + if col := cfg.Collation; col != "" { + writeDSNParam(&buf, &hasParam, "collation", col) + } + + if cfg.ColumnsWithAlias { + writeDSNParam(&buf, &hasParam, "columnsWithAlias", "true") + } + + if cfg.ConnectionAttributes != "" { + writeDSNParam(&buf, &hasParam, "connectionAttributes", url.QueryEscape(cfg.ConnectionAttributes)) + } + + if cfg.compress { + writeDSNParam(&buf, &hasParam, "compress", "true") + } + + if cfg.InterpolateParams { + writeDSNParam(&buf, &hasParam, "interpolateParams", "true") + } + + if cfg.Loc != time.UTC && cfg.Loc != nil { + writeDSNParam(&buf, &hasParam, "loc", url.QueryEscape(cfg.Loc.String())) + } + + if cfg.MultiStatements { + writeDSNParam(&buf, &hasParam, "multiStatements", "true") + } + + if cfg.ParseTime { + writeDSNParam(&buf, &hasParam, "parseTime", "true") + } + + if cfg.timeTruncate > 0 { + writeDSNParam(&buf, &hasParam, "timeTruncate", cfg.timeTruncate.String()) + } + + if cfg.ReadTimeout > 0 { + writeDSNParam(&buf, &hasParam, "readTimeout", cfg.ReadTimeout.String()) + } + + if cfg.RejectReadOnly { + writeDSNParam(&buf, &hasParam, "rejectReadOnly", "true") + } + + if len(cfg.ServerPubKey) > 0 { + writeDSNParam(&buf, &hasParam, "serverPubKey", url.QueryEscape(cfg.ServerPubKey)) + } + + if cfg.Timeout > 0 { + writeDSNParam(&buf, &hasParam, "timeout", cfg.Timeout.String()) + } + + if len(cfg.TLSConfig) > 0 { + writeDSNParam(&buf, &hasParam, "tls", url.QueryEscape(cfg.TLSConfig)) + } + + if cfg.WriteTimeout > 0 { + writeDSNParam(&buf, &hasParam, "writeTimeout", cfg.WriteTimeout.String()) + } + + if cfg.MaxAllowedPacket != defaultMaxAllowedPacket { + writeDSNParam(&buf, &hasParam, "maxAllowedPacket", strconv.Itoa(cfg.MaxAllowedPacket)) + } + + // other params + if cfg.Params != nil { + var params []string + for param := range cfg.Params { + params = append(params, param) + } + sort.Strings(params) + for _, param := range params { + writeDSNParam(&buf, &hasParam, param, url.QueryEscape(cfg.Params[param])) + } + } + + return buf.String() +} + +// ParseDSN parses the DSN string to a Config +func ParseDSN(dsn string) (cfg *Config, err error) { + // New config with some default values + cfg = NewConfig() + + // [user[:password]@][net[(addr)]]/dbname[?param1=value1¶mN=valueN] + // Find the last '/' (since the password or the net addr might contain a '/') + foundSlash := false + for i := len(dsn) - 1; i >= 0; i-- { + if dsn[i] == '/' { + foundSlash = true + var j, k int + + // left part is empty if i <= 0 + if i > 0 { + // [username[:password]@][protocol[(address)]] + // Find the last '@' in dsn[:i] + for j = i; j >= 0; j-- { + if dsn[j] == '@' { + // username[:password] + // Find the first ':' in dsn[:j] + for k = 0; k < j; k++ { // We cannot use k = range j here, because we use dsn[:k] below + if dsn[k] == ':' { + cfg.Passwd = dsn[k+1 : j] + break + } + } + cfg.User = dsn[:k] + + break + } + } + + // [protocol[(address)]] + // Find the first '(' in dsn[j+1:i] + for k = j + 1; k < i; k++ { + if dsn[k] == '(' { + // dsn[i-1] must be == ')' if an address is specified + if dsn[i-1] != ')' { + if strings.ContainsRune(dsn[k+1:i], ')') { + return nil, errInvalidDSNUnescaped + } + return nil, errInvalidDSNAddr + } + cfg.Addr = dsn[k+1 : i-1] + break + } + } + cfg.Net = dsn[j+1 : k] + } + + // dbname[?param1=value1&...¶mN=valueN] + // Find the first '?' in dsn[i+1:] + for j = i + 1; j < len(dsn); j++ { + if dsn[j] == '?' { + if err = parseDSNParams(cfg, dsn[j+1:]); err != nil { + return + } + break + } + } + + dbname := dsn[i+1 : j] + if cfg.DBName, err = url.PathUnescape(dbname); err != nil { + return nil, fmt.Errorf("invalid dbname %q: %w", dbname, err) + } + + break + } + } + + if !foundSlash && len(dsn) > 0 { + return nil, errInvalidDSNNoSlash + } + + if err = cfg.normalize(); err != nil { + return nil, err + } + return +} + +// parseDSNParams parses the DSN "query string" +// Values must be url.QueryEscape'ed +func parseDSNParams(cfg *Config, params string) (err error) { + for v := range strings.SplitSeq(params, "&") { + key, value, found := strings.Cut(v, "=") + if !found { + continue + } + + // cfg params + switch key { + // Disable INFILE allowlist / enable all files + case "allowAllFiles": + var isBool bool + cfg.AllowAllFiles, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Use cleartext authentication mode (MySQL 5.5.10+) + case "allowCleartextPasswords": + var isBool bool + cfg.AllowCleartextPasswords, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Allow fallback to unencrypted connection if server does not support TLS + case "allowFallbackToPlaintext": + var isBool bool + cfg.AllowFallbackToPlaintext, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Use native password authentication + case "allowNativePasswords": + var isBool bool + cfg.AllowNativePasswords, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Use old authentication mode (pre MySQL 4.1) + case "allowOldPasswords": + var isBool bool + cfg.AllowOldPasswords, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Check connections for Liveness before using them + case "checkConnLiveness": + var isBool bool + cfg.CheckConnLiveness, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Switch "rowsAffected" mode + case "clientFoundRows": + var isBool bool + cfg.ClientFoundRows, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // charset + case "charset": + cfg.charsets = strings.Split(value, ",") + + // Collation + case "collation": + cfg.Collation = value + + case "columnsWithAlias": + var isBool bool + cfg.ColumnsWithAlias, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Compression + case "compress": + var isBool bool + cfg.compress, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Enable client side placeholder substitution + case "interpolateParams": + var isBool bool + cfg.InterpolateParams, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Time Location + case "loc": + if value, err = url.QueryUnescape(value); err != nil { + return + } + cfg.Loc, err = time.LoadLocation(value) + if err != nil { + return + } + + // multiple statements in one query + case "multiStatements": + var isBool bool + cfg.MultiStatements, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // time.Time parsing + case "parseTime": + var isBool bool + cfg.ParseTime, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // time.Time truncation + case "timeTruncate": + cfg.timeTruncate, err = time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid timeTruncate value: %v, error: %w", value, err) + } + + // I/O read Timeout + case "readTimeout": + cfg.ReadTimeout, err = time.ParseDuration(value) + if err != nil { + return + } + + // Reject read-only connections + case "rejectReadOnly": + var isBool bool + cfg.RejectReadOnly, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + + // Server public key + case "serverPubKey": + name, err := url.QueryUnescape(value) + if err != nil { + return fmt.Errorf("invalid value for server pub key name: %v", err) + } + cfg.ServerPubKey = name + + // Strict mode + case "strict": + panic("strict mode has been removed. See https://github.com/go-sql-driver/mysql/wiki/strict-mode") + + // Dial Timeout + case "timeout": + cfg.Timeout, err = time.ParseDuration(value) + if err != nil { + return + } + + // TLS-Encryption + case "tls": + boolValue, isBool := readBool(value) + if isBool { + if boolValue { + cfg.TLSConfig = "true" + } else { + cfg.TLSConfig = "false" + } + } else if vl := strings.ToLower(value); vl == "skip-verify" || vl == "preferred" { + cfg.TLSConfig = vl + } else { + name, err := url.QueryUnescape(value) + if err != nil { + return fmt.Errorf("invalid value for TLS config name: %v", err) + } + cfg.TLSConfig = name + } + + // I/O write Timeout + case "writeTimeout": + cfg.WriteTimeout, err = time.ParseDuration(value) + if err != nil { + return + } + case "maxAllowedPacket": + cfg.MaxAllowedPacket, err = strconv.Atoi(value) + if err != nil { + return + } + + // Connection attributes + case "connectionAttributes": + connectionAttributes, err := url.QueryUnescape(value) + if err != nil { + return fmt.Errorf("invalid connectionAttributes value: %v", err) + } + cfg.ConnectionAttributes = connectionAttributes + + default: + // lazy init + if cfg.Params == nil { + cfg.Params = make(map[string]string) + } + + if cfg.Params[key], err = url.QueryUnescape(value); err != nil { + return + } + } + } + + return +} + +func ensureHavePort(addr string) string { + if _, _, err := net.SplitHostPort(addr); err != nil { + return net.JoinHostPort(addr, "3306") + } + return addr +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn_fuzz_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn_fuzz_test.go new file mode 100644 index 0000000..c561333 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn_fuzz_test.go @@ -0,0 +1,46 @@ +//go:build go1.18 + +package mysql + +import ( + "net" + "testing" +) + +func FuzzFormatDSN(f *testing.F) { + for _, test := range testDSNs { // See dsn_test.go + f.Add(test.in) + } + + f.Fuzz(func(t *testing.T, dsn1 string) { + // Do not waste resources + if len(dsn1) > 1000 { + t.Skip("ignore: too long") + } + + cfg1, err := ParseDSN(dsn1) + if err != nil { + t.Skipf("invalid DSN: %v", err) + } + + dsn2 := cfg1.FormatDSN() + if dsn2 == dsn1 { + return + } + + // Skip known cases of bad config that are not strictly checked by ParseDSN + if _, _, err := net.SplitHostPort(cfg1.Addr); err != nil { + t.Skipf("invalid addr %q: %v", cfg1.Addr, err) + } + + cfg2, err := ParseDSN(dsn2) + if err != nil { + t.Fatalf("%q rewritten as %q: %v", dsn1, dsn2, err) + } + + dsn3 := cfg2.FormatDSN() + if dsn3 != dsn2 { + t.Errorf("%q rewritten as %q", dsn2, dsn3) + } + }) +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn_test.go new file mode 100644 index 0000000..c4ec989 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/dsn_test.go @@ -0,0 +1,442 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2016 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "crypto/tls" + "fmt" + "net/url" + "reflect" + "testing" + "time" +) + +var testDSNs = []struct { + in string + out *Config +}{{ + "username:password@protocol(address)/dbname?param=value", + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "username:password@protocol(address)/dbname?param=value&columnsWithAlias=true", + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ColumnsWithAlias: true}, +}, { + "username:password@protocol(address)/dbname?param=value&columnsWithAlias=true&multiStatements=true", + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ColumnsWithAlias: true, MultiStatements: true}, +}, { + "user@unix(/path/to/socket)/dbname?charset=utf8", + &Config{User: "user", Net: "unix", Addr: "/path/to/socket", DBName: "dbname", charsets: []string{"utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "user:password@tcp(localhost:5555)/dbname?charset=utf8&tls=true", + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "localhost:5555", DBName: "dbname", charsets: []string{"utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, TLSConfig: "true"}, +}, { + "user:password@tcp(localhost:5555)/dbname?charset=utf8mb4,utf8&tls=skip-verify", + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "localhost:5555", DBName: "dbname", charsets: []string{"utf8mb4", "utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, TLSConfig: "skip-verify"}, +}, { + "user:password@/dbname?loc=UTC&timeout=30s&readTimeout=1s&writeTimeout=1s&allowAllFiles=1&clientFoundRows=true&allowOldPasswords=TRUE&collation=utf8mb4_unicode_ci&maxAllowedPacket=16777216&tls=false&allowCleartextPasswords=true&parseTime=true&rejectReadOnly=true", + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Collation: "utf8mb4_unicode_ci", Loc: time.UTC, TLSConfig: "false", AllowCleartextPasswords: true, AllowNativePasswords: true, Timeout: 30 * time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second, Logger: defaultLogger, AllowAllFiles: true, AllowOldPasswords: true, CheckConnLiveness: true, ClientFoundRows: true, MaxAllowedPacket: 16777216, ParseTime: true, RejectReadOnly: true}, +}, { + "user:password@/dbname?allowNativePasswords=false&checkConnLiveness=false&maxAllowedPacket=0&allowFallbackToPlaintext=true", + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: 0, Logger: defaultLogger, AllowFallbackToPlaintext: true, AllowNativePasswords: false, CheckConnLiveness: false}, +}, { + "user:p@ss(word)@tcp([de:ad:be:ef::ca:fe]:80)/dbname?loc=Local", + &Config{User: "user", Passwd: "p@ss(word)", Net: "tcp", Addr: "[de:ad:be:ef::ca:fe]:80", DBName: "dbname", Loc: time.Local, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "/dbname", + &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "/dbname%2Fwithslash", + &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname/withslash", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "@/", + &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "/", + &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "", + &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "user:p@/ssword@/", + &Config{User: "user", Passwd: "p@/ssword", Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "unix/?arg=%2Fsome%2Fpath.ext", + &Config{Net: "unix", Addr: "/tmp/mysql.sock", Params: map[string]string{"arg": "/some/path.ext"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "tcp(127.0.0.1)/dbname", + &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "tcp(de:ad:be:ef::ca:fe)/dbname", + &Config{Net: "tcp", Addr: "[de:ad:be:ef::ca:fe]:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, +}, { + "user:password@/dbname?loc=UTC&timeout=30s&parseTime=true&timeTruncate=1h", + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, Timeout: 30 * time.Second, ParseTime: true, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, timeTruncate: time.Hour}, +}, { + "foo:bar@tcp(192.168.1.50:3307)/baz?timeout=10s&connectionAttributes=program_name:MySQLGoDriver%2FTest,program_version:1.2.3", + &Config{User: "foo", Passwd: "bar", Net: "tcp", Addr: "192.168.1.50:3307", DBName: "baz", Loc: time.UTC, Timeout: 10 * time.Second, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ConnectionAttributes: "program_name:MySQLGoDriver/Test,program_version:1.2.3"}, +}, +} + +func TestDSNParser(t *testing.T) { + for i, tst := range testDSNs { + t.Run(tst.in, func(t *testing.T) { + cfg, err := ParseDSN(tst.in) + if err != nil { + t.Error(err.Error()) + return + } + + // pointer not static + cfg.TLS = nil + + if !reflect.DeepEqual(cfg, tst.out) { + t.Errorf("%d. ParseDSN(%q) mismatch:\ngot %+v\nwant %+v", i, tst.in, cfg, tst.out) + } + }) + } +} + +func TestDSNParserInvalid(t *testing.T) { + var invalidDSNs = []string{ + "@net(addr/", // no closing brace + "@tcp(/", // no closing brace + "tcp(/", // no closing brace + "(/", // no closing brace + "net(addr)//", // unescaped + "User:pass@tcp(1.2.3.4:3306)", // no trailing slash + "net()/", // unknown default addr + "user:pass@tcp(127.0.0.1:3306)/db/name", // invalid dbname + "user:password@/dbname?allowFallbackToPlaintext=PREFERRED", // wrong bool flag + "user:password@/dbname?connectionAttributes=attr1:/unescaped/value", // unescaped + //"/dbname?arg=/some/unescaped/path", + } + + for i, tst := range invalidDSNs { + if _, err := ParseDSN(tst); err == nil { + t.Errorf("invalid DSN #%d. (%s) didn't error!", i, tst) + } + } +} + +func TestDSNReformat(t *testing.T) { + for i, tst := range testDSNs { + t.Run(tst.in, func(t *testing.T) { + dsn1 := tst.in + cfg1, err := ParseDSN(dsn1) + if err != nil { + t.Error(err.Error()) + return + } + cfg1.TLS = nil // pointer not static + res1 := fmt.Sprintf("%+v", cfg1) + + dsn2 := cfg1.FormatDSN() + if dsn2 != dsn1 { + // Just log + t.Logf("%d. %q reformatted as %q", i, dsn1, dsn2) + } + + cfg2, err := ParseDSN(dsn2) + if err != nil { + t.Error(err.Error()) + return + } + cfg2.TLS = nil // pointer not static + res2 := fmt.Sprintf("%+v", cfg2) + + if res1 != res2 { + t.Errorf("%d. %q does not match %q", i, res2, res1) + } + + dsn3 := cfg2.FormatDSN() + if dsn3 != dsn2 { + t.Errorf("%d. %q does not match %q", i, dsn2, dsn3) + } + }) + } +} + +func TestDSNServerPubKey(t *testing.T) { + baseDSN := "User:password@tcp(localhost:5555)/dbname?serverPubKey=" + + RegisterServerPubKey("testKey", testPubKeyRSA) + defer DeregisterServerPubKey("testKey") + + tst := baseDSN + "testKey" + cfg, err := ParseDSN(tst) + if err != nil { + t.Error(err.Error()) + } + + if cfg.ServerPubKey != "testKey" { + t.Errorf("unexpected cfg.ServerPubKey value: %v", cfg.ServerPubKey) + } + if cfg.pubKey != testPubKeyRSA { + t.Error("pub key pointer doesn't match") + } + + // Key is missing + tst = baseDSN + "invalid_name" + cfg, err = ParseDSN(tst) + if err == nil { + t.Errorf("invalid name in DSN (%s) but did not error. Got config: %#v", tst, cfg) + } +} + +func TestDSNServerPubKeyQueryEscape(t *testing.T) { + const name = "&%!:" + dsn := "User:password@tcp(localhost:5555)/dbname?serverPubKey=" + url.QueryEscape(name) + + RegisterServerPubKey(name, testPubKeyRSA) + defer DeregisterServerPubKey(name) + + cfg, err := ParseDSN(dsn) + if err != nil { + t.Error(err.Error()) + } + + if cfg.pubKey != testPubKeyRSA { + t.Error("pub key pointer doesn't match") + } +} + +func TestDSNWithCustomTLS(t *testing.T) { + baseDSN := "User:password@tcp(localhost:5555)/dbname?tls=" + tlsCfg := tls.Config{} + + RegisterTLSConfig("utils_test", &tlsCfg) + defer DeregisterTLSConfig("utils_test") + + // Custom TLS is missing + tst := baseDSN + "invalid_tls" + cfg, err := ParseDSN(tst) + if err == nil { + t.Errorf("invalid custom TLS in DSN (%s) but did not error. Got config: %#v", tst, cfg) + } + + tst = baseDSN + "utils_test" + + // Custom TLS with a server name + name := "foohost" + tlsCfg.ServerName = name + cfg, err = ParseDSN(tst) + + if err != nil { + t.Error(err.Error()) + } else if cfg.TLS.ServerName != name { + t.Errorf("did not get the correct TLS ServerName (%s) parsing DSN (%s).", name, tst) + } + + // Custom TLS without a server name + name = "localhost" + tlsCfg.ServerName = "" + cfg, err = ParseDSN(tst) + + if err != nil { + t.Error(err.Error()) + } else if cfg.TLS.ServerName != name { + t.Errorf("did not get the correct ServerName (%s) parsing DSN (%s).", name, tst) + } else if tlsCfg.ServerName != "" { + t.Errorf("tlsCfg was mutated ServerName (%s) should be empty parsing DSN (%s).", name, tst) + } +} + +func TestDSNTLSConfig(t *testing.T) { + expectedServerName := "example.com" + dsn := "tcp(example.com:1234)/?tls=true" + + cfg, err := ParseDSN(dsn) + if err != nil { + t.Error(err.Error()) + } + if cfg.TLS == nil { + t.Error("cfg.tls should not be nil") + } + if cfg.TLS.ServerName != expectedServerName { + t.Errorf("cfg.tls.ServerName should be %q, got %q (host with port)", expectedServerName, cfg.TLS.ServerName) + } + + dsn = "tcp(example.com)/?tls=true" + cfg, err = ParseDSN(dsn) + if err != nil { + t.Error(err.Error()) + } + if cfg.TLS == nil { + t.Error("cfg.tls should not be nil") + } + if cfg.TLS.ServerName != expectedServerName { + t.Errorf("cfg.tls.ServerName should be %q, got %q (host without port)", expectedServerName, cfg.TLS.ServerName) + } +} + +func TestDSNWithCustomTLSQueryEscape(t *testing.T) { + const configKey = "&%!:" + dsn := "User:password@tcp(localhost:5555)/dbname?tls=" + url.QueryEscape(configKey) + name := "foohost" + tlsCfg := tls.Config{ServerName: name} + + RegisterTLSConfig(configKey, &tlsCfg) + defer DeregisterTLSConfig(configKey) + + cfg, err := ParseDSN(dsn) + + if err != nil { + t.Error(err.Error()) + } else if cfg.TLS.ServerName != name { + t.Errorf("did not get the correct TLS ServerName (%s) parsing DSN (%s).", name, dsn) + } +} + +func TestDSNUnsafeCollation(t *testing.T) { + _, err := ParseDSN("/dbname?collation=gbk_chinese_ci&interpolateParams=true") + if err != errInvalidDSNUnsafeCollation { + t.Errorf("expected %v, got %v", errInvalidDSNUnsafeCollation, err) + } + + _, err = ParseDSN("/dbname?collation=gbk_chinese_ci&interpolateParams=false") + if err != nil { + t.Errorf("expected %v, got %v", nil, err) + } + + _, err = ParseDSN("/dbname?collation=gbk_chinese_ci") + if err != nil { + t.Errorf("expected %v, got %v", nil, err) + } + + _, err = ParseDSN("/dbname?collation=ascii_bin&interpolateParams=true") + if err != nil { + t.Errorf("expected %v, got %v", nil, err) + } + + _, err = ParseDSN("/dbname?collation=latin1_german1_ci&interpolateParams=true") + if err != nil { + t.Errorf("expected %v, got %v", nil, err) + } + + _, err = ParseDSN("/dbname?collation=utf8_general_ci&interpolateParams=true") + if err != nil { + t.Errorf("expected %v, got %v", nil, err) + } + + _, err = ParseDSN("/dbname?collation=utf8mb4_general_ci&interpolateParams=true") + if err != nil { + t.Errorf("expected %v, got %v", nil, err) + } +} + +func TestParamsAreSorted(t *testing.T) { + expected := "/dbname?interpolateParams=true&foobar=baz&quux=loo" + cfg := NewConfig() + cfg.DBName = "dbname" + cfg.InterpolateParams = true + cfg.Params = map[string]string{ + "quux": "loo", + "foobar": "baz", + } + actual := cfg.FormatDSN() + if actual != expected { + t.Errorf("generic Config.Params were not sorted: want %#v, got %#v", expected, actual) + } +} + +func TestCloneConfig(t *testing.T) { + RegisterServerPubKey("testKey", testPubKeyRSA) + defer DeregisterServerPubKey("testKey") + + expectedServerName := "example.com" + dsn := "tcp(example.com:1234)/?tls=true&foobar=baz&serverPubKey=testKey" + cfg, err := ParseDSN(dsn) + if err != nil { + t.Fatal(err.Error()) + } + + cfg2 := cfg.Clone() + if cfg == cfg2 { + t.Errorf("Config.Clone did not create a separate config struct") + } + + if cfg2.TLS.ServerName != expectedServerName { + t.Errorf("cfg.tls.ServerName should be %q, got %q (host with port)", expectedServerName, cfg.TLS.ServerName) + } + + cfg2.TLS.ServerName = "example2.com" + if cfg.TLS.ServerName == cfg2.TLS.ServerName { + t.Errorf("changed cfg.tls.Server name should not propagate to original Config") + } + + if _, ok := cfg2.Params["foobar"]; !ok { + t.Errorf("cloned Config is missing custom params") + } + + delete(cfg2.Params, "foobar") + + if _, ok := cfg.Params["foobar"]; !ok { + t.Errorf("custom params in cloned Config should not propagate to original Config") + } + + if !reflect.DeepEqual(cfg.pubKey, cfg2.pubKey) { + t.Errorf("public key in Config should be identical") + } +} + +func TestNormalizeTLSConfig(t *testing.T) { + tt := []struct { + tlsConfig string + want *tls.Config + }{ + {"", nil}, + {"false", nil}, + {"true", &tls.Config{ServerName: "myserver"}}, + {"skip-verify", &tls.Config{InsecureSkipVerify: true}}, + {"preferred", &tls.Config{InsecureSkipVerify: true}}, + {"test_tls_config", &tls.Config{ServerName: "myServerName"}}, + } + + RegisterTLSConfig("test_tls_config", &tls.Config{ServerName: "myServerName"}) + defer func() { DeregisterTLSConfig("test_tls_config") }() + + for _, tc := range tt { + t.Run(tc.tlsConfig, func(t *testing.T) { + cfg := &Config{ + Addr: "myserver:3306", + TLSConfig: tc.tlsConfig, + } + + cfg.normalize() + + if cfg.TLS == nil { + if tc.want != nil { + t.Fatal("wanted a tls config but got nil instead") + } + return + } + + if cfg.TLS.ServerName != tc.want.ServerName { + t.Errorf("tls.ServerName doesn't match (want: '%s', got: '%s')", + tc.want.ServerName, cfg.TLS.ServerName) + } + if cfg.TLS.InsecureSkipVerify != tc.want.InsecureSkipVerify { + t.Errorf("tls.InsecureSkipVerify doesn't match (want: %T, got :%T)", + tc.want.InsecureSkipVerify, cfg.TLS.InsecureSkipVerify) + } + }) + } +} + +func BenchmarkParseDSN(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + for _, tst := range testDSNs { + if _, err := ParseDSN(tst.in); err != nil { + b.Error(err.Error()) + } + } + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/errors.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/errors.go new file mode 100644 index 0000000..584617b --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/errors.go @@ -0,0 +1,83 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "errors" + "fmt" + "log" + "os" +) + +// Various errors the driver might return. Can change between driver versions. +var ( + ErrInvalidConn = errors.New("invalid connection") + ErrMalformPkt = errors.New("malformed packet") + ErrNoTLS = errors.New("TLS requested but server does not support TLS") + ErrCleartextPassword = errors.New("this user requires clear text authentication. If you still want to use it, please add 'allowCleartextPasswords=1' to your DSN") + ErrNativePassword = errors.New("this user requires mysql native password authentication") + ErrOldPassword = errors.New("this user requires old password authentication. If you still want to use it, please add 'allowOldPasswords=1' to your DSN. See also https://github.com/go-sql-driver/mysql/wiki/old_passwords") + ErrUnknownPlugin = errors.New("this authentication plugin is not supported") + ErrOldProtocol = errors.New("MySQL server does not support required protocol 41+") + ErrPktSync = errors.New("commands out of sync. You can't run this command now") + ErrPktSyncMul = errors.New("commands out of sync. Did you run multiple statements at once?") + ErrPktTooLarge = errors.New("packet for query is too large. Try adjusting the `Config.MaxAllowedPacket`") + ErrBusyBuffer = errors.New("busy buffer") + + // errBadConnNoWrite is used for connection errors where nothing was sent to the database yet. + // If this happens first in a function starting a database interaction, it should be replaced by driver.ErrBadConn + // to trigger a resend. Use mc.markBadConn(err) to do this. + // See https://github.com/go-sql-driver/mysql/pull/302 + errBadConnNoWrite = errors.New("bad connection") +) + +var defaultLogger = Logger(log.New(os.Stderr, "[mysql] ", log.Ldate|log.Ltime)) + +// Logger is used to log critical error messages. +type Logger interface { + Print(v ...any) +} + +// NopLogger is a nop implementation of the Logger interface. +type NopLogger struct{} + +// Print implements Logger interface. +func (nl *NopLogger) Print(_ ...any) {} + +// SetLogger is used to set the default logger for critical errors. +// The initial logger is os.Stderr. +func SetLogger(logger Logger) error { + if logger == nil { + return errors.New("logger is nil") + } + defaultLogger = logger + return nil +} + +// MySQLError is an error type which represents a single MySQL error +type MySQLError struct { + Number uint16 + SQLState [5]byte + Message string +} + +func (me *MySQLError) Error() string { + if me.SQLState != [5]byte{} { + return fmt.Sprintf("Error %d (%s): %s", me.Number, me.SQLState, me.Message) + } + + return fmt.Sprintf("Error %d: %s", me.Number, me.Message) +} + +func (me *MySQLError) Is(err error) bool { + if merr, ok := err.(*MySQLError); ok { + return merr.Number == me.Number + } + return false +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/errors_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/errors_test.go new file mode 100644 index 0000000..53d6344 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/errors_test.go @@ -0,0 +1,61 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "errors" + "log" + "testing" +) + +func TestErrorsSetLogger(t *testing.T) { + previous := defaultLogger + defer func() { + defaultLogger = previous + }() + + // set up logger + const expected = "prefix: test\n" + buffer := bytes.NewBuffer(make([]byte, 0, 64)) + logger := log.New(buffer, "prefix: ", 0) + + // print + SetLogger(logger) + defaultLogger.Print("test") + + // check result + if actual := buffer.String(); actual != expected { + t.Errorf("expected %q, got %q", expected, actual) + } +} + +func TestErrorsStrictIgnoreNotes(t *testing.T) { + runTests(t, dsn+"&sql_notes=false", func(dbt *DBTest) { + dbt.mustExec("DROP TABLE IF EXISTS does_not_exist") + }) +} + +func TestMySQLErrIs(t *testing.T) { + infraErr := &MySQLError{Number: 1234, Message: "the server is on fire"} + otherInfraErr := &MySQLError{Number: 1234, Message: "the datacenter is flooded"} + if !errors.Is(infraErr, otherInfraErr) { + t.Errorf("expected errors to be the same: %+v %+v", infraErr, otherInfraErr) + } + + differentCodeErr := &MySQLError{Number: 5678, Message: "the server is on fire"} + if errors.Is(infraErr, differentCodeErr) { + t.Fatalf("expected errors to be different: %+v %+v", infraErr, differentCodeErr) + } + + nonMysqlErr := errors.New("not a mysql error") + if errors.Is(infraErr, nonMysqlErr) { + t.Fatalf("expected errors to be different: %+v %+v", infraErr, nonMysqlErr) + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/fields.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/fields.go new file mode 100644 index 0000000..ee9d964 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/fields.go @@ -0,0 +1,228 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2017 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "database/sql" + "reflect" +) + +func (mf *mysqlField) typeDatabaseName() string { + switch mf.fieldType { + case fieldTypeBit: + return "BIT" + case fieldTypeBLOB: + if mf.charSet != binaryCollationID { + return "TEXT" + } + return "BLOB" + case fieldTypeDate: + return "DATE" + case fieldTypeDateTime: + return "DATETIME" + case fieldTypeDecimal: + return "DECIMAL" + case fieldTypeDouble: + return "DOUBLE" + case fieldTypeEnum: + return "ENUM" + case fieldTypeFloat: + return "FLOAT" + case fieldTypeGeometry: + return "GEOMETRY" + case fieldTypeInt24: + if mf.flags&flagUnsigned != 0 { + return "UNSIGNED MEDIUMINT" + } + return "MEDIUMINT" + case fieldTypeJSON: + return "JSON" + case fieldTypeLong: + if mf.flags&flagUnsigned != 0 { + return "UNSIGNED INT" + } + return "INT" + case fieldTypeLongBLOB: + if mf.charSet != binaryCollationID { + return "LONGTEXT" + } + return "LONGBLOB" + case fieldTypeLongLong: + if mf.flags&flagUnsigned != 0 { + return "UNSIGNED BIGINT" + } + return "BIGINT" + case fieldTypeMediumBLOB: + if mf.charSet != binaryCollationID { + return "MEDIUMTEXT" + } + return "MEDIUMBLOB" + case fieldTypeNewDate: + return "DATE" + case fieldTypeNewDecimal: + return "DECIMAL" + case fieldTypeNULL: + return "NULL" + case fieldTypeSet: + return "SET" + case fieldTypeShort: + if mf.flags&flagUnsigned != 0 { + return "UNSIGNED SMALLINT" + } + return "SMALLINT" + case fieldTypeString: + if mf.flags&flagEnum != 0 { + return "ENUM" + } else if mf.flags&flagSet != 0 { + return "SET" + } + if mf.charSet == binaryCollationID { + return "BINARY" + } + return "CHAR" + case fieldTypeTime: + return "TIME" + case fieldTypeTimestamp: + return "TIMESTAMP" + case fieldTypeTiny: + if mf.flags&flagUnsigned != 0 { + return "UNSIGNED TINYINT" + } + return "TINYINT" + case fieldTypeTinyBLOB: + if mf.charSet != binaryCollationID { + return "TINYTEXT" + } + return "TINYBLOB" + case fieldTypeVarChar: + if mf.charSet == binaryCollationID { + return "VARBINARY" + } + return "VARCHAR" + case fieldTypeVarString: + if mf.charSet == binaryCollationID { + return "VARBINARY" + } + return "VARCHAR" + case fieldTypeYear: + return "YEAR" + case fieldTypeVector: + return "VECTOR" + default: + return "" + } +} + +var ( + scanTypeFloat32 = reflect.TypeFor[float32]() + scanTypeFloat64 = reflect.TypeFor[float64]() + scanTypeInt8 = reflect.TypeFor[int8]() + scanTypeInt16 = reflect.TypeFor[int16]() + scanTypeInt32 = reflect.TypeFor[int32]() + scanTypeInt64 = reflect.TypeFor[int64]() + scanTypeNullFloat = reflect.TypeFor[sql.NullFloat64]() + scanTypeNullInt = reflect.TypeFor[sql.NullInt64]() + scanTypeNullUint = reflect.TypeFor[sql.Null[uint64]]() + scanTypeNullTime = reflect.TypeFor[sql.NullTime]() + scanTypeUint8 = reflect.TypeFor[uint8]() + scanTypeUint16 = reflect.TypeFor[uint16]() + scanTypeUint32 = reflect.TypeFor[uint32]() + scanTypeUint64 = reflect.TypeFor[uint64]() + scanTypeString = reflect.TypeFor[string]() + scanTypeNullString = reflect.TypeFor[sql.NullString]() + scanTypeBytes = reflect.TypeFor[[]byte]() + scanTypeUnknown = reflect.TypeFor[*any]() +) + +type mysqlField struct { + tableName string + name string + length uint32 + flags fieldFlag + fieldType fieldType + decimals byte + charSet uint8 +} + +func (mf *mysqlField) scanType() reflect.Type { + switch mf.fieldType { + case fieldTypeTiny: + if mf.flags&flagNotNULL != 0 { + if mf.flags&flagUnsigned != 0 { + return scanTypeUint8 + } + return scanTypeInt8 + } + return scanTypeNullInt + + case fieldTypeShort, fieldTypeYear: + if mf.flags&flagNotNULL != 0 { + if mf.flags&flagUnsigned != 0 { + return scanTypeUint16 + } + return scanTypeInt16 + } + return scanTypeNullInt + + case fieldTypeInt24, fieldTypeLong: + if mf.flags&flagNotNULL != 0 { + if mf.flags&flagUnsigned != 0 { + return scanTypeUint32 + } + return scanTypeInt32 + } + return scanTypeNullInt + + case fieldTypeLongLong: + if mf.flags&flagNotNULL != 0 { + if mf.flags&flagUnsigned != 0 { + return scanTypeUint64 + } + return scanTypeInt64 + } + if mf.flags&flagUnsigned != 0 { + return scanTypeNullUint + } + return scanTypeNullInt + + case fieldTypeFloat: + if mf.flags&flagNotNULL != 0 { + return scanTypeFloat32 + } + return scanTypeNullFloat + + case fieldTypeDouble: + if mf.flags&flagNotNULL != 0 { + return scanTypeFloat64 + } + return scanTypeNullFloat + + case fieldTypeBit, fieldTypeTinyBLOB, fieldTypeMediumBLOB, fieldTypeLongBLOB, + fieldTypeBLOB, fieldTypeVarString, fieldTypeString, fieldTypeGeometry, fieldTypeVector: + if mf.charSet == binaryCollationID { + return scanTypeBytes + } + fallthrough + case fieldTypeDecimal, fieldTypeNewDecimal, fieldTypeVarChar, + fieldTypeEnum, fieldTypeSet, fieldTypeJSON, fieldTypeTime: + if mf.flags&flagNotNULL != 0 { + return scanTypeString + } + return scanTypeNullString + + case fieldTypeDate, fieldTypeNewDate, + fieldTypeTimestamp, fieldTypeDateTime: + // NullTime is always returned for more consistent behavior as it can + // handle both cases of parseTime regardless if the field is nullable. + return scanTypeNullTime + + default: + return scanTypeUnknown + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/go.mod b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/go.mod new file mode 100644 index 0000000..728ab9f --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/go.mod @@ -0,0 +1,5 @@ +module github.com/go-sql-driver/mysql + +go 1.24.0 + +require filippo.io/edwards25519 v1.2.0 diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/go.sum b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/go.sum new file mode 100644 index 0000000..277ea2e --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/go.sum @@ -0,0 +1,2 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/infile.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/infile.go new file mode 100644 index 0000000..597b5e7 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/infile.go @@ -0,0 +1,181 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "fmt" + "io" + "os" + "strings" + "sync" +) + +var ( + fileRegister map[string]struct{} + fileRegisterLock sync.RWMutex + readerRegister map[string]func() io.Reader + readerRegisterLock sync.RWMutex +) + +// RegisterLocalFile adds the given file to the file allowlist, +// so that it can be used by "LOAD DATA LOCAL INFILE ". +// Alternatively you can allow the use of all local files with +// the DSN parameter 'allowAllFiles=true' +// +// filePath := "/home/gopher/data.csv" +// mysql.RegisterLocalFile(filePath) +// err := db.Exec("LOAD DATA LOCAL INFILE '" + filePath + "' INTO TABLE foo") +// if err != nil { +// ... +func RegisterLocalFile(filePath string) { + fileRegisterLock.Lock() + // lazy map init + if fileRegister == nil { + fileRegister = make(map[string]struct{}) + } + + fileRegister[strings.Trim(filePath, `"`)] = struct{}{} + fileRegisterLock.Unlock() +} + +// DeregisterLocalFile removes the given filepath from the allowlist. +func DeregisterLocalFile(filePath string) { + fileRegisterLock.Lock() + delete(fileRegister, strings.Trim(filePath, `"`)) + fileRegisterLock.Unlock() +} + +// RegisterReaderHandler registers a handler function which is used +// to receive a io.Reader. +// The Reader can be used by "LOAD DATA LOCAL INFILE Reader::". +// If the handler returns a io.ReadCloser Close() is called when the +// request is finished. +// +// mysql.RegisterReaderHandler("data", func() io.Reader { +// var csvReader io.Reader // Some Reader that returns CSV data +// ... // Open Reader here +// return csvReader +// }) +// err := db.Exec("LOAD DATA LOCAL INFILE 'Reader::data' INTO TABLE foo") +// if err != nil { +// ... +func RegisterReaderHandler(name string, handler func() io.Reader) { + readerRegisterLock.Lock() + // lazy map init + if readerRegister == nil { + readerRegister = make(map[string]func() io.Reader) + } + + readerRegister[name] = handler + readerRegisterLock.Unlock() +} + +// DeregisterReaderHandler removes the ReaderHandler function with +// the given name from the registry. +func DeregisterReaderHandler(name string) { + readerRegisterLock.Lock() + delete(readerRegister, name) + readerRegisterLock.Unlock() +} + +func deferredClose(err *error, closer io.Closer) { + closeErr := closer.Close() + if *err == nil { + *err = closeErr + } +} + +const defaultPacketSize = 16 * 1024 // 16KB is small enough for disk readahead and large enough for TCP + +func (mc *okHandler) handleInFileRequest(name string) (err error) { + var rdr io.Reader + packetSize := min(mc.maxWriteSize, defaultPacketSize) + + if idx := strings.Index(name, "Reader::"); idx == 0 || (idx > 0 && name[idx-1] == '/') { // io.Reader + // The server might return an an absolute path. See issue #355. + name = name[idx+8:] + + readerRegisterLock.RLock() + handler, inMap := readerRegister[name] + readerRegisterLock.RUnlock() + + if inMap { + rdr = handler() + if rdr != nil { + if cl, ok := rdr.(io.Closer); ok { + defer deferredClose(&err, cl) + } + } else { + err = fmt.Errorf("reader '%s' is ", name) + } + } else { + err = fmt.Errorf("reader '%s' is not registered", name) + } + } else { // File + name = strings.Trim(name, `"`) + fileRegisterLock.RLock() + _, exists := fileRegister[name] + fileRegisterLock.RUnlock() + if mc.cfg.AllowAllFiles || exists { + var file *os.File + var fi os.FileInfo + + if file, err = os.Open(name); err == nil { + defer deferredClose(&err, file) + + // get file size + if fi, err = file.Stat(); err == nil { + rdr = file + if fileSize := int(fi.Size()); fileSize < packetSize { + packetSize = fileSize + } + } + } + } else { + err = fmt.Errorf("local file '%s' is not registered", name) + } + } + + // send content packets + var data []byte + + // if packetSize == 0, the Reader contains no data + if err == nil && packetSize > 0 { + data = make([]byte, 4+packetSize) + var n int + for err == nil { + n, err = rdr.Read(data[4:]) + if n > 0 { + if ioErr := mc.conn().writePacket(data[:4+n]); ioErr != nil { + return ioErr + } + } + } + if err == io.EOF { + err = nil + } + } + + // send empty packet (termination) + if data == nil { + data = make([]byte, 4) + } + if ioErr := mc.conn().writePacket(data[:4]); ioErr != nil { + return ioErr + } + mc.conn().syncSequence() + + // read OK packet + if err == nil { + return mc.readResultOK() + } + + mc.conn().readPacket() + return err +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/nulltime.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/nulltime.go new file mode 100644 index 0000000..316a48a --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/nulltime.go @@ -0,0 +1,71 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "database/sql" + "database/sql/driver" + "fmt" + "time" +) + +// NullTime represents a time.Time that may be NULL. +// NullTime implements the Scanner interface so +// it can be used as a scan destination: +// +// var nt NullTime +// err := db.QueryRow("SELECT time FROM foo WHERE id=?", id).Scan(&nt) +// ... +// if nt.Valid { +// // use nt.Time +// } else { +// // NULL value +// } +// +// # This NullTime implementation is not driver-specific +// +// Deprecated: NullTime doesn't honor the loc DSN parameter. +// NullTime.Scan interprets a time as UTC, not the loc DSN parameter. +// Use sql.NullTime instead. +type NullTime sql.NullTime + +// Scan implements the Scanner interface. +// The value type must be time.Time or string / []byte (formatted time-string), +// otherwise Scan fails. +func (nt *NullTime) Scan(value any) (err error) { + if value == nil { + nt.Time, nt.Valid = time.Time{}, false + return + } + + switch v := value.(type) { + case time.Time: + nt.Time, nt.Valid = v, true + return + case []byte: + nt.Time, err = parseDateTime(v, time.UTC) + nt.Valid = (err == nil) + return + case string: + nt.Time, err = parseDateTime([]byte(v), time.UTC) + nt.Valid = (err == nil) + return + } + + nt.Valid = false + return fmt.Errorf("can't convert %T to time.Time", value) +} + +// Value implements the driver Valuer interface. +func (nt NullTime) Value() (driver.Value, error) { + if !nt.Valid { + return nil, nil + } + return nt.Time, nil +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/nulltime_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/nulltime_test.go new file mode 100644 index 0000000..4f1d902 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/nulltime_test.go @@ -0,0 +1,62 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "database/sql" + "database/sql/driver" + "testing" + "time" +) + +var ( + // Check implementation of interfaces + _ driver.Valuer = NullTime{} + _ sql.Scanner = (*NullTime)(nil) +) + +func TestScanNullTime(t *testing.T) { + var scanTests = []struct { + in any + error bool + valid bool + time time.Time + }{ + {tDate, false, true, tDate}, + {sDate, false, true, tDate}, + {[]byte(sDate), false, true, tDate}, + {tDateTime, false, true, tDateTime}, + {sDateTime, false, true, tDateTime}, + {[]byte(sDateTime), false, true, tDateTime}, + {tDate0, false, true, tDate0}, + {sDate0, false, true, tDate0}, + {[]byte(sDate0), false, true, tDate0}, + {sDateTime0, false, true, tDate0}, + {[]byte(sDateTime0), false, true, tDate0}, + {"", true, false, tDate0}, + {"1234", true, false, tDate0}, + {0, true, false, tDate0}, + } + + var nt = NullTime{} + var err error + + for _, tst := range scanTests { + err = nt.Scan(tst.in) + if (err != nil) != tst.error { + t.Errorf("%v: expected error status %t, got %t", tst.in, tst.error, (err != nil)) + } + if nt.Valid != tst.valid { + t.Errorf("%v: expected valid status %t, got %t", tst.in, tst.valid, nt.Valid) + } + if nt.Time != tst.time { + t.Errorf("%v: expected time %v, got %v", tst.in, tst.time, nt.Time) + } + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/packets.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/packets.go new file mode 100644 index 0000000..d0b21b0 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/packets.go @@ -0,0 +1,1451 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "crypto/tls" + "database/sql/driver" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math" + "os" + "strconv" + "time" +) + +// MySQL client/server protocol documentations. +// https://dev.mysql.com/doc/dev/mysql-server/latest/PAGE_PROTOCOL.html +// https://mariadb.com/kb/en/clientserver-protocol/ + +// read n bytes from mc.buf +func (mc *mysqlConn) readNext(n int) ([]byte, error) { + if mc.buf.len() < n { + err := mc.buf.fill(n, mc.readWithTimeout) + if err != nil { + return nil, err + } + } + return mc.buf.readNext(n), nil +} + +// Read packet to buffer 'data' +func (mc *mysqlConn) readPacket() ([]byte, error) { + var prevData []byte + invalidSequence := false + + readNext := mc.readNext + if mc.compress { + readNext = mc.compIO.readNext + } + + for { + // read packet header + data, err := readNext(4) + if err != nil { + mc.close() + if cerr := mc.canceled.Value(); cerr != nil { + return nil, cerr + } + mc.log(err) + return nil, ErrInvalidConn + } + + // packet length [24 bit] + pktLen := getUint24(data[:3]) + seq := data[3] + + // check packet sync [8 bit] + if seq != mc.sequence { + mc.log(fmt.Sprintf("[warn] unexpected sequence nr: expected %v, got %v", mc.sequence, seq)) + // MySQL and MariaDB doesn't check packet nr in compressed packet. + if !mc.compress { + // For large packets, we stop reading as soon as sync error. + if len(prevData) > 0 { + mc.close() + return nil, ErrPktSyncMul + } + invalidSequence = true + } + } + mc.sequence = seq + 1 + + // packets with length 0 terminate a previous packet which is a + // multiple of (2^24)-1 bytes long + if pktLen == 0 { + // there was no previous packet + if prevData == nil { + mc.log(ErrMalformPkt) + mc.close() + return nil, ErrInvalidConn + } + return prevData, nil + } + + // read packet body [pktLen bytes] + data, err = readNext(pktLen) + if err != nil { + mc.close() + if cerr := mc.canceled.Value(); cerr != nil { + return nil, cerr + } + mc.log(err) + return nil, ErrInvalidConn + } + + // return data if this was the last packet + if pktLen < maxPacketSize { + // zero allocations for non-split packets + if prevData != nil { + data = append(prevData, data...) + } + if invalidSequence { + mc.close() + // return sync error only for regular packet. + // error packets may have wrong sequence number. + if data[0] != iERR { + return nil, ErrPktSync + } + } + return data, nil + } + + prevData = append(prevData, data...) + } +} + +// Write packet buffer 'data' +func (mc *mysqlConn) writePacket(data []byte) error { + pktLen := len(data) - 4 + if pktLen > mc.maxAllowedPacket { + return ErrPktTooLarge + } + + writeFunc := mc.writeWithTimeout + if mc.compress { + writeFunc = mc.compIO.writePackets + } + + for { + size := min(maxPacketSize, pktLen) + putUint24(data[:3], size) + data[3] = mc.sequence + + // Write packet + if debug { + fmt.Fprintf(os.Stderr, "writePacket: size=%v seq=%v\n", size, mc.sequence) + } + + n, err := writeFunc(data[:4+size]) + if err != nil { + mc.cleanup() + if cerr := mc.canceled.Value(); cerr != nil { + return cerr + } + if n == 0 && pktLen == len(data)-4 { + // only for the first loop iteration when nothing was written yet + mc.log(err) + return errBadConnNoWrite + } else { + return err + } + } + if n != 4+size { + // io.Writer(b) must return a non-nil error if it cannot write len(b) bytes. + // The io.ErrShortWrite error is used to indicate that this rule has not been followed. + mc.cleanup() + return io.ErrShortWrite + } + + mc.sequence++ + if size != maxPacketSize { + return nil + } + pktLen -= size + data = data[size:] + } +} + +/****************************************************************************** +* Initialization Process * +******************************************************************************/ + +// Handshake Initialization Packet +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_handshake_v10.html +// https://mariadb.com/kb/en/connection/#initial-handshake-packet +func (mc *mysqlConn) readHandshakePacket() (data []byte, capabilities capabilityFlag, extendedCapabilities extendedCapabilityFlag, plugin string, err error) { + data, err = mc.readPacket() + if err != nil { + return + } + + if data[0] == iERR { + err = mc.handleErrorPacket(data) + return + } + + // protocol version [1 byte] + if data[0] < minProtocolVersion { + return nil, 0, 0, "", fmt.Errorf( + "unsupported protocol version %d. Version %d or higher is required", + data[0], + minProtocolVersion, + ) + } + + // server version [null terminated string] + // connection id [4 bytes] + pos := 1 + bytes.IndexByte(data[1:], 0x00) + 1 + 4 + + // first part of the password cipher [8 bytes] + authData := data[pos : pos+8] + + // (filler) always 0x00 [1 byte] + pos += 8 + 1 + + // capability flags (lower 2 bytes) [2 bytes] + capabilities = capabilityFlag(binary.LittleEndian.Uint16(data[pos : pos+2])) + if capabilities&clientProtocol41 == 0 { + return nil, capabilities, 0, "", ErrOldProtocol + } + if capabilities&clientSSL == 0 && mc.cfg.TLS != nil { + if mc.cfg.AllowFallbackToPlaintext { + mc.cfg.TLS = nil + } else { + return nil, capabilities, 0, "", ErrNoTLS + } + } + pos += 2 + + if len(data) > pos { + // character set [1 byte] + // status flags [2 bytes] + pos += 3 + // capability flags (upper 2 bytes) [2 bytes] + capabilities |= capabilityFlag(binary.LittleEndian.Uint16(data[pos:pos+2])) << 16 + pos += 2 + // length of auth-plugin-data [1 byte] + // reserved (all [00]) [6 bytes] + pos += 7 + if capabilities&clientMySQL == 0 { + // MariaDB server extended flag + extendedCapabilities = extendedCapabilityFlag(binary.LittleEndian.Uint32(data[pos : pos+4])) + } + pos += 4 + + // second part of the password cipher [minimum 13 bytes], + // where len=MAX(13, length of auth-plugin-data - 8) + // + // The web documentation is ambiguous about the length. However, + // according to mysql-5.7/sql/auth/sql_authentication.cc line 538, + // the 13th byte is "\0 byte, terminating the second part of + // a scramble". So the second part of the password cipher is + // a NULL terminated string that's at least 13 bytes with the + // last byte being NULL. + // + // The official Python library uses the fixed length 12 + // which seems to work but technically could have a hidden bug. + authData = append(authData, data[pos:pos+12]...) + pos += 13 + + // EOF if version (>= 5.5.7 and < 5.5.10) or (>= 5.6.0 and < 5.6.2) + // \NUL otherwise + if end := bytes.IndexByte(data[pos:], 0x00); end != -1 { + plugin = string(data[pos : pos+end]) + } else { + plugin = string(data[pos:]) + } + + // make a memory safe copy of the cipher slice + var b [20]byte + copy(b[:], authData) + return b[:], capabilities, extendedCapabilities, plugin, nil + } + + // make a memory safe copy of the cipher slice + var b [8]byte + copy(b[:], authData) + return b[:], capabilities, 0, plugin, nil +} + +// initCapabilities initializes the capabilities based on server support and configuration +func (mc *mysqlConn) initCapabilities(serverCapabilities capabilityFlag, serverExtCapabilities extendedCapabilityFlag, cfg *Config) { + clientCapabilities := + clientMySQL | + clientLongFlag | + clientProtocol41 | + clientSecureConn | + clientTransactions | + clientPluginAuthLenEncClientData | + clientLocalFiles | + clientPluginAuth | + clientMultiResults | + clientConnectAttrs | + clientDeprecateEOF + + if cfg.ClientFoundRows { + clientCapabilities |= clientFoundRows + } + if cfg.compress { + clientCapabilities |= clientCompress + } + // To enable TLS / SSL + if mc.cfg.TLS != nil { + clientCapabilities |= clientSSL + } + + if mc.cfg.MultiStatements { + clientCapabilities |= clientMultiStatements + } + if n := len(cfg.DBName); n > 0 { + clientCapabilities |= clientConnectWithDB + } + + // only keep client capabilities that server have + mc.capabilities = clientCapabilities & serverCapabilities + + // set MariaDB extended clientCacheMetadata capability if server support it + mc.extCapabilities = clientCacheMetadata & serverExtCapabilities +} + +// Client Authentication Packet +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_handshake_response.html +func (mc *mysqlConn) writeHandshakeResponsePacket(authResp []byte, plugin string) error { + // packet header 4 + // capabilities 4 + // maxPacketSize 4 + // collation id 1 + // filler 23 + data, err := mc.buf.takeSmallBuffer(4*3 + 24) + if err != nil { + mc.cleanup() + return err + } + _ = data[4*3+23] // boundery check + + // clientCapabilities [32 bit] + binary.LittleEndian.PutUint32(data[4:], uint32(mc.capabilities)) + + // MaxPacketSize [32 bit] (none) + binary.LittleEndian.PutUint32(data[8:], 0) + + // Collation ID [1 byte] + data[12] = defaultCollationID + if cname := mc.cfg.Collation; cname != "" { + colID, ok := collations[cname] + if ok { + data[12] = colID + } else if len(mc.cfg.charsets) > 0 { + // When cfg.charset is set, the collation is set by `SET NAMES COLLATE `. + return fmt.Errorf("unknown collation: %q", cname) + } + } + + // Filler [23 bytes] (all 0x00) + // or filler 19bytes + mariadb extCapabilities + pos := 13 + if mc.capabilities&clientMySQL == 0 { + for ; pos < 13+19; pos++ { + data[pos] = 0 + } + // MariaDB Extended Capabilities + binary.LittleEndian.PutUint32(data[13+19:], uint32(mc.extCapabilities)) + } else { + for ; pos < 13+23; pos++ { + data[pos] = 0 + } + } + + // SSL Connection Request Packet + // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_ssl_request.html + // https://mariadb.com/kb/en/connection/#sslrequest-packet + if mc.cfg.TLS != nil { + // Send TLS / SSL request packet + if err := mc.writePacket(data); err != nil { + return err + } + + // Switch to TLS + tlsConn := tls.Client(mc.netConn, mc.cfg.TLS) + if err := tlsConn.Handshake(); err != nil { + if cerr := mc.canceled.Value(); cerr != nil { + return cerr + } + return err + } + mc.netConn = tlsConn + } + + // User [null terminated string] + if len(mc.cfg.User) > 0 { + data = append(data, mc.cfg.User...) + } + data = append(data, 0) + + // Auth Data [length encoded integer] + data = appendLengthEncodedInteger(data, uint64(len(authResp))) + data = append(data, authResp...) + + // Database name [null terminated string] + if mc.capabilities&clientConnectWithDB != 0 { + data = append(data, mc.cfg.DBName...) + data = append(data, 0) + } + + data = append(data, plugin...) + data = append(data, 0) + + // Connection Attributes + if mc.capabilities&clientConnectAttrs != 0 { + connAttrsLen := len(mc.connector.encodedAttributes) + data = appendLengthEncodedInteger(data, uint64(connAttrsLen)) + data = append(data, mc.connector.encodedAttributes...) + } + + // Send Auth packet + return mc.writePacket(data) +} + +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_auth_switch_response.html +func (mc *mysqlConn) writeAuthSwitchPacket(authData []byte) error { + pktLen := 4 + len(authData) + data, err := mc.buf.takeBuffer(pktLen) + if err != nil { + mc.cleanup() + return err + } + + // Add the auth data [EOF] + copy(data[4:], authData) + return mc.writePacket(data) +} + +/****************************************************************************** +* Command Packets * +******************************************************************************/ + +func (mc *mysqlConn) writeCommandPacket(command byte) error { + // Reset Packet Sequence + mc.resetSequence() + + data, err := mc.buf.takeSmallBuffer(4 + 1) + if err != nil { + return err + } + + // Add command byte + data[4] = command + + // Send CMD packet + err = mc.writePacket(data) + mc.syncSequence() + return err +} + +func (mc *mysqlConn) writeCommandPacketStr(command byte, arg string) error { + // Reset Packet Sequence + mc.resetSequence() + + pktLen := 1 + len(arg) + data, err := mc.buf.takeBuffer(pktLen + 4) + if err != nil { + return err + } + + // Add command byte + data[4] = command + + // Add arg + copy(data[5:], arg) + + // Send CMD packet + err = mc.writePacket(data) + mc.syncSequence() + return err +} + +func (mc *mysqlConn) writeCommandPacketUint32(command byte, arg uint32) error { + // Reset Packet Sequence + mc.resetSequence() + + data, err := mc.buf.takeSmallBuffer(4 + 1 + 4) + if err != nil { + return err + } + + // Add command byte + data[4] = command + + // Add arg [32 bit] + binary.LittleEndian.PutUint32(data[5:], arg) + + // Send CMD packet + err = mc.writePacket(data) + mc.syncSequence() + return err +} + +/****************************************************************************** +* Result Packets * +******************************************************************************/ + +func (mc *mysqlConn) readAuthResult() ([]byte, string, error) { + data, err := mc.readPacket() + if err != nil { + return nil, "", err + } + + // packet indicator + switch data[0] { + + case iOK: + // resultUnchanged, since auth happens before any queries or + // commands have been executed. + return nil, "", mc.resultUnchanged().handleOkPacket(data) + + case iAuthMoreData: + return data[1:], "", err + + case iEOF: + if len(data) == 1 { + // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_old_auth_switch_request.html + return nil, "mysql_old_password", nil + } + pluginEndIndex := bytes.IndexByte(data, 0x00) + if pluginEndIndex < 0 { + return nil, "", ErrMalformPkt + } + plugin := string(data[1:pluginEndIndex]) + authData := data[pluginEndIndex+1:] + if len(authData) > 0 && authData[len(authData)-1] == 0 { + authData = authData[:len(authData)-1] + } + return authData, plugin, nil + + default: // Error otherwise + return nil, "", mc.handleErrorPacket(data) + } +} + +// Returns error if Packet is not a 'Result OK'-Packet +func (mc *okHandler) readResultOK() error { + data, err := mc.conn().readPacket() + if err != nil { + return err + } + + if data[0] == iOK { + return mc.handleOkPacket(data) + } + return mc.conn().handleErrorPacket(data) +} + +// Result Set Header Packet +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response.html +func (mc *okHandler) readResultSetHeaderPacket() (int, bool, error) { + // handleOkPacket replaces both values; other cases leave the values unchanged. + mc.result.affectedRows = append(mc.result.affectedRows, 0) + mc.result.insertIds = append(mc.result.insertIds, 0) + + data, err := mc.conn().readPacket() + if err != nil { + return 0, false, err + } + + switch data[0] { + case iOK: + return 0, false, mc.handleOkPacket(data) + + case iERR: + return 0, false, mc.conn().handleErrorPacket(data) + + case iLocalInFile: + return 0, false, mc.handleInFileRequest(string(data[1:])) + } + + // column count + // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset.html + // https://mariadb.com/kb/en/result-set-packets/#column-count-packet + num, _, len := readLengthEncodedInteger(data) + + if mc.extCapabilities&clientCacheMetadata != 0 { + return int(num), data[len] == 0x01, nil + } + // ignore remaining data in the packet. see #1478. + return int(num), true, nil +} + +// Error Packet +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_err_packet.html +func (mc *mysqlConn) handleErrorPacket(data []byte) error { + if data[0] != iERR { + return ErrMalformPkt + } + + // 0xff [1 byte] + + // Error Number [16 bit uint] + errno := binary.LittleEndian.Uint16(data[1:3]) + + // 1792: ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION + // 1290: ER_OPTION_PREVENTS_STATEMENT (returned by Aurora during failover) + // 1836: ER_READ_ONLY_MODE + if (errno == 1792 || errno == 1290 || errno == 1836) && mc.cfg.RejectReadOnly { + // Oops; we are connected to a read-only connection, and won't be able + // to issue any write statements. Since RejectReadOnly is configured, + // we throw away this connection hoping this one would have write + // permission. This is specifically for a possible race condition + // during failover (e.g. on AWS Aurora). See README.md for more. + // + // We explicitly close the connection before returning + // driver.ErrBadConn to ensure that `database/sql` purges this + // connection and initiates a new one for next statement next time. + mc.Close() + return driver.ErrBadConn + } + + me := &MySQLError{Number: errno} + + pos := 3 + + // SQL State [optional: # + 5bytes string] + if data[3] == 0x23 { + copy(me.SQLState[:], data[4:4+5]) + pos = 9 + } + + // Error Message [string] + me.Message = string(data[pos:]) + + return me +} + +func readStatus(b []byte) statusFlag { + return statusFlag(b[0]) | statusFlag(b[1])<<8 +} + +// Returns an instance of okHandler for codepaths where mysqlConn.result doesn't +// need to be cleared first (e.g. during authentication, or while additional +// resultsets are being fetched.) +func (mc *mysqlConn) resultUnchanged() *okHandler { + return (*okHandler)(mc) +} + +// okHandler represents the state of the connection when mysqlConn.result has +// been prepared for processing of OK packets. +// +// To correctly populate mysqlConn.result (updated by handleOkPacket()), all +// callpaths must either: +// +// 1. first clear it using clearResult(), or +// 2. confirm that they don't need to (by calling resultUnchanged()). +// +// Both return an instance of type *okHandler. +type okHandler mysqlConn + +// Exposes the underlying type's methods. +func (mc *okHandler) conn() *mysqlConn { + return (*mysqlConn)(mc) +} + +// clearResult clears the connection's stored affectedRows and insertIds +// fields. +// +// It returns a handler that can process OK responses. +func (mc *mysqlConn) clearResult() *okHandler { + mc.result = mysqlResult{} + return (*okHandler)(mc) +} + +// Ok Packet +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_ok_packet.html +func (mc *okHandler) handleOkPacket(data []byte) error { + var n, m int + var affectedRows, insertId uint64 + + // 0x00 [1 byte] + + // Affected rows [Length Coded Binary] + affectedRows, _, n = readLengthEncodedInteger(data[1:]) + + // Insert id [Length Coded Binary] + insertId, _, m = readLengthEncodedInteger(data[1+n:]) + + // Update for the current statement result (only used by + // readResultSetHeaderPacket). + if len(mc.result.affectedRows) > 0 { + mc.result.affectedRows[len(mc.result.affectedRows)-1] = int64(affectedRows) + } + if len(mc.result.insertIds) > 0 { + mc.result.insertIds[len(mc.result.insertIds)-1] = int64(insertId) + } + + // server_status [2 bytes] + mc.status = readStatus(data[1+n+m : 1+n+m+2]) + if mc.status&statusMoreResultsExists != 0 { + return nil + } + + // warning count [2 bytes] + + return nil +} + +// Read Packets as Field Packets until EOF-Packet or an Error appears +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset_column_definition.html#sect_protocol_com_query_response_text_resultset_column_definition_41 +func (mc *mysqlConn) readColumns(count int, old []mysqlField) ([]mysqlField, error) { + columns := make([]mysqlField, count) + if len(old) != count { + old = nil + } + + for i := range count { + data, err := mc.readPacket() + if err != nil { + return nil, err + } + + // Catalog + pos, err := skipLengthEncodedString(data) + if err != nil { + return nil, err + } + + // Database [len coded string] + n, err := skipLengthEncodedString(data[pos:]) + if err != nil { + return nil, err + } + pos += n + + // Table [len coded string] + if mc.cfg.ColumnsWithAlias { + tableName, _, n, err := readLengthEncodedString(data[pos:]) + if err != nil { + return nil, err + } + pos += n + if old != nil && old[i].tableName == string(tableName) { + // avoid allocating new string + columns[i].tableName = old[i].tableName + } else { + columns[i].tableName = string(tableName) + } + } else { + n, err = skipLengthEncodedString(data[pos:]) + if err != nil { + return nil, err + } + pos += n + } + + // Original table [len coded string] + n, err = skipLengthEncodedString(data[pos:]) + if err != nil { + return nil, err + } + pos += n + + // Name [len coded string] + name, _, n, err := readLengthEncodedString(data[pos:]) + if err != nil { + return nil, err + } + if old != nil && old[i].name == string(name) { + // avoid allocating new string + columns[i].name = old[i].name + } else { + columns[i].name = string(name) + } + pos += n + + // Original name [len coded string] + n, err = skipLengthEncodedString(data[pos:]) + if err != nil { + return nil, err + } + pos += n + + // Filler [uint8] + pos++ + + // Charset [charset, collation uint8] + columns[i].charSet = data[pos] + pos += 2 + + // Length [uint32] + columns[i].length = binary.LittleEndian.Uint32(data[pos : pos+4]) + pos += 4 + + // Field type [uint8] + columns[i].fieldType = fieldType(data[pos]) + pos++ + + // Flags [uint16] + columns[i].flags = fieldFlag(binary.LittleEndian.Uint16(data[pos : pos+2])) + pos += 2 + + // Decimals [uint8] + columns[i].decimals = data[pos] + } + + // skip EOF packet if client does not support deprecateEOF + if err := mc.skipEof(); err != nil { + return nil, err + } + return columns, nil +} + +// Read Packets as Field Packets until EOF-Packet or an Error appears +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset_row.html +func (rows *textRows) readRow(dest []driver.Value) error { + mc := rows.mc + + if rows.rs.done { + return io.EOF + } + + data, err := mc.readPacket() + if err != nil { + return err + } + + // EOF Packet + // text row packets may starts with LengthEncodedString. + // In such case, 0xFE can mean string larger than 0xffffff. + // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_integers.html#sect_protocol_basic_dt_int_le + if data[0] == iEOF && len(data) <= 0xffffff { + if mc.capabilities&clientDeprecateEOF == 0 { + // Deprecated EOF packet + // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_eof_packet.html + mc.status = readStatus(data[3:]) + } else { + // Ok Packet with an 0xFE header + _, _, n := readLengthEncodedInteger(data[1:]) // affected_rows + _, _, m := readLengthEncodedInteger(data[1+n:]) // last_insert_id + mc.status = readStatus(data[1+n+m:]) + } + rows.rs.done = true + if !rows.HasNextResultSet() { + rows.mc = nil + } + return io.EOF + } + if data[0] == iERR { + rows.mc = nil + return mc.handleErrorPacket(data) + } + + // RowSet Packet + var ( + n int + isNull bool + pos int = 0 + ) + + for i := range dest { + // Read bytes and convert to string + var buf []byte + buf, isNull, n, err = readLengthEncodedString(data[pos:]) + pos += n + + if err != nil { + return err + } + + if isNull { + dest[i] = nil + continue + } + + switch rows.rs.columns[i].fieldType { + case fieldTypeTimestamp, + fieldTypeDateTime, + fieldTypeDate, + fieldTypeNewDate: + if mc.parseTime { + dest[i], err = parseDateTime(buf, mc.cfg.Loc) + } else { + dest[i] = buf + } + + case fieldTypeTiny, fieldTypeShort, fieldTypeInt24, fieldTypeYear, fieldTypeLong: + dest[i], err = strconv.ParseInt(string(buf), 10, 64) + + case fieldTypeLongLong: + if rows.rs.columns[i].flags&flagUnsigned != 0 { + dest[i], err = strconv.ParseUint(string(buf), 10, 64) + } else { + dest[i], err = strconv.ParseInt(string(buf), 10, 64) + } + + case fieldTypeFloat: + var d float64 + d, err = strconv.ParseFloat(string(buf), 32) + dest[i] = float32(d) + + case fieldTypeDouble: + dest[i], err = strconv.ParseFloat(string(buf), 64) + + default: + dest[i] = buf + } + if err != nil { + return err + } + } + + return nil +} + +func (mc *mysqlConn) skipPackets(n int) error { + for range n { + if _, err := mc.readPacket(); err != nil { + return err + } + } + return nil +} + +// skips EOF packet after n * ColumnDefinition packets when clientDeprecateEOF is not set +func (mc *mysqlConn) skipEof() error { + if mc.capabilities&clientDeprecateEOF == 0 { + if _, err := mc.readPacket(); err != nil { + return err + } + } + return nil +} + +func (mc *mysqlConn) skipColumns(n int) error { + if err := mc.skipPackets(n); err != nil { + return err + } + return mc.skipEof() +} + +// Reads Packets until EOF-Packet or an Error appears. +func (mc *mysqlConn) skipRows() error { + for { + data, err := mc.readPacket() + if err != nil { + return err + } + + switch data[0] { + case iERR: + return mc.handleErrorPacket(data) + case iEOF: + // text row packets may starts with LengthEncodedString. + // In such case, 0xFE can mean string larger than 0xffffff. + if len(data) <= 0xffffff { + if mc.capabilities&clientDeprecateEOF == 0 { + // EOF packet + mc.status = readStatus(data[3:]) + } else { + // OK packet with an 0xFE header + _, _, n := readLengthEncodedInteger(data[1:]) // affected_rows + _, _, m := readLengthEncodedInteger(data[1+n:]) // last_insert_id + mc.status = readStatus(data[1+n+m:]) + } + return nil + } + } + } +} + +/****************************************************************************** +* Prepared Statements * +******************************************************************************/ + +// Prepare Result Packets +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_stmt_prepare.html#sect_protocol_com_stmt_prepare_response +func (stmt *mysqlStmt) readPrepareResultPacket() (uint16, error) { + data, err := stmt.mc.readPacket() + if err == nil { + // packet indicator [1 byte] + if data[0] != iOK { + return 0, stmt.mc.handleErrorPacket(data) + } + + // statement id [4 bytes] + stmt.id = binary.LittleEndian.Uint32(data[1:5]) + + // Column count [16 bit uint] + columnCount := binary.LittleEndian.Uint16(data[5:7]) + + // Param count [16 bit uint] + stmt.paramCount = int(binary.LittleEndian.Uint16(data[7:9])) + + // Reserved [8 bit] + + // Warning count [16 bit uint] + + return columnCount, nil + } + return 0, err +} + +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_stmt_send_long_data.html +func (stmt *mysqlStmt) writeCommandLongData(paramID int, arg []byte) error { + maxLen := stmt.mc.maxAllowedPacket - 1 + pktLen := maxLen + + // After the header (bytes 0-3) follows before the data: + // 1 byte command + // 4 bytes stmtID + // 2 bytes paramID + const dataOffset = 1 + 4 + 2 + + // Cannot use the write buffer since + // a) the buffer is too small + // b) it is in use + data := make([]byte, 4+1+4+2+len(arg)) + + copy(data[4+dataOffset:], arg) + + for argLen := len(arg); argLen > 0; argLen -= pktLen - dataOffset { + if dataOffset+argLen < maxLen { + pktLen = dataOffset + argLen + } + + // Add command byte [1 byte] + data[4] = comStmtSendLongData + + // Add stmtID [32 bit] + binary.LittleEndian.PutUint32(data[5:], stmt.id) + + // Add paramID [16 bit] + binary.LittleEndian.PutUint16(data[9:], uint16(paramID)) + + // Send CMD packet + err := stmt.mc.writePacket(data[:4+pktLen]) + // Every COM_LONG_DATA packet reset Packet Sequence + stmt.mc.resetSequence() + if err == nil { + data = data[pktLen-dataOffset:] + continue + } + return err + } + + return nil +} + +// Execute Prepared Statement +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_stmt_execute.html +func (stmt *mysqlStmt) writeExecutePacket(args []driver.Value) error { + if len(args) != stmt.paramCount { + return fmt.Errorf( + "argument count mismatch (got: %d; has: %d)", + len(args), + stmt.paramCount, + ) + } + + const minPktLen = 4 + 1 + 4 + 1 + 4 + mc := stmt.mc + + // Determine threshold dynamically to avoid packet size shortage. + longDataSize := max(mc.maxAllowedPacket/(stmt.paramCount+1), 64) + + // Reset packet-sequence + mc.resetSequence() + + var data []byte + var err error + + if len(args) == 0 { + data, err = mc.buf.takeBuffer(minPktLen) + } else { + data, err = mc.buf.takeCompleteBuffer() + // In this case the len(data) == cap(data) which is used to optimise the flow below. + } + if err != nil { + return err + } + + // command [1 byte] + data[4] = comStmtExecute + + // statement_id [4 bytes] + binary.LittleEndian.PutUint32(data[5:], stmt.id) + + // flags (0: CURSOR_TYPE_NO_CURSOR) [1 byte] + data[9] = 0x00 + + // iteration_count (uint32(1)) [4 bytes] + binary.LittleEndian.PutUint32(data[10:], 1) + + if len(args) > 0 { + pos := minPktLen + + var nullMask []byte + if maskLen, typesLen := (len(args)+7)/8, 1+2*len(args); pos+maskLen+typesLen >= cap(data) { + // buffer has to be extended but we don't know by how much so + // we depend on append after all data with known sizes fit. + // We stop at that because we deal with a lot of columns here + // which makes the required allocation size hard to guess. + tmp := make([]byte, pos+maskLen+typesLen) + copy(tmp[:pos], data[:pos]) + data = tmp + nullMask = data[pos : pos+maskLen] + // No need to clean nullMask as make ensures that. + pos += maskLen + } else { + nullMask = data[pos : pos+maskLen] + for i := range nullMask { + nullMask[i] = 0 + } + pos += maskLen + } + + // newParameterBoundFlag 1 [1 byte] + data[pos] = 0x01 + pos++ + + // type of each parameter [len(args)*2 bytes] + paramTypes := data[pos:] + pos += len(args) * 2 + + // value of each parameter [n bytes] + paramValues := data[pos:pos] + valuesCap := cap(paramValues) + + for i, arg := range args { + // build NULL-bitmap + if arg == nil { + nullMask[i/8] |= 1 << (uint(i) & 7) + paramTypes[i+i] = byte(fieldTypeNULL) + paramTypes[i+i+1] = 0x00 + continue + } + + if v, ok := arg.(json.RawMessage); ok { + arg = []byte(v) + } + // cache types and values + switch v := arg.(type) { + case int64: + paramTypes[i+i] = byte(fieldTypeLongLong) + paramTypes[i+i+1] = 0x00 + paramValues = binary.LittleEndian.AppendUint64(paramValues, uint64(v)) + + case uint64: + paramTypes[i+i] = byte(fieldTypeLongLong) + paramTypes[i+i+1] = 0x80 // type is unsigned + paramValues = binary.LittleEndian.AppendUint64(paramValues, uint64(v)) + + case float64: + paramTypes[i+i] = byte(fieldTypeDouble) + paramTypes[i+i+1] = 0x00 + paramValues = binary.LittleEndian.AppendUint64(paramValues, math.Float64bits(v)) + + case bool: + paramTypes[i+i] = byte(fieldTypeTiny) + paramTypes[i+i+1] = 0x00 + + if v { + paramValues = append(paramValues, 0x01) + } else { + paramValues = append(paramValues, 0x00) + } + + case []byte: + // Common case (non-nil value) first + if v != nil { + paramTypes[i+i] = byte(fieldTypeString) + paramTypes[i+i+1] = 0x00 + + if len(v) < longDataSize { + paramValues = appendLengthEncodedInteger(paramValues, + uint64(len(v)), + ) + paramValues = append(paramValues, v...) + } else { + if err := stmt.writeCommandLongData(i, v); err != nil { + return err + } + } + continue + } + + // Handle []byte(nil) as a NULL value + nullMask[i/8] |= 1 << (uint(i) & 7) + paramTypes[i+i] = byte(fieldTypeNULL) + paramTypes[i+i+1] = 0x00 + + case string: + paramTypes[i+i] = byte(fieldTypeString) + paramTypes[i+i+1] = 0x00 + + if len(v) < longDataSize { + paramValues = appendLengthEncodedInteger(paramValues, + uint64(len(v)), + ) + paramValues = append(paramValues, v...) + } else { + if err := stmt.writeCommandLongData(i, []byte(v)); err != nil { + return err + } + } + + case time.Time: + paramTypes[i+i] = byte(fieldTypeString) + paramTypes[i+i+1] = 0x00 + + var a [64]byte + var b = a[:0] + + if v.IsZero() { + b = append(b, "0000-00-00"...) + } else { + b, err = appendDateTime(b, v.In(mc.cfg.Loc), mc.cfg.timeTruncate) + if err != nil { + return err + } + } + + paramValues = appendLengthEncodedInteger(paramValues, + uint64(len(b)), + ) + paramValues = append(paramValues, b...) + + default: + return fmt.Errorf("cannot convert type: %T", arg) + } + } + + // Check if param values exceeded the available buffer + // In that case we must build the data packet with the new values buffer + if valuesCap != cap(paramValues) { + data = append(data[:pos], paramValues...) + mc.buf.store(data) // allow this buffer to be reused + } + + pos += len(paramValues) + data = data[:pos] + } + + err = mc.writePacket(data) + mc.syncSequence() + return err +} + +// For each remaining resultset in the stream, discards its rows and updates +// mc.affectedRows and mc.insertIds. +func (mc *okHandler) discardResults() error { + for mc.status&statusMoreResultsExists != 0 { + resLen, _, err := mc.readResultSetHeaderPacket() + if err != nil { + return err + } + if resLen > 0 { + // columns + if err := mc.conn().skipColumns(resLen); err != nil { + return err + } + // rows + if err := mc.conn().skipRows(); err != nil { + return err + } + } + } + return nil +} + +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_binary_resultset.html#sect_protocol_binary_resultset_row +func (rows *binaryRows) readRow(dest []driver.Value) error { + data, err := rows.mc.readPacket() + if err != nil { + return err + } + + // packet indicator [1 byte] + if data[0] != iOK { + // EOF/OK Packet + if data[0] == iEOF { + if rows.mc.capabilities&clientDeprecateEOF == 0 { + // EOF packet + rows.mc.status = readStatus(data[3:]) + } else { + // OK Packet with an 0xFE header + _, _, n := readLengthEncodedInteger(data[1:]) + _, _, m := readLengthEncodedInteger(data[1+n:]) + rows.mc.status = readStatus(data[1+n+m:]) + } + rows.rs.done = true + if !rows.HasNextResultSet() { + rows.mc = nil + } + return io.EOF + } + mc := rows.mc + rows.mc = nil + + // Error otherwise + return mc.handleErrorPacket(data) + } + + // NULL-bitmap, [(column-count + 7 + 2) / 8 bytes] + pos := 1 + (len(dest)+7+2)>>3 + nullMask := data[1:pos] + + for i := range dest { + // Field is NULL + // (byte >> bit-pos) % 2 == 1 + if ((nullMask[(i+2)>>3] >> uint((i+2)&7)) & 1) == 1 { + dest[i] = nil + continue + } + + // Convert to byte-coded string + switch rows.rs.columns[i].fieldType { + case fieldTypeNULL: + dest[i] = nil + continue + + // Numeric Types + case fieldTypeTiny: + if rows.rs.columns[i].flags&flagUnsigned != 0 { + dest[i] = int64(data[pos]) + } else { + dest[i] = int64(int8(data[pos])) + } + pos++ + continue + + case fieldTypeShort, fieldTypeYear: + if rows.rs.columns[i].flags&flagUnsigned != 0 { + dest[i] = int64(binary.LittleEndian.Uint16(data[pos : pos+2])) + } else { + dest[i] = int64(int16(binary.LittleEndian.Uint16(data[pos : pos+2]))) + } + pos += 2 + continue + + case fieldTypeInt24, fieldTypeLong: + if rows.rs.columns[i].flags&flagUnsigned != 0 { + dest[i] = int64(binary.LittleEndian.Uint32(data[pos : pos+4])) + } else { + dest[i] = int64(int32(binary.LittleEndian.Uint32(data[pos : pos+4]))) + } + pos += 4 + continue + + case fieldTypeLongLong: + if rows.rs.columns[i].flags&flagUnsigned != 0 { + val := binary.LittleEndian.Uint64(data[pos : pos+8]) + if val > math.MaxInt64 { + dest[i] = uint64ToString(val) + } else { + dest[i] = int64(val) + } + } else { + dest[i] = int64(binary.LittleEndian.Uint64(data[pos : pos+8])) + } + pos += 8 + continue + + case fieldTypeFloat: + dest[i] = math.Float32frombits(binary.LittleEndian.Uint32(data[pos : pos+4])) + pos += 4 + continue + + case fieldTypeDouble: + dest[i] = math.Float64frombits(binary.LittleEndian.Uint64(data[pos : pos+8])) + pos += 8 + continue + + // Length coded Binary Strings + case fieldTypeDecimal, fieldTypeNewDecimal, fieldTypeVarChar, + fieldTypeBit, fieldTypeEnum, fieldTypeSet, fieldTypeTinyBLOB, + fieldTypeMediumBLOB, fieldTypeLongBLOB, fieldTypeBLOB, + fieldTypeVarString, fieldTypeString, fieldTypeGeometry, fieldTypeJSON, + fieldTypeVector: + var isNull bool + var n int + dest[i], isNull, n, err = readLengthEncodedString(data[pos:]) + pos += n + if err == nil { + if !isNull { + continue + } else { + dest[i] = nil + continue + } + } + return err + + case + fieldTypeDate, fieldTypeNewDate, // Date YYYY-MM-DD + fieldTypeTime, // Time [-][H]HH:MM:SS[.fractal] + fieldTypeTimestamp, fieldTypeDateTime: // Timestamp YYYY-MM-DD HH:MM:SS[.fractal] + + num, isNull, n := readLengthEncodedInteger(data[pos:]) + pos += n + + switch { + case isNull: + dest[i] = nil + continue + case rows.rs.columns[i].fieldType == fieldTypeTime: + // database/sql does not support an equivalent to TIME, return a string + var dstlen uint8 + switch decimals := rows.rs.columns[i].decimals; decimals { + case 0x00, 0x1f: + dstlen = 8 + case 1, 2, 3, 4, 5, 6: + dstlen = 8 + 1 + decimals + default: + return fmt.Errorf( + "protocol error, illegal decimals value %d", + rows.rs.columns[i].decimals, + ) + } + dest[i], err = formatBinaryTime(data[pos:pos+int(num)], dstlen) + case rows.mc.parseTime: + dest[i], err = parseBinaryDateTime(num, data[pos:], rows.mc.cfg.Loc) + default: + var dstlen uint8 + if rows.rs.columns[i].fieldType == fieldTypeDate { + dstlen = 10 + } else { + switch decimals := rows.rs.columns[i].decimals; decimals { + case 0x00, 0x1f: + dstlen = 19 + case 1, 2, 3, 4, 5, 6: + dstlen = 19 + 1 + decimals + default: + return fmt.Errorf( + "protocol error, illegal decimals value %d", + rows.rs.columns[i].decimals, + ) + } + } + dest[i], err = formatBinaryDateTime(data[pos:pos+int(num)], dstlen) + } + + if err == nil { + pos += int(num) + continue + } else { + return err + } + + // Please report if this happens! + default: + return fmt.Errorf("unknown field type %d", rows.rs.columns[i].fieldType) + } + } + + return nil +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/packets_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/packets_test.go new file mode 100644 index 0000000..b487051 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/packets_test.go @@ -0,0 +1,357 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2016 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "errors" + "net" + "testing" + "time" +) + +var ( + errConnClosed = errors.New("connection is closed") + errConnTooManyReads = errors.New("too many reads") + errConnTooManyWrites = errors.New("too many writes") +) + +// struct to mock a net.Conn for testing purposes +type mockConn struct { + laddr net.Addr + raddr net.Addr + data []byte + written []byte + queuedReplies [][]byte + closed bool + read int + reads int + writes int + maxReads int + maxWrites int +} + +func (m *mockConn) Read(b []byte) (n int, err error) { + if m.closed { + return 0, errConnClosed + } + + m.reads++ + if m.maxReads > 0 && m.reads > m.maxReads { + return 0, errConnTooManyReads + } + + n = copy(b, m.data) + m.read += n + m.data = m.data[n:] + return +} +func (m *mockConn) Write(b []byte) (n int, err error) { + if m.closed { + return 0, errConnClosed + } + + m.writes++ + if m.maxWrites > 0 && m.writes > m.maxWrites { + return 0, errConnTooManyWrites + } + + n = len(b) + m.written = append(m.written, b...) + + if n > 0 && len(m.queuedReplies) > 0 { + m.data = m.queuedReplies[0] + m.queuedReplies = m.queuedReplies[1:] + } + return +} +func (m *mockConn) Close() error { + m.closed = true + return nil +} +func (m *mockConn) LocalAddr() net.Addr { + return m.laddr +} +func (m *mockConn) RemoteAddr() net.Addr { + return m.raddr +} +func (m *mockConn) SetDeadline(t time.Time) error { + return nil +} +func (m *mockConn) SetReadDeadline(t time.Time) error { + return nil +} +func (m *mockConn) SetWriteDeadline(t time.Time) error { + return nil +} + +// make sure mockConn implements the net.Conn interface +var _ net.Conn = new(mockConn) + +func newRWMockConn(sequence uint8) (*mockConn, *mysqlConn) { + conn := new(mockConn) + connector := newConnector(NewConfig()) + mc := &mysqlConn{ + buf: newBuffer(), + cfg: connector.cfg, + connector: connector, + netConn: conn, + closech: make(chan struct{}), + maxAllowedPacket: defaultMaxAllowedPacket, + sequence: sequence, + } + return conn, mc +} + +func TestReadPacketSingleByte(t *testing.T) { + conn := new(mockConn) + mc := &mysqlConn{ + netConn: conn, + buf: newBuffer(), + cfg: NewConfig(), + } + + conn.data = []byte{0x01, 0x00, 0x00, 0x00, 0xff} + conn.maxReads = 1 + packet, err := mc.readPacket() + if err != nil { + t.Fatal(err) + } + if len(packet) != 1 { + t.Fatalf("unexpected packet length: expected %d, got %d", 1, len(packet)) + } + if packet[0] != 0xff { + t.Fatalf("unexpected packet content: expected %x, got %x", 0xff, packet[0]) + } +} + +func TestReadPacketWrongSequenceID(t *testing.T) { + for _, testCase := range []struct { + ClientSequenceID byte + ServerSequenceID byte + ExpectedErr error + }{ + { + ClientSequenceID: 1, + ServerSequenceID: 0, + ExpectedErr: ErrPktSync, + }, + { + ClientSequenceID: 0, + ServerSequenceID: 0x42, + ExpectedErr: ErrPktSync, + }, + } { + conn, mc := newRWMockConn(testCase.ClientSequenceID) + + conn.data = []byte{0x01, 0x00, 0x00, testCase.ServerSequenceID, 0x22} + _, err := mc.readPacket() + if err != testCase.ExpectedErr { + t.Errorf("expected %v, got %v", testCase.ExpectedErr, err) + } + + // connection should not be returned to the pool in this state + if mc.IsValid() { + t.Errorf("expected IsValid() to be false") + } + } +} + +func TestReadPacketSplit(t *testing.T) { + conn := new(mockConn) + mc := &mysqlConn{ + netConn: conn, + buf: newBuffer(), + cfg: NewConfig(), + } + + data := make([]byte, maxPacketSize*2+4*3) + const pkt2ofs = maxPacketSize + 4 + const pkt3ofs = 2 * (maxPacketSize + 4) + + // case 1: payload has length maxPacketSize + data = data[:pkt2ofs+4] + + // 1st packet has maxPacketSize length and sequence id 0 + // ff ff ff 00 ... + data[0] = 0xff + data[1] = 0xff + data[2] = 0xff + + // mark the payload start and end of 1st packet so that we can check if the + // content was correctly appended + data[4] = 0x11 + data[maxPacketSize+3] = 0x22 + + // 2nd packet has payload length 0 and sequence id 1 + // 00 00 00 01 + data[pkt2ofs+3] = 0x01 + + conn.data = data + conn.maxReads = 3 + packet, err := mc.readPacket() + if err != nil { + t.Fatal(err) + } + if len(packet) != maxPacketSize { + t.Fatalf("unexpected packet length: expected %d, got %d", maxPacketSize, len(packet)) + } + if packet[0] != 0x11 { + t.Fatalf("unexpected payload start: expected %x, got %x", 0x11, packet[0]) + } + if packet[maxPacketSize-1] != 0x22 { + t.Fatalf("unexpected payload end: expected %x, got %x", 0x22, packet[maxPacketSize-1]) + } + + // case 2: payload has length which is a multiple of maxPacketSize + data = data[:cap(data)] + + // 2nd packet now has maxPacketSize length + data[pkt2ofs] = 0xff + data[pkt2ofs+1] = 0xff + data[pkt2ofs+2] = 0xff + + // mark the payload start and end of the 2nd packet + data[pkt2ofs+4] = 0x33 + data[pkt2ofs+maxPacketSize+3] = 0x44 + + // 3rd packet has payload length 0 and sequence id 2 + // 00 00 00 02 + data[pkt3ofs+3] = 0x02 + + conn.data = data + conn.reads = 0 + conn.maxReads = 5 + mc.sequence = 0 + packet, err = mc.readPacket() + if err != nil { + t.Fatal(err) + } + if len(packet) != 2*maxPacketSize { + t.Fatalf("unexpected packet length: expected %d, got %d", 2*maxPacketSize, len(packet)) + } + if packet[0] != 0x11 { + t.Fatalf("unexpected payload start: expected %x, got %x", 0x11, packet[0]) + } + if packet[2*maxPacketSize-1] != 0x44 { + t.Fatalf("unexpected payload end: expected %x, got %x", 0x44, packet[2*maxPacketSize-1]) + } + + // case 3: payload has a length larger maxPacketSize, which is not an exact + // multiple of it + data = data[:pkt2ofs+4+42] + data[pkt2ofs] = 0x2a + data[pkt2ofs+1] = 0x00 + data[pkt2ofs+2] = 0x00 + data[pkt2ofs+4+41] = 0x44 + + conn.data = data + conn.reads = 0 + conn.maxReads = 4 + mc.sequence = 0 + packet, err = mc.readPacket() + if err != nil { + t.Fatal(err) + } + if len(packet) != maxPacketSize+42 { + t.Fatalf("unexpected packet length: expected %d, got %d", maxPacketSize+42, len(packet)) + } + if packet[0] != 0x11 { + t.Fatalf("unexpected payload start: expected %x, got %x", 0x11, packet[0]) + } + if packet[maxPacketSize+41] != 0x44 { + t.Fatalf("unexpected payload end: expected %x, got %x", 0x44, packet[maxPacketSize+41]) + } +} + +func TestReadPacketFail(t *testing.T) { + conn := new(mockConn) + mc := &mysqlConn{ + netConn: conn, + buf: newBuffer(), + closech: make(chan struct{}), + cfg: NewConfig(), + } + + // illegal empty (stand-alone) packet + conn.data = []byte{0x00, 0x00, 0x00, 0x00} + conn.maxReads = 1 + _, err := mc.readPacket() + if err != ErrInvalidConn { + t.Errorf("expected ErrInvalidConn, got %v", err) + } + + // reset + conn.reads = 0 + mc.sequence = 0 + mc.buf = newBuffer() + + // fail to read header + conn.closed = true + _, err = mc.readPacket() + if err != ErrInvalidConn { + t.Errorf("expected ErrInvalidConn, got %v", err) + } + + // reset + conn.closed = false + conn.reads = 0 + mc.sequence = 0 + mc.buf = newBuffer() + + // fail to read body + conn.maxReads = 1 + _, err = mc.readPacket() + if err != ErrInvalidConn { + t.Errorf("expected ErrInvalidConn, got %v", err) + } +} + +// https://github.com/go-sql-driver/mysql/pull/801 +// not-NUL terminated plugin_name in init packet +func TestRegression801(t *testing.T) { + conn := new(mockConn) + mc := &mysqlConn{ + netConn: conn, + buf: newBuffer(), + cfg: new(Config), + sequence: 42, + closech: make(chan struct{}), + } + + conn.data = []byte{72, 0, 0, 42, 10, 53, 46, 53, 46, 56, 0, 165, 0, 0, 0, + 60, 70, 63, 58, 68, 104, 34, 97, 0, 223, 247, 33, 2, 0, 15, 128, 21, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 98, 120, 114, 47, 85, 75, 109, 99, 51, 77, + 50, 64, 0, 109, 121, 115, 113, 108, 95, 110, 97, 116, 105, 118, 101, 95, + 112, 97, 115, 115, 119, 111, 114, 100} + conn.maxReads = 1 + + authData, serverCapabilities, serverExtendedCapabilities, pluginName, err := mc.readHandshakePacket() + if err != nil { + t.Fatalf("got error: %v", err) + } + + if serverCapabilities != 2148530143 { + t.Fatalf("expected serverCapabilities to be 2148530143, got %v", serverCapabilities) + } + + if serverExtendedCapabilities != 0 { + t.Fatalf("expected serverExtendedCapabilities to be 0, got %v", serverExtendedCapabilities) + } + + if pluginName != "mysql_native_password" { + t.Errorf("expected plugin name 'mysql_native_password', got '%s'", pluginName) + } + + expectedAuthData := []byte{60, 70, 63, 58, 68, 104, 34, 97, 98, 120, 114, + 47, 85, 75, 109, 99, 51, 77, 50, 64} + if !bytes.Equal(authData, expectedAuthData) { + t.Errorf("expected authData '%v', got '%v'", expectedAuthData, authData) + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/result.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/result.go new file mode 100644 index 0000000..82dc0f9 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/result.go @@ -0,0 +1,52 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import "slices" + +import "database/sql/driver" + +// Result exposes data not available through *connection.Result. +// +// This is accessible by executing statements using sql.Conn.Raw() and +// downcasting the returned result: +// +// res, err := rawConn.Exec(...) +// res.(mysql.Result).AllRowsAffected() +type Result interface { + driver.Result + // AllRowsAffected returns a slice containing the affected rows for each + // executed statement. + AllRowsAffected() []int64 + // AllLastInsertIds returns a slice containing the last inserted ID for each + // executed statement. + AllLastInsertIds() []int64 +} + +type mysqlResult struct { + // One entry in both slices is created for every executed statement result. + affectedRows []int64 + insertIds []int64 +} + +func (res *mysqlResult) LastInsertId() (int64, error) { + return res.insertIds[len(res.insertIds)-1], nil +} + +func (res *mysqlResult) RowsAffected() (int64, error) { + return res.affectedRows[len(res.affectedRows)-1], nil +} + +func (res *mysqlResult) AllLastInsertIds() []int64 { + return slices.Clone(res.insertIds) // defensive copy +} + +func (res *mysqlResult) AllRowsAffected() []int64 { + return slices.Clone(res.affectedRows) // defensive copy +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/rows.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/rows.go new file mode 100644 index 0000000..190e75f --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/rows.go @@ -0,0 +1,225 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "database/sql/driver" + "io" + "math" + "reflect" +) + +type resultSet struct { + columns []mysqlField + columnNames []string + done bool +} + +type mysqlRows struct { + mc *mysqlConn + rs resultSet + finish func() +} + +type binaryRows struct { + mysqlRows +} + +type textRows struct { + mysqlRows +} + +func (rows *mysqlRows) Columns() []string { + if rows.rs.columnNames != nil { + return rows.rs.columnNames + } + + columns := make([]string, len(rows.rs.columns)) + if rows.mc != nil && rows.mc.cfg.ColumnsWithAlias { + for i := range columns { + if tableName := rows.rs.columns[i].tableName; len(tableName) > 0 { + columns[i] = tableName + "." + rows.rs.columns[i].name + } else { + columns[i] = rows.rs.columns[i].name + } + } + } else { + for i := range columns { + columns[i] = rows.rs.columns[i].name + } + } + + rows.rs.columnNames = columns + return columns +} + +func (rows *mysqlRows) ColumnTypeDatabaseTypeName(i int) string { + return rows.rs.columns[i].typeDatabaseName() +} + +// func (rows *mysqlRows) ColumnTypeLength(i int) (length int64, ok bool) { +// return int64(rows.rs.columns[i].length), true +// } + +func (rows *mysqlRows) ColumnTypeNullable(i int) (nullable, ok bool) { + return rows.rs.columns[i].flags&flagNotNULL == 0, true +} + +func (rows *mysqlRows) ColumnTypePrecisionScale(i int) (int64, int64, bool) { + column := rows.rs.columns[i] + decimals := int64(column.decimals) + + switch column.fieldType { + case fieldTypeDecimal, fieldTypeNewDecimal: + if decimals > 0 { + return int64(column.length) - 2, decimals, true + } + return int64(column.length) - 1, decimals, true + case fieldTypeTimestamp, fieldTypeDateTime, fieldTypeTime: + return decimals, decimals, true + case fieldTypeFloat, fieldTypeDouble: + if decimals == 0x1f { + return math.MaxInt64, math.MaxInt64, true + } + return math.MaxInt64, decimals, true + } + + return 0, 0, false +} + +func (rows *mysqlRows) ColumnTypeScanType(i int) reflect.Type { + return rows.rs.columns[i].scanType() +} + +func (rows *mysqlRows) Close() (err error) { + if f := rows.finish; f != nil { + f() + rows.finish = nil + } + + mc := rows.mc + if mc == nil { + return nil + } + if err := mc.error(); err != nil { + return err + } + + // Remove unread packets from stream + if !rows.rs.done { + err = mc.skipRows() + } + if err == nil { + handleOk := mc.clearResult() + if err = handleOk.discardResults(); err != nil { + return err + } + } + + rows.mc = nil + return err +} + +func (rows *mysqlRows) HasNextResultSet() (b bool) { + if rows.mc == nil { + return false + } + return rows.mc.status&statusMoreResultsExists != 0 +} + +func (rows *mysqlRows) nextResultSet() (int, error) { + if rows.mc == nil { + return 0, io.EOF + } + if err := rows.mc.error(); err != nil { + return 0, err + } + + // Remove unread packets from stream + if !rows.rs.done { + if err := rows.mc.skipRows(); err != nil { + return 0, err + } + rows.rs.done = true + } + + if !rows.HasNextResultSet() { + rows.mc = nil + return 0, io.EOF + } + rows.rs = resultSet{} + // rows.mc.affectedRows and rows.mc.insertIds accumulate on each call to + // nextResultSet. + resLen, _, err := rows.mc.resultUnchanged().readResultSetHeaderPacket() + if err != nil { + // Clean up about multi-results flag + rows.rs.done = true + rows.mc.status = rows.mc.status & (^statusMoreResultsExists) + } + return resLen, err +} + +func (rows *mysqlRows) nextNotEmptyResultSet() (int, error) { + for { + resLen, err := rows.nextResultSet() + if err != nil { + return 0, err + } + + if resLen > 0 { + return resLen, nil + } + + rows.rs.done = true + } +} + +func (rows *binaryRows) NextResultSet() error { + resLen, err := rows.nextNotEmptyResultSet() + if err != nil { + return err + } + + rows.rs.columns, err = rows.mc.readColumns(resLen, nil) + return err +} + +func (rows *binaryRows) Next(dest []driver.Value) error { + if mc := rows.mc; mc != nil { + if err := mc.error(); err != nil { + return err + } + + // Fetch next row from stream + return rows.readRow(dest) + } + return io.EOF +} + +func (rows *textRows) NextResultSet() (err error) { + resLen, err := rows.nextNotEmptyResultSet() + if err != nil { + return err + } + + rows.rs.columns, err = rows.mc.readColumns(resLen, nil) + return err +} + +func (rows *textRows) Next(dest []driver.Value) error { + if mc := rows.mc; mc != nil { + if err := mc.error(); err != nil { + return err + } + + // Fetch next row from stream + return rows.readRow(dest) + } + return io.EOF +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/statement.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/statement.go new file mode 100644 index 0000000..0261903 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/statement.go @@ -0,0 +1,234 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "io" + "reflect" +) + +type mysqlStmt struct { + mc *mysqlConn + id uint32 + paramCount int + columns []mysqlField +} + +func (stmt *mysqlStmt) Close() error { + if stmt.mc == nil || stmt.mc.closed.Load() { + // driver.Stmt.Close could be called more than once, thus this function + // had to be idempotent. See also Issue #450 and golang/go#16019. + // This bug has been fixed in Go 1.8. + // https://github.com/golang/go/commit/90b8a0ca2d0b565c7c7199ffcf77b15ea6b6db3a + // But we keep this function idempotent because it is safer. + return nil + } + + err := stmt.mc.writeCommandPacketUint32(comStmtClose, stmt.id) + stmt.mc = nil + return err +} + +func (stmt *mysqlStmt) NumInput() int { + return stmt.paramCount +} + +func (stmt *mysqlStmt) ColumnConverter(idx int) driver.ValueConverter { + return converter{} +} + +func (stmt *mysqlStmt) CheckNamedValue(nv *driver.NamedValue) (err error) { + nv.Value, err = converter{}.ConvertValue(nv.Value) + return +} + +func (stmt *mysqlStmt) Exec(args []driver.Value) (driver.Result, error) { + if stmt.mc.closed.Load() { + return nil, driver.ErrBadConn + } + // Send command + err := stmt.writeExecutePacket(args) + if err != nil { + return nil, stmt.mc.markBadConn(err) + } + + mc := stmt.mc + handleOk := stmt.mc.clearResult() + + // Read Result + resLen, metadataFollows, err := handleOk.readResultSetHeaderPacket() + if err != nil { + return nil, err + } + + if resLen > 0 { + // Columns + if metadataFollows && stmt.mc.extCapabilities&clientCacheMetadata != 0 { + // we can not skip column metadata because next stmt.Query() may use it. + if stmt.columns, err = mc.readColumns(resLen, stmt.columns); err != nil { + return nil, err + } + } else { + if err = mc.skipColumns(resLen); err != nil { + return nil, err + } + } + + // Rows + if err = mc.skipRows(); err != nil { + return nil, err + } + } + + if err := handleOk.discardResults(); err != nil { + return nil, err + } + + copied := mc.result + return &copied, nil +} + +func (stmt *mysqlStmt) Query(args []driver.Value) (driver.Rows, error) { + return stmt.query(args) +} + +func (stmt *mysqlStmt) query(args []driver.Value) (*binaryRows, error) { + if stmt.mc.closed.Load() { + return nil, driver.ErrBadConn + } + // Send command + err := stmt.writeExecutePacket(args) + if err != nil { + return nil, stmt.mc.markBadConn(err) + } + + mc := stmt.mc + + // Read Result + handleOk := stmt.mc.clearResult() + resLen, metadataFollows, err := handleOk.readResultSetHeaderPacket() + if err != nil { + return nil, err + } + + rows := new(binaryRows) + + if resLen > 0 { + rows.mc = mc + if metadataFollows { + if rows.rs.columns, err = mc.readColumns(resLen, stmt.columns); err != nil { + return nil, err + } + stmt.columns = rows.rs.columns + } else { + if err = mc.skipEof(); err != nil { + return nil, err + } + rows.rs.columns = stmt.columns + } + } else { + rows.rs.done = true + + switch err := rows.NextResultSet(); err { + case nil, io.EOF: + return rows, nil + default: + return nil, err + } + } + + return rows, err +} + +var jsonType = reflect.TypeFor[json.RawMessage]() + +type converter struct{} + +// ConvertValue mirrors the reference/default converter in database/sql/driver +// with _one_ exception. We support uint64 with their high bit and the default +// implementation does not. This function should be kept in sync with +// database/sql/driver defaultConverter.ConvertValue() except for that +// deliberate difference. +func (c converter) ConvertValue(v any) (driver.Value, error) { + if driver.IsValue(v) { + return v, nil + } + + if vr, ok := v.(driver.Valuer); ok { + sv, err := callValuerValue(vr) + if err != nil { + return nil, err + } + if driver.IsValue(sv) { + return sv, nil + } + // A value returned from the Valuer interface can be "a type handled by + // a database driver's NamedValueChecker interface" so we should accept + // uint64 here as well. + if u, ok := sv.(uint64); ok { + return u, nil + } + return nil, fmt.Errorf("non-Value type %T returned from Value", sv) + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Ptr: + // indirect pointers + if rv.IsNil() { + return nil, nil + } else { + return c.ConvertValue(rv.Elem().Interface()) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return rv.Uint(), nil + case reflect.Float32, reflect.Float64: + return rv.Float(), nil + case reflect.Bool: + return rv.Bool(), nil + case reflect.Slice: + switch t := rv.Type(); { + case t == jsonType: + return v, nil + case t.Elem().Kind() == reflect.Uint8: + return rv.Bytes(), nil + default: + return nil, fmt.Errorf("unsupported type %T, a slice of %s", v, t.Elem().Kind()) + } + case reflect.String: + return rv.String(), nil + } + return nil, fmt.Errorf("unsupported type %T, a %s", v, rv.Kind()) +} + +var valuerReflectType = reflect.TypeFor[driver.Valuer]() + +// callValuerValue returns vr.Value(), with one exception: +// If vr.Value is an auto-generated method on a pointer type and the +// pointer is nil, it would panic at runtime in the panicwrap +// method. Treat it like nil instead. +// +// This is so people can implement driver.Value on value types and +// still use nil pointers to those types to mean nil/NULL, just like +// string/*string. +// +// This is an exact copy of the same-named unexported function from the +// database/sql package. +func callValuerValue(vr driver.Valuer) (v driver.Value, err error) { + if rv := reflect.ValueOf(vr); rv.Kind() == reflect.Ptr && + rv.IsNil() && + rv.Type().Elem().Implements(valuerReflectType) { + return nil, nil + } + return vr.Value() +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/statement_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/statement_test.go new file mode 100644 index 0000000..15f9d7c --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/statement_test.go @@ -0,0 +1,151 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2017 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "testing" +) + +func TestConvertDerivedString(t *testing.T) { + type derived string + + output, err := converter{}.ConvertValue(derived("value")) + if err != nil { + t.Fatal("Derived string type not convertible", err) + } + + if output != "value" { + t.Fatalf("Derived string type not converted, got %#v %T", output, output) + } +} + +func TestConvertDerivedByteSlice(t *testing.T) { + type derived []uint8 + + output, err := converter{}.ConvertValue(derived("value")) + if err != nil { + t.Fatal("Byte slice not convertible", err) + } + + if !bytes.Equal(output.([]byte), []byte("value")) { + t.Fatalf("Byte slice not converted, got %#v %T", output, output) + } +} + +func TestConvertDerivedUnsupportedSlice(t *testing.T) { + type derived []int + + _, err := converter{}.ConvertValue(derived{1}) + if err == nil || err.Error() != "unsupported type mysql.derived, a slice of int" { + t.Fatal("Unexpected error", err) + } +} + +func TestConvertDerivedBool(t *testing.T) { + type derived bool + + output, err := converter{}.ConvertValue(derived(true)) + if err != nil { + t.Fatal("Derived bool type not convertible", err) + } + + if output != true { + t.Fatalf("Derived bool type not converted, got %#v %T", output, output) + } +} + +func TestConvertPointer(t *testing.T) { + str := "value" + + output, err := converter{}.ConvertValue(&str) + if err != nil { + t.Fatal("Pointer type not convertible", err) + } + + if output != "value" { + t.Fatalf("Pointer type not converted, got %#v %T", output, output) + } +} + +func TestConvertSignedIntegers(t *testing.T) { + values := []any{ + int8(-42), + int16(-42), + int32(-42), + int64(-42), + int(-42), + } + + for _, value := range values { + output, err := converter{}.ConvertValue(value) + if err != nil { + t.Fatalf("%T type not convertible %s", value, err) + } + + if output != int64(-42) { + t.Fatalf("%T type not converted, got %#v %T", value, output, output) + } + } +} + +type myUint64 struct { + value uint64 +} + +func (u myUint64) Value() (driver.Value, error) { + return u.value, nil +} + +func TestConvertUnsignedIntegers(t *testing.T) { + values := []any{ + uint8(42), + uint16(42), + uint32(42), + uint64(42), + uint(42), + myUint64{uint64(42)}, + } + + for _, value := range values { + output, err := converter{}.ConvertValue(value) + if err != nil { + t.Fatalf("%T type not convertible %s", value, err) + } + + if output != uint64(42) { + t.Fatalf("%T type not converted, got %#v %T", value, output, output) + } + } + + output, err := converter{}.ConvertValue(^uint64(0)) + if err != nil { + t.Fatal("uint64 high-bit not convertible", err) + } + + if output != ^uint64(0) { + t.Fatalf("uint64 high-bit converted, got %#v %T", output, output) + } +} + +func TestConvertJSON(t *testing.T) { + raw := json.RawMessage("{}") + + out, err := converter{}.ConvertValue(raw) + + if err != nil { + t.Fatal("json.RawMessage was failed in convert", err) + } + + if _, ok := out.(json.RawMessage); !ok { + t.Fatalf("json.RawMessage converted, got %#v %T", out, out) + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/transaction.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/transaction.go new file mode 100644 index 0000000..8c502f4 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/transaction.go @@ -0,0 +1,45 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +type mysqlTx struct { + mc *mysqlConn +} + +func (tx *mysqlTx) Commit() (err error) { + if tx.mc == nil { + return ErrInvalidConn + } + if tx.mc.closed.Load() { + err = tx.mc.error() + if err == nil { + err = ErrInvalidConn + } + return + } + err = tx.mc.exec("COMMIT") + tx.mc = nil + return +} + +func (tx *mysqlTx) Rollback() (err error) { + if tx.mc == nil { + return ErrInvalidConn + } + if tx.mc.closed.Load() { + err = tx.mc.error() + if err == nil { + err = ErrInvalidConn + } + return + } + err = tx.mc.exec("ROLLBACK") + tx.mc = nil + return +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/utils.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/utils.go new file mode 100644 index 0000000..2dccb7d --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/utils.go @@ -0,0 +1,805 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "crypto/tls" + "database/sql" + "database/sql/driver" + "encoding/binary" + "errors" + "fmt" + "io" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Registry for custom tls.Configs +var ( + tlsConfigLock sync.RWMutex + tlsConfigRegistry map[string]*tls.Config +) + +// RegisterTLSConfig registers a custom tls.Config to be used with sql.Open. +// Use the key as a value in the DSN where tls=value. +// +// Note: The provided tls.Config is exclusively owned by the driver after +// registering it. +// +// rootCertPool := x509.NewCertPool() +// pem, err := os.ReadFile("/path/ca-cert.pem") +// if err != nil { +// log.Fatal(err) +// } +// if ok := rootCertPool.AppendCertsFromPEM(pem); !ok { +// log.Fatal("Failed to append PEM.") +// } +// clientCert := make([]tls.Certificate, 0, 1) +// certs, err := tls.LoadX509KeyPair("/path/client-cert.pem", "/path/client-key.pem") +// if err != nil { +// log.Fatal(err) +// } +// clientCert = append(clientCert, certs) +// mysql.RegisterTLSConfig("custom", &tls.Config{ +// RootCAs: rootCertPool, +// Certificates: clientCert, +// }) +// db, err := sql.Open("mysql", "user@tcp(localhost:3306)/test?tls=custom") +func RegisterTLSConfig(key string, config *tls.Config) error { + if _, isBool := readBool(key); isBool || strings.ToLower(key) == "skip-verify" || strings.ToLower(key) == "preferred" { + return fmt.Errorf("key '%s' is reserved", key) + } + + tlsConfigLock.Lock() + if tlsConfigRegistry == nil { + tlsConfigRegistry = make(map[string]*tls.Config) + } + + tlsConfigRegistry[key] = config + tlsConfigLock.Unlock() + return nil +} + +// DeregisterTLSConfig removes the tls.Config associated with key. +func DeregisterTLSConfig(key string) { + tlsConfigLock.Lock() + if tlsConfigRegistry != nil { + delete(tlsConfigRegistry, key) + } + tlsConfigLock.Unlock() +} + +func getTLSConfigClone(key string) (config *tls.Config) { + tlsConfigLock.RLock() + if v, ok := tlsConfigRegistry[key]; ok { + config = v.Clone() + } + tlsConfigLock.RUnlock() + return +} + +// Returns the bool value of the input. +// The 2nd return value indicates if the input was a valid bool value +func readBool(input string) (value bool, valid bool) { + switch input { + case "1", "true", "TRUE", "True": + return true, true + case "0", "false", "FALSE", "False": + return false, true + } + + // Not a valid bool value + return +} + +/****************************************************************************** +* Time related utils * +******************************************************************************/ + +func parseDateTime(b []byte, loc *time.Location) (time.Time, error) { + const base = "0000-00-00 00:00:00.000000" + switch len(b) { + case 10, 19, 21, 22, 23, 24, 25, 26: // up to "YYYY-MM-DD HH:MM:SS.MMMMMM" + if string(b) == base[:len(b)] { + return time.Time{}, nil + } + + year, err := parseByteYear(b) + if err != nil { + return time.Time{}, err + } + if b[4] != '-' { + return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[4]) + } + + m, err := parseByte2Digits(b[5], b[6]) + if err != nil { + return time.Time{}, err + } + month := time.Month(m) + + if b[7] != '-' { + return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[7]) + } + + day, err := parseByte2Digits(b[8], b[9]) + if err != nil { + return time.Time{}, err + } + if len(b) == 10 { + return time.Date(year, month, day, 0, 0, 0, 0, loc), nil + } + + if b[10] != ' ' { + return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[10]) + } + + hour, err := parseByte2Digits(b[11], b[12]) + if err != nil { + return time.Time{}, err + } + if b[13] != ':' { + return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[13]) + } + + min, err := parseByte2Digits(b[14], b[15]) + if err != nil { + return time.Time{}, err + } + if b[16] != ':' { + return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[16]) + } + + sec, err := parseByte2Digits(b[17], b[18]) + if err != nil { + return time.Time{}, err + } + if len(b) == 19 { + return time.Date(year, month, day, hour, min, sec, 0, loc), nil + } + + if b[19] != '.' { + return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[19]) + } + nsec, err := parseByteNanoSec(b[20:]) + if err != nil { + return time.Time{}, err + } + return time.Date(year, month, day, hour, min, sec, nsec, loc), nil + default: + return time.Time{}, fmt.Errorf("invalid time bytes: %s", b) + } +} + +func parseByteYear(b []byte) (int, error) { + year, n := 0, 1000 + for i := range 4 { + v, err := bToi(b[i]) + if err != nil { + return 0, err + } + year += v * n + n /= 10 + } + return year, nil +} + +func parseByte2Digits(b1, b2 byte) (int, error) { + d1, err := bToi(b1) + if err != nil { + return 0, err + } + d2, err := bToi(b2) + if err != nil { + return 0, err + } + return d1*10 + d2, nil +} + +func parseByteNanoSec(b []byte) (int, error) { + ns, digit := 0, 100000 // max is 6-digits + for i := range b { + v, err := bToi(b[i]) + if err != nil { + return 0, err + } + ns += v * digit + digit /= 10 + } + // nanoseconds has 10-digits. (needs to scale digits) + // 10 - 6 = 4, so we have to multiple 1000. + return ns * 1000, nil +} + +func bToi(b byte) (int, error) { + if b < '0' || b > '9' { + return 0, errors.New("not [0-9]") + } + return int(b - '0'), nil +} + +func parseBinaryDateTime(num uint64, data []byte, loc *time.Location) (driver.Value, error) { + switch num { + case 0: + return time.Time{}, nil + case 4: + return time.Date( + int(binary.LittleEndian.Uint16(data[:2])), // year + time.Month(data[2]), // month + int(data[3]), // day + 0, 0, 0, 0, + loc, + ), nil + case 7: + return time.Date( + int(binary.LittleEndian.Uint16(data[:2])), // year + time.Month(data[2]), // month + int(data[3]), // day + int(data[4]), // hour + int(data[5]), // minutes + int(data[6]), // seconds + 0, + loc, + ), nil + case 11: + return time.Date( + int(binary.LittleEndian.Uint16(data[:2])), // year + time.Month(data[2]), // month + int(data[3]), // day + int(data[4]), // hour + int(data[5]), // minutes + int(data[6]), // seconds + int(binary.LittleEndian.Uint32(data[7:11]))*1000, // nanoseconds + loc, + ), nil + } + return nil, fmt.Errorf("invalid DATETIME packet length %d", num) +} + +func appendDateTime(buf []byte, t time.Time, timeTruncate time.Duration) ([]byte, error) { + if timeTruncate > 0 { + t = t.Truncate(timeTruncate) + } + + year, month, day := t.Date() + hour, min, sec := t.Clock() + nsec := t.Nanosecond() + + if year < 1 || year > 9999 { + return buf, errors.New("year is not in the range [1, 9999]: " + strconv.Itoa(year)) // use errors.New instead of fmt.Errorf to avoid year escape to heap + } + year100 := year / 100 + year1 := year % 100 + + var localBuf [len("2006-01-02T15:04:05.999999999")]byte // does not escape + localBuf[0], localBuf[1], localBuf[2], localBuf[3] = digits10[year100], digits01[year100], digits10[year1], digits01[year1] + localBuf[4] = '-' + localBuf[5], localBuf[6] = digits10[month], digits01[month] + localBuf[7] = '-' + localBuf[8], localBuf[9] = digits10[day], digits01[day] + + if hour == 0 && min == 0 && sec == 0 && nsec == 0 { + return append(buf, localBuf[:10]...), nil + } + + localBuf[10] = ' ' + localBuf[11], localBuf[12] = digits10[hour], digits01[hour] + localBuf[13] = ':' + localBuf[14], localBuf[15] = digits10[min], digits01[min] + localBuf[16] = ':' + localBuf[17], localBuf[18] = digits10[sec], digits01[sec] + + if nsec == 0 { + return append(buf, localBuf[:19]...), nil + } + nsec100000000 := nsec / 100000000 + nsec1000000 := (nsec / 1000000) % 100 + nsec10000 := (nsec / 10000) % 100 + nsec100 := (nsec / 100) % 100 + nsec1 := nsec % 100 + localBuf[19] = '.' + + // milli second + localBuf[20], localBuf[21], localBuf[22] = + digits01[nsec100000000], digits10[nsec1000000], digits01[nsec1000000] + // micro second + localBuf[23], localBuf[24], localBuf[25] = + digits10[nsec10000], digits01[nsec10000], digits10[nsec100] + // nano second + localBuf[26], localBuf[27], localBuf[28] = + digits01[nsec100], digits10[nsec1], digits01[nsec1] + + // trim trailing zeros + n := len(localBuf) + for n > 0 && localBuf[n-1] == '0' { + n-- + } + + return append(buf, localBuf[:n]...), nil +} + +// zeroDateTime is used in formatBinaryDateTime to avoid an allocation +// if the DATE or DATETIME has the zero value. +// It must never be changed. +// The current behavior depends on database/sql copying the result. +var zeroDateTime = []byte("0000-00-00 00:00:00.000000") + +const digits01 = "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789" +const digits10 = "0000000000111111111122222222223333333333444444444455555555556666666666777777777788888888889999999999" + +func appendMicrosecs(dst, src []byte, decimals int) []byte { + if decimals <= 0 { + return dst + } + if len(src) == 0 { + return append(dst, ".000000"[:decimals+1]...) + } + + microsecs := binary.LittleEndian.Uint32(src[:4]) + p1 := byte(microsecs / 10000) + microsecs -= 10000 * uint32(p1) + p2 := byte(microsecs / 100) + microsecs -= 100 * uint32(p2) + p3 := byte(microsecs) + + switch decimals { + default: + return append(dst, '.', + digits10[p1], digits01[p1], + digits10[p2], digits01[p2], + digits10[p3], digits01[p3], + ) + case 1: + return append(dst, '.', + digits10[p1], + ) + case 2: + return append(dst, '.', + digits10[p1], digits01[p1], + ) + case 3: + return append(dst, '.', + digits10[p1], digits01[p1], + digits10[p2], + ) + case 4: + return append(dst, '.', + digits10[p1], digits01[p1], + digits10[p2], digits01[p2], + ) + case 5: + return append(dst, '.', + digits10[p1], digits01[p1], + digits10[p2], digits01[p2], + digits10[p3], + ) + } +} + +func formatBinaryDateTime(src []byte, length uint8) (driver.Value, error) { + // length expects the deterministic length of the zero value, + // negative time and 100+ hours are automatically added if needed + if len(src) == 0 { + return zeroDateTime[:length], nil + } + var dst []byte // return value + var p1, p2, p3 byte // current digit pair + + switch length { + case 10, 19, 21, 22, 23, 24, 25, 26: + default: + t := "DATE" + if length > 10 { + t += "TIME" + } + return nil, fmt.Errorf("illegal %s length %d", t, length) + } + switch len(src) { + case 4, 7, 11: + default: + t := "DATE" + if length > 10 { + t += "TIME" + } + return nil, fmt.Errorf("illegal %s packet length %d", t, len(src)) + } + dst = make([]byte, 0, length) + // start with the date + year := binary.LittleEndian.Uint16(src[:2]) + pt := year / 100 + p1 = byte(year - 100*uint16(pt)) + p2, p3 = src[2], src[3] + dst = append(dst, + digits10[pt], digits01[pt], + digits10[p1], digits01[p1], '-', + digits10[p2], digits01[p2], '-', + digits10[p3], digits01[p3], + ) + if length == 10 { + return dst, nil + } + if len(src) == 4 { + return append(dst, zeroDateTime[10:length]...), nil + } + dst = append(dst, ' ') + p1 = src[4] // hour + src = src[5:] + + // p1 is 2-digit hour, src is after hour + p2, p3 = src[0], src[1] + dst = append(dst, + digits10[p1], digits01[p1], ':', + digits10[p2], digits01[p2], ':', + digits10[p3], digits01[p3], + ) + return appendMicrosecs(dst, src[2:], int(length)-20), nil +} + +func formatBinaryTime(src []byte, length uint8) (driver.Value, error) { + // length expects the deterministic length of the zero value, + // negative time and 100+ hours are automatically added if needed + if len(src) == 0 { + return zeroDateTime[11 : 11+length], nil + } + var dst []byte // return value + + switch length { + case + 8, // time (can be up to 10 when negative and 100+ hours) + 10, 11, 12, 13, 14, 15: // time with fractional seconds + default: + return nil, fmt.Errorf("illegal TIME length %d", length) + } + switch len(src) { + case 8, 12: + default: + return nil, fmt.Errorf("invalid TIME packet length %d", len(src)) + } + // +2 to enable negative time and 100+ hours + dst = make([]byte, 0, length+2) + if src[0] == 1 { + dst = append(dst, '-') + } + days := binary.LittleEndian.Uint32(src[1:5]) + hours := int64(days)*24 + int64(src[5]) + + if hours >= 100 { + dst = strconv.AppendInt(dst, hours, 10) + } else { + dst = append(dst, digits10[hours], digits01[hours]) + } + + min, sec := src[6], src[7] + dst = append(dst, ':', + digits10[min], digits01[min], ':', + digits10[sec], digits01[sec], + ) + return appendMicrosecs(dst, src[8:], int(length)-9), nil +} + +/****************************************************************************** +* Convert from and to bytes * +******************************************************************************/ + +// 24bit integer: used for packet headers. + +func putUint24(data []byte, n int) { + data[2] = byte(n >> 16) + data[1] = byte(n >> 8) + data[0] = byte(n) +} + +func getUint24(data []byte) int { + return int(data[2])<<16 | int(data[1])<<8 | int(data[0]) +} + +func uint64ToString(n uint64) []byte { + var a [20]byte + i := 20 + + // U+0030 = 0 + // ... + // U+0039 = 9 + + var q uint64 + for n >= 10 { + i-- + q = n / 10 + a[i] = uint8(n-q*10) + 0x30 + n = q + } + + i-- + a[i] = uint8(n) + 0x30 + + return a[i:] +} + +// returns the string read as a bytes slice, whether the value is NULL, +// the number of bytes read and an error, in case the string is longer than +// the input slice +func readLengthEncodedString(b []byte) ([]byte, bool, int, error) { + // Get length + num, isNull, n := readLengthEncodedInteger(b) + if num < 1 { + return b[n:n], isNull, n, nil + } + + n += int(num) + + // Check data length + if len(b) >= n { + return b[n-int(num) : n : n], false, n, nil + } + return nil, false, n, io.EOF +} + +// returns the number of bytes skipped and an error, in case the string is +// longer than the input slice +func skipLengthEncodedString(b []byte) (int, error) { + // Get length + num, _, n := readLengthEncodedInteger(b) + if num < 1 { + return n, nil + } + + n += int(num) + + // Check data length + if len(b) >= n { + return n, nil + } + return n, io.EOF +} + +// returns the number read, whether the value is NULL and the number of bytes read +func readLengthEncodedInteger(b []byte) (uint64, bool, int) { + // See issue #349 + if len(b) == 0 { + return 0, true, 1 + } + + switch b[0] { + // 251: NULL + case 0xfb: + return 0, true, 1 + + // 252: value of following 2 + case 0xfc: + return uint64(binary.LittleEndian.Uint16(b[1:])), false, 3 + + // 253: value of following 3 + case 0xfd: + return uint64(getUint24(b[1:])), false, 4 + + // 254: value of following 8 + case 0xfe: + return uint64(binary.LittleEndian.Uint64(b[1:])), false, 9 + } + + // 0-250: value of first byte + return uint64(b[0]), false, 1 +} + +// encodes a uint64 value and appends it to the given bytes slice +func appendLengthEncodedInteger(b []byte, n uint64) []byte { + switch { + case n <= 250: + return append(b, byte(n)) + + case n <= 0xffff: + b = append(b, 0xfc) + return binary.LittleEndian.AppendUint16(b, uint16(n)) + + case n <= 0xffffff: + return append(b, 0xfd, byte(n), byte(n>>8), byte(n>>16)) + } + b = append(b, 0xfe) + return binary.LittleEndian.AppendUint64(b, n) +} + +func appendLengthEncodedString(b []byte, s string) []byte { + b = appendLengthEncodedInteger(b, uint64(len(s))) + return append(b, s...) +} + +// reserveBuffer checks cap(buf) and expand buffer to len(buf) + appendSize. +// If cap(buf) is not enough, reallocate new buffer. +func reserveBuffer(buf []byte, appendSize int) []byte { + newSize := len(buf) + appendSize + if cap(buf) < newSize { + // Grow buffer exponentially + newBuf := make([]byte, len(buf)*2+appendSize) + copy(newBuf, buf) + buf = newBuf + } + return buf[:newSize] +} + +// Lookup table for backslash escapes (used for both string and bytes) +var backslashEscapeTable [256]byte + +func init() { + backslashEscapeTable['\x00'] = '0' + backslashEscapeTable['\n'] = 'n' + backslashEscapeTable['\r'] = 'r' + backslashEscapeTable['\x1a'] = 'Z' + backslashEscapeTable['\''] = '\'' + backslashEscapeTable['"'] = '"' + backslashEscapeTable['\\'] = '\\' +} + +// escapeStringBackslash is similar to escapeBytesBackslash but for string. +func escapeStringBackslash(buf []byte, v string) []byte { + pos := len(buf) + buf = reserveBuffer(buf, len(v)*2+2) + buf[pos] = '\'' + pos++ + for i := 0; i < len(v); i++ { + c := v[i] + if esc := backslashEscapeTable[c]; esc != 0 { + buf[pos+1] = esc + buf[pos] = '\\' + pos += 2 + } else { + buf[pos] = c + pos++ + } + } + buf[pos] = '\'' + pos++ + return buf[:pos] +} + +// escapeBytesBackslash appends _binary'...' or '...' with backslash escaping for bytes. +func escapeBytesBackslash(buf, v []byte, binary bool) []byte { + pos := len(buf) + if binary { + buf = reserveBuffer(buf, len(v)*2+9) + copy(buf[pos:], []byte("_binary'")) + pos += 8 + } else { + buf = reserveBuffer(buf, len(v)*2+2) + buf[pos] = '\'' + pos++ + } + for _, c := range v { + if esc := backslashEscapeTable[c]; esc != 0 { + buf[pos+1] = esc + buf[pos] = '\\' + pos += 2 + } else { + buf[pos] = c + pos++ + } + } + buf[pos] = '\'' + pos++ + return buf[:pos] +} + +// escapeBytesQuotes appends _binary'...' or '...' with single-quote escaping for bytes. +func escapeBytesQuotes(buf, v []byte, binary bool) []byte { + pos := len(buf) + if binary { + buf = reserveBuffer(buf, len(v)*2+9) + copy(buf[pos:], []byte("_binary'")) + pos += 8 + } else { + buf = reserveBuffer(buf, len(v)*2+2) + buf[pos] = '\'' + pos++ + } + for _, c := range v { + if c == '\'' { + buf[pos+1] = '\'' + buf[pos] = '\'' + pos += 2 + } else { + buf[pos] = c + pos++ + } + } + buf[pos] = '\'' + pos++ + return buf[:pos] +} + +// escapeStringQuotes is similar to escapeBytesQuotes but for string. +func escapeStringQuotes(buf []byte, v string) []byte { + pos := len(buf) + buf = reserveBuffer(buf, len(v)*2+2) + buf[pos] = '\'' + pos++ + for i := range len(v) { + c := v[i] + if c == '\'' { + buf[pos+1] = '\'' + buf[pos] = '\'' + pos += 2 + } else { + buf[pos] = c + pos++ + } + } + buf[pos] = '\'' + pos++ + return buf[:pos] +} + +/****************************************************************************** +* Sync utils * +******************************************************************************/ + +// noCopy may be embedded into structs which must not be copied +// after the first use. +// +// See https://github.com/golang/go/issues/8005#issuecomment-190753527 +// for details. +type noCopy struct{} + +// Lock is a no-op used by -copylocks checker from `go vet`. +func (*noCopy) Lock() {} + +// Unlock is a no-op used by -copylocks checker from `go vet`. +// noCopy should implement sync.Locker from Go 1.11 +// https://github.com/golang/go/commit/c2eba53e7f80df21d51285879d51ab81bcfbf6bc +// https://github.com/golang/go/issues/26165 +func (*noCopy) Unlock() {} + +// atomicError is a wrapper for atomically accessed error values +type atomicError struct { + _ noCopy + value atomic.Value +} + +// Set sets the error value regardless of the previous value. +// The value must not be nil +func (ae *atomicError) Set(value error) { + ae.value.Store(value) +} + +// Value returns the current error value +func (ae *atomicError) Value() error { + if v := ae.value.Load(); v != nil { + // this will panic if the value doesn't implement the error interface + return v.(error) + } + return nil +} + +func namedValueToValue(named []driver.NamedValue) ([]driver.Value, error) { + dargs := make([]driver.Value, len(named)) + for n, param := range named { + if len(param.Name) > 0 { + // TODO: support the use of Named Parameters #561 + return nil, errors.New("mysql: driver does not support the use of Named Parameters") + } + dargs[n] = param.Value + } + return dargs, nil +} + +func mapIsolationLevel(level driver.IsolationLevel) (string, error) { + switch sql.IsolationLevel(level) { + case sql.LevelRepeatableRead: + return "REPEATABLE READ", nil + case sql.LevelReadCommitted: + return "READ COMMITTED", nil + case sql.LevelReadUncommitted: + return "READ UNCOMMITTED", nil + case sql.LevelSerializable: + return "SERIALIZABLE", nil + default: + return "", fmt.Errorf("mysql: unsupported isolation level: %v", level) + } +} diff --git a/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/utils_test.go b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/utils_test.go new file mode 100644 index 0000000..4c171f6 --- /dev/null +++ b/platform/pkg/mod/github.com/go-sql-driver/mysql@v1.10.0/utils_test.go @@ -0,0 +1,555 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2013 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "bytes" + "database/sql" + "database/sql/driver" + "encoding/binary" + "testing" + "time" +) + +func TestLengthEncodedInteger(t *testing.T) { + var integerTests = []struct { + num uint64 + encoded []byte + }{ + {0x0000000000000000, []byte{0x00}}, + {0x0000000000000012, []byte{0x12}}, + {0x00000000000000fa, []byte{0xfa}}, + {0x0000000000000100, []byte{0xfc, 0x00, 0x01}}, + {0x0000000000001234, []byte{0xfc, 0x34, 0x12}}, + {0x000000000000ffff, []byte{0xfc, 0xff, 0xff}}, + {0x0000000000010000, []byte{0xfd, 0x00, 0x00, 0x01}}, + {0x0000000000123456, []byte{0xfd, 0x56, 0x34, 0x12}}, + {0x0000000000ffffff, []byte{0xfd, 0xff, 0xff, 0xff}}, + {0x0000000001000000, []byte{0xfe, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}}, + {0x123456789abcdef0, []byte{0xfe, 0xf0, 0xde, 0xbc, 0x9a, 0x78, 0x56, 0x34, 0x12}}, + {0xffffffffffffffff, []byte{0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}}, + } + + for _, tst := range integerTests { + num, isNull, numLen := readLengthEncodedInteger(tst.encoded) + if isNull { + t.Errorf("%x: expected %d, got NULL", tst.encoded, tst.num) + } + if num != tst.num { + t.Errorf("%x: expected %d, got %d", tst.encoded, tst.num, num) + } + if numLen != len(tst.encoded) { + t.Errorf("%x: expected size %d, got %d", tst.encoded, len(tst.encoded), numLen) + } + encoded := appendLengthEncodedInteger(nil, num) + if !bytes.Equal(encoded, tst.encoded) { + t.Errorf("%v: expected %x, got %x", num, tst.encoded, encoded) + } + } +} + +func TestFormatBinaryDateTime(t *testing.T) { + rawDate := [11]byte{} + binary.LittleEndian.PutUint16(rawDate[:2], 1978) // years + rawDate[2] = 12 // months + rawDate[3] = 30 // days + rawDate[4] = 15 // hours + rawDate[5] = 46 // minutes + rawDate[6] = 23 // seconds + binary.LittleEndian.PutUint32(rawDate[7:], 987654) // microseconds + expect := func(expected string, inlen, outlen uint8) { + actual, _ := formatBinaryDateTime(rawDate[:inlen], outlen) + bytes, ok := actual.([]byte) + if !ok { + t.Errorf("formatBinaryDateTime must return []byte, was %T", actual) + } + if string(bytes) != expected { + t.Errorf( + "expected %q, got %q for length in %d, out %d", + expected, actual, inlen, outlen, + ) + } + } + expect("0000-00-00", 0, 10) + expect("0000-00-00 00:00:00", 0, 19) + expect("1978-12-30", 4, 10) + expect("1978-12-30 15:46:23", 7, 19) + expect("1978-12-30 15:46:23.987654", 11, 26) +} + +func TestFormatBinaryTime(t *testing.T) { + expect := func(expected string, src []byte, outlen uint8) { + actual, _ := formatBinaryTime(src, outlen) + bytes, ok := actual.([]byte) + if !ok { + t.Errorf("formatBinaryDateTime must return []byte, was %T", actual) + } + if string(bytes) != expected { + t.Errorf( + "expected %q, got %q for src=%q and outlen=%d", + expected, actual, src, outlen) + } + } + + // binary format: + // sign (0: positive, 1: negative), days(4), hours, minutes, seconds, micro(4) + + // Zeros + expect("00:00:00", []byte{}, 8) + expect("00:00:00.0", []byte{}, 10) + expect("00:00:00.000000", []byte{}, 15) + + // Without micro(4) + expect("12:34:56", []byte{0, 0, 0, 0, 0, 12, 34, 56}, 8) + expect("-12:34:56", []byte{1, 0, 0, 0, 0, 12, 34, 56}, 8) + expect("12:34:56.00", []byte{0, 0, 0, 0, 0, 12, 34, 56}, 11) + expect("24:34:56", []byte{0, 1, 0, 0, 0, 0, 34, 56}, 8) + expect("-99:34:56", []byte{1, 4, 0, 0, 0, 3, 34, 56}, 8) + expect("103079215103:34:56", []byte{0, 255, 255, 255, 255, 23, 34, 56}, 8) + + // With micro(4) + expect("12:34:56.00", []byte{0, 0, 0, 0, 0, 12, 34, 56, 99, 0, 0, 0}, 11) + expect("12:34:56.000099", []byte{0, 0, 0, 0, 0, 12, 34, 56, 99, 0, 0, 0}, 15) +} + +func TestEscapeBackslash(t *testing.T) { + expect := func(expected, value string) { + actual := string(escapeBytesBackslash([]byte{}, []byte(value), false)) + if actual != expected { + t.Errorf( + "expected %s, got %s", + expected, actual, + ) + } + + actual = string(escapeStringBackslash([]byte{}, value)) + if actual != expected { + t.Errorf( + "expected %s, got %s", + expected, actual, + ) + } + } + + expect("'foo\\0bar'", "foo\x00bar") + expect("'foo\\nbar'", "foo\nbar") + expect("'foo\\rbar'", "foo\rbar") + expect("'foo\\Zbar'", "foo\x1abar") + expect("'foo\\\"bar'", "foo\"bar") + expect("'foo\\\\bar'", "foo\\bar") + expect("'foo\\'bar'", "foo'bar") + + // Test binary flag for escapeBytesBackslash + binExpect := func(expected, value string) { + actual := string(escapeBytesBackslash([]byte{}, []byte(value), true)) + if actual != expected { + t.Errorf( + "expected %s, got %s (binary)", + expected, actual, + ) + } + } + binExpect("_binary'foo\\0bar'", "foo\x00bar") + binExpect("_binary'foo\\nbar'", "foo\nbar") + binExpect("_binary'foo\\rbar'", "foo\rbar") + binExpect("_binary'foo\\Zbar'", "foo\x1abar") + binExpect("_binary'foo\\\"bar'", "foo\"bar") + binExpect("_binary'foo\\\\bar'", "foo\\bar") + binExpect("_binary'foo\\'bar'", "foo'bar") +} + +func TestEscapeQuotes(t *testing.T) { + expect := func(expected, value string) { + actual := string(escapeBytesQuotes([]byte{}, []byte(value), false)) + if actual != expected { + t.Errorf( + "expected %s, got %s", + expected, actual, + ) + } + + actual = string(escapeStringQuotes([]byte{}, value)) + if actual != expected { + t.Errorf( + "expected %s, got %s", + expected, actual, + ) + } + } + + expect("'foo\x00bar'", "foo\x00bar") // not affected + expect("'foo\nbar'", "foo\nbar") // not affected + expect("'foo\rbar'", "foo\rbar") // not affected + expect("'foo\x1abar'", "foo\x1abar") // not affected + expect("'foo''bar'", "foo'bar") // affected + expect("'foo\"bar'", "foo\"bar") // not affected + + // Test binary flag for escapeBytesQuotes + binExpect := func(expected, value string) { + actual := string(escapeBytesQuotes([]byte{}, []byte(value), true)) + if actual != expected { + t.Errorf( + "expected %s, got %s (binary)", + expected, actual, + ) + } + } + binExpect("_binary'foo\x00bar'", "foo\x00bar") + binExpect("_binary'foo\nbar'", "foo\nbar") + binExpect("_binary'foo\rbar'", "foo\rbar") + binExpect("_binary'foo\x1abar'", "foo\x1abar") + binExpect("_binary'foo''bar'", "foo'bar") + binExpect("_binary'foo\"bar'", "foo\"bar") +} + +func TestAtomicError(t *testing.T) { + var ae atomicError + if ae.Value() != nil { + t.Fatal("Expected value to be nil") + } + + ae.Set(ErrMalformPkt) + if v := ae.Value(); v != ErrMalformPkt { + if v == nil { + t.Fatal("Value is still nil") + } + t.Fatal("Error did not match") + } + ae.Set(ErrPktSync) + if ae.Value() == ErrMalformPkt { + t.Fatal("Error still matches old error") + } + if v := ae.Value(); v != ErrPktSync { + t.Fatal("Error did not match") + } +} + +func TestIsolationLevelMapping(t *testing.T) { + data := []struct { + level driver.IsolationLevel + expected string + }{ + { + level: driver.IsolationLevel(sql.LevelReadCommitted), + expected: "READ COMMITTED", + }, + { + level: driver.IsolationLevel(sql.LevelRepeatableRead), + expected: "REPEATABLE READ", + }, + { + level: driver.IsolationLevel(sql.LevelReadUncommitted), + expected: "READ UNCOMMITTED", + }, + { + level: driver.IsolationLevel(sql.LevelSerializable), + expected: "SERIALIZABLE", + }, + } + + for i, td := range data { + if actual, err := mapIsolationLevel(td.level); actual != td.expected || err != nil { + t.Fatal(i, td.expected, actual, err) + } + } + + // check unsupported mapping + expectedErr := "mysql: unsupported isolation level: 7" + actual, err := mapIsolationLevel(driver.IsolationLevel(sql.LevelLinearizable)) + if actual != "" || err == nil { + t.Fatal("Expected error on unsupported isolation level") + } + if err.Error() != expectedErr { + t.Fatalf("Expected error to be %q, got %q", expectedErr, err) + } +} + +func TestAppendDateTime(t *testing.T) { + tests := []struct { + t time.Time + str string + timeTruncate time.Duration + expectedErr bool + }{ + { + t: time.Date(1234, 5, 6, 0, 0, 0, 0, time.UTC), + str: "1234-05-06", + }, + { + t: time.Date(4567, 12, 31, 12, 0, 0, 0, time.UTC), + str: "4567-12-31 12:00:00", + }, + { + t: time.Date(2020, 5, 30, 12, 34, 0, 0, time.UTC), + str: "2020-05-30 12:34:00", + }, + { + t: time.Date(2020, 5, 30, 12, 34, 56, 0, time.UTC), + str: "2020-05-30 12:34:56", + }, + { + t: time.Date(2020, 5, 30, 22, 33, 44, 123000000, time.UTC), + str: "2020-05-30 22:33:44.123", + }, + { + t: time.Date(2020, 5, 30, 22, 33, 44, 123456000, time.UTC), + str: "2020-05-30 22:33:44.123456", + }, + { + t: time.Date(2020, 5, 30, 22, 33, 44, 123456789, time.UTC), + str: "2020-05-30 22:33:44.123456789", + }, + { + t: time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC), + str: "9999-12-31 23:59:59.999999999", + }, + { + t: time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC), + str: "0001-01-01", + }, + // Truncated time + { + t: time.Date(1234, 5, 6, 0, 0, 0, 0, time.UTC), + str: "1234-05-06", + timeTruncate: time.Second, + }, + { + t: time.Date(4567, 12, 31, 12, 0, 0, 0, time.UTC), + str: "4567-12-31 12:00:00", + timeTruncate: time.Minute, + }, + { + t: time.Date(2020, 5, 30, 12, 34, 0, 0, time.UTC), + str: "2020-05-30 12:34:00", + timeTruncate: 0, + }, + { + t: time.Date(2020, 5, 30, 12, 34, 56, 0, time.UTC), + str: "2020-05-30 12:34:56", + timeTruncate: time.Second, + }, + { + t: time.Date(2020, 5, 30, 22, 33, 44, 123000000, time.UTC), + str: "2020-05-30 22:33:44", + timeTruncate: time.Second, + }, + { + t: time.Date(2020, 5, 30, 22, 33, 44, 123456000, time.UTC), + str: "2020-05-30 22:33:44.123", + timeTruncate: time.Millisecond, + }, + { + t: time.Date(2020, 5, 30, 22, 33, 44, 123456789, time.UTC), + str: "2020-05-30 22:33:44", + timeTruncate: time.Second, + }, + { + t: time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC), + str: "9999-12-31 23:59:59.999999999", + timeTruncate: 0, + }, + { + t: time.Date(1, 1, 1, 1, 1, 1, 1, time.UTC), + str: "0001-01-01", + timeTruncate: 365 * 24 * time.Hour, + }, + // year out of range + { + t: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC), + expectedErr: true, + }, + { + t: time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC), + expectedErr: true, + }, + } + for _, v := range tests { + buf := make([]byte, 0, 32) + buf, err := appendDateTime(buf, v.t, v.timeTruncate) + if err != nil { + if !v.expectedErr { + t.Errorf("appendDateTime(%v) returned an error: %v", v.t, err) + } + continue + } + if str := string(buf); str != v.str { + t.Errorf("appendDateTime(%v), have: %s, want: %s", v.t, str, v.str) + } + } +} + +func TestParseDateTime(t *testing.T) { + cases := []struct { + name string + str string + }{ + { + name: "parse date", + str: "2020-05-13", + }, + { + name: "parse null date", + str: sDate0, + }, + { + name: "parse datetime", + str: "2020-05-13 21:30:45", + }, + { + name: "parse null datetime", + str: sDateTime0, + }, + { + name: "parse datetime nanosec 1-digit", + str: "2020-05-25 23:22:01.1", + }, + { + name: "parse datetime nanosec 2-digits", + str: "2020-05-25 23:22:01.15", + }, + { + name: "parse datetime nanosec 3-digits", + str: "2020-05-25 23:22:01.159", + }, + { + name: "parse datetime nanosec 4-digits", + str: "2020-05-25 23:22:01.1594", + }, + { + name: "parse datetime nanosec 5-digits", + str: "2020-05-25 23:22:01.15949", + }, + { + name: "parse datetime nanosec 6-digits", + str: "2020-05-25 23:22:01.159491", + }, + } + + for _, loc := range []*time.Location{ + time.UTC, + time.FixedZone("test", 8*60*60), + } { + for _, cc := range cases { + t.Run(cc.name+"-"+loc.String(), func(t *testing.T) { + var want time.Time + if cc.str != sDate0 && cc.str != sDateTime0 { + var err error + want, err = time.ParseInLocation(timeFormat[:len(cc.str)], cc.str, loc) + if err != nil { + t.Fatal(err) + } + } + got, err := parseDateTime([]byte(cc.str), loc) + if err != nil { + t.Fatal(err) + } + + if !want.Equal(got) { + t.Fatalf("want: %v, but got %v", want, got) + } + }) + } + } +} + +func TestInvalidDateTime(t *testing.T) { + cases := []struct { + name string + str string + want time.Time + }{ + { + name: "parse datetime without day", + str: "0000-00-00 21:30:45", + want: time.Date(0, 0, 0, 21, 30, 45, 0, time.UTC), + }, + } + + for _, cc := range cases { + t.Run(cc.name, func(t *testing.T) { + got, err := parseDateTime([]byte(cc.str), time.UTC) + if err != nil { + t.Fatal(err) + } + + if !cc.want.Equal(got) { + t.Fatalf("want: %v, but got %v", cc.want, got) + } + }) + } +} + +func TestParseDateTimeFail(t *testing.T) { + cases := []struct { + name string + str string + wantErr string + }{ + { + name: "parse invalid time", + str: "hello", + wantErr: "invalid time bytes: hello", + }, + { + name: "parse year", + str: "000!-00-00 00:00:00.000000", + wantErr: "not [0-9]", + }, + { + name: "parse month", + str: "0000-!0-00 00:00:00.000000", + wantErr: "not [0-9]", + }, + { + name: `parse "-" after parsed year`, + str: "0000:00-00 00:00:00.000000", + wantErr: "bad value for field: `:`", + }, + { + name: `parse "-" after parsed month`, + str: "0000-00:00 00:00:00.000000", + wantErr: "bad value for field: `:`", + }, + { + name: `parse " " after parsed date`, + str: "0000-00-00+00:00:00.000000", + wantErr: "bad value for field: `+`", + }, + { + name: `parse ":" after parsed date`, + str: "0000-00-00 00-00:00.000000", + wantErr: "bad value for field: `-`", + }, + { + name: `parse ":" after parsed hour`, + str: "0000-00-00 00:00-00.000000", + wantErr: "bad value for field: `-`", + }, + { + name: `parse "." after parsed sec`, + str: "0000-00-00 00:00:00?000000", + wantErr: "bad value for field: `?`", + }, + } + + for _, cc := range cases { + t.Run(cc.name, func(t *testing.T) { + got, err := parseDateTime([]byte(cc.str), time.UTC) + if err == nil { + t.Fatal("want error") + } + if cc.wantErr != err.Error() { + t.Fatalf("want `%s`, but got `%s`", cc.wantErr, err) + } + if !got.IsZero() { + t.Fatal("want zero time") + } + }) + } +} diff --git a/platform/protocol/ai-provider-contracts.md b/platform/protocol/ai-provider-contracts.md new file mode 100644 index 0000000..9cc66cd --- /dev/null +++ b/platform/protocol/ai-provider-contracts.md @@ -0,0 +1,39 @@ +# AI Provider Contracts + +AI providers are platform-managed model endpoints used by plugins through scoped platform capabilities. + +## AIProviderConfig + +- `id`: provider ID. +- `name`: display name. +- `kind`: provider kind. +- `baseUrl`: base URL or relay URL. +- `apiKeyRef`: secret reference. +- `models`: allowed model list. +- `defaultModel`: default model. +- `relayMode`: direct, relay, or local. +- `timeoutMs`: timeout. +- `status`: active, disabled, or error. +- `redactionPolicy`: redaction policy key. + +## AIInvocationRequest + +- `providerId`: selected provider. +- `purpose`: scoped purpose such as `config.suggest` or `logs.diagnose`. +- `serverInstanceId`: optional server context. +- `pluginId`: caller plugin. +- `inputRef`: artifact or bounded structured input. +- `model`: optional model override. + +AI invocation responses must be bounded and must not include raw provider credentials. Config write suggestions must be presented as a diff or recommendation before dispatching run jobs. + +## Management API Contracts + +- `AIProviderCreateRequest`: create provider metadata with `apiKeyRef`, never raw key material. +- `AIProviderUpdateRequest`: replace editable provider metadata while preserving status through the service layer. +- `AIProviderStatusRequest`: set provider status to `active` or `disabled`. +- `AIProviderResponse`: redacted provider response with `apiKeyRef` only. +- `AIProviderTestResponse`: local metadata validation result with `mode=metadata`; live external connectivity is deferred. +- `AIProviderModelsResponse`: configured model list and default model, without credentials. + +Management endpoints must reject raw key-shaped values in `apiKeyRef`. Platform-mediated AI invocation is implemented through the platform service boundary; live external connectivity tests and remote model discovery are deferred to later changes. diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md new file mode 100644 index 0000000..972d97d --- /dev/null +++ b/platform/protocol/run-contracts.md @@ -0,0 +1,99 @@ +# Run Protocol Contracts + +The platform side of run communication is split into independent contracts. + +## Control + +Implemented HTTP JSON routes: + +- `POST /api/v1/run/control/hello` +- `POST /api/v1/run/control/heartbeat` + +Named control DTOs: + +- `RunHelloRequest` +- `RunHelloResponse` +- `RunHeartbeatRequest` +- `RunHeartbeatResponse` +- `RunCapabilityReport` +- `RunCapacityReport` + +Control payloads must remain small and must not include logs, artifact chunks, host paths, raw credentials, direct sockets, or long task results. Hello creates or updates run endpoint metadata and issues an in-memory platform session token. Heartbeat requires that active session token and may request capability refresh when the fingerprint changes. + +Control is the highest-priority run/platform path. Artifact/file transfer load must not delay heartbeat acceptance or mutate heartbeat capacity state through heavy payload fields. + +## Job + +Implemented HTTP JSON routes: + +- `POST /api/v1/run/jobs/claim` +- `POST /api/v1/run/jobs/ack` +- `POST /api/v1/run/jobs/progress` +- `POST /api/v1/run/jobs/result` +- `POST /api/v1/run/jobs/cancel` +- `POST /api/v1/run/jobs/reconcile` + +Named job DTOs: + +- `RunJobClaimRequest` +- `RunJobClaimResponse` +- `RunJobAckRequest` +- `RunJobProgressRequest` +- `RunJobResultRequest` +- `RunJobCancelPollRequest` +- `RunJobReconcileRequest` +- `RunJobReconcileResponse` + +Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstanceId`, `capability`, `idempotencyKey`, lease token, attempt, progress, terminal state, message, error code, and result reference. Job payloads must not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies. + +Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure. + +## Log Ingest + +Implemented HTTP JSON routes: + +- `POST /api/v1/run/logs/batches` +- `POST /api/v1/log-streams/query` + +Named log DTOs: + +- `LogBatchIngestRequest` +- `LogBatchIngestResponse` +- `LogEntry` +- `LogStreamCursorRequest` +- `LogStreamCursorResponse` + +Log ingest supports bounded batches, sequence ranges, checksum validation, retry-safe duplicate acknowledgement, latest sequence tracking, and cursor query. Log payloads must not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data. + +Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log batch acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup. + +The platform stores log stream metadata through `repo.Store` and stores log bodies through the configured `LogBodyStore`. The default `file` backend persists platform metadata to `PLATFORM_METADATA_PATH` and appends log entries to segmented JSONL files under `PLATFORM_LOG_DIR`; the `memory` backend is only for tests and disposable local development. MySQL/Postgres are appropriate for platform metadata, stream state, retention policy, indexes, and audit records, but should not be the primary row-per-log-line store for hundreds or thousands of servers. Production log bodies should move behind the same boundary to append/query backends such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments with compact indexes. + +## Artifact + +Implemented HTTP JSON routes: + +- `POST /api/v1/run/artifacts/open` +- `POST /api/v1/run/artifacts/chunks` +- `POST /api/v1/run/artifacts/status` +- `POST /api/v1/run/artifacts/complete` + +Named artifact DTOs: + +- `ArtifactTransferOpenRequest` +- `ArtifactTransferOpenResponse` +- `ArtifactChunkUploadRequest` +- `ArtifactChunkUploadResponse` +- `ArtifactTransferStatusRequest` +- `ArtifactTransferStatusResponse` +- `ArtifactTransferCompleteRequest` +- `ArtifactTransferCompleteResponse` +- `ArtifactResponse` + +Artifact upload supports active run session validation, job/server-instance owner scoping, bounded JSON chunk payloads, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, log ingest, plugin bridge, and browser file APIs. + +Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them. + +## Game Client Bridge + +The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport. diff --git a/platform/protocol/server-lifecycle.md b/platform/protocol/server-lifecycle.md new file mode 100644 index 0000000..d61e678 --- /dev/null +++ b/platform/protocol/server-lifecycle.md @@ -0,0 +1,77 @@ +# Server Plugin and Instance Lifecycle Contract + +## Installed Server Plugin + +An installed game management plugin is reusable. It defines a server type, but it is not a server instance. + +The plugin marketplace API is a platform-facing projection over this installed registry metadata. It is not a package store, billing system, provider marketplace, or cloud host sales surface. + +### States + +- `installed`: plugin manifest and schemas are valid. +- `disabled`: plugin cannot create new servers but existing instances remain inspectable. +- `invalid`: plugin failed validation and cannot create or manage instances. +- `updating`: plugin version is being changed. + +## Server Instance + +A server instance is created from one installed game management plugin and bound to one run endpoint. + +### States + +- `draft`: instance record exists but install job has not completed. +- `installing`: run install job is active. +- `ready`: install succeeded and the server can start. +- `running`: server process is running. +- `stopped`: server process is stopped. +- `failed`: last lifecycle operation failed. +- `deleted`: instance is no longer active. + +## Invariants + +- One `GamePlugin` installation may own many `ServerInstance` records. +- A `ServerInstance` must keep its own config version, artifacts, jobs, log streams, and permissions. +- Updating a plugin must not silently mutate existing server instances without a recorded reconcile job. +- Deleting a plugin must be blocked or explicitly require handling existing server instances first. + +## Lifecycle Actions + +- `create`: validate plugin, create instance record, dispatch install job. +- `start`: dispatch process start job through the bound run endpoint. +- `stop`: dispatch process stop job through the bound run endpoint. +- `restart`: dispatch stop/start or plugin-defined restart job. +- `update`: dispatch server update job and record version/result. +- `delete`: stop server when needed, preserve or remove artifacts according to policy, mark deleted. + +## Implemented Workflow Routes + +- `GET /api/v1/plugin-marketplace/plugins` lists plugin marketplace summaries from registry metadata with status, server type, capability, and keyword filters. +- `GET /api/v1/plugin-marketplace/plugins/{id}` returns one registry-backed marketplace detail. +- `POST /api/v1/plugin-marketplace/plugins/{id}/state` applies metadata-only `install`, `enable`, or `disable` state changes. +- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, a compatible run endpoint, a non-empty idempotency key, and required lifecycle action references. It creates the instance in `installing` state and queues a `process.install` job. +- `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job. +- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job. +- `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata. +- `POST /api/v1/server-instances/{id}/config/diff` validates an authorized proposed config write against the current config version and returns a bounded platform diff without queuing work. +- `POST /api/v1/server-instances/{id}/config/approve` revalidates an explicitly reviewed config diff and queues a scoped `config.write` job using a logical config key and input ref. +- `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks. +- `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user. + +Workflow route responses include the accepted action, bounded server instance metadata, and bounded job metadata. They do not expose run session tokens, host paths, raw credentials, direct sockets, AI provider keys, or plugin action file contents. + +Config read and server metrics responses are also bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys. + +Config write approval and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads. + +Marketplace state actions update only registry install state. They do not download packages, dispatch run jobs, execute plugin bridge code, write server files, expose package bytes, or contact external services. Package acquisition and runtime execution remain deferred to explicit future changes. + +## Lifecycle Job Projection + +Terminal run job results update the associated server instance when the job capability is a lifecycle capability: + +- `process.install` + `succeeded` marks the instance `ready`. +- `process.start` + `succeeded` marks the instance `running`. +- `process.stop` + `succeeded` marks the instance `stopped`. +- `process.install`, `process.start`, or `process.stop` + `failed` or `cancelled` marks the instance `failed`. + +Active start and stop jobs do not introduce separate `starting` or `stopping` states in this change. Operators can inspect pending job state through the job list while the instance remains in its last terminal server state. diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go new file mode 100644 index 0000000..bd057f0 --- /dev/null +++ b/platform/repo/file_store.go @@ -0,0 +1,225 @@ +package repo + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "browser.local/platform/domain" +) + +type StoreSnapshot struct { + Users []domain.User `json:"users"` + AIProviders []domain.AIProvider `json:"aiProviders"` + GamePlugins []domain.GamePlugin `json:"gamePlugins"` + ServerInstances []domain.ServerInstance `json:"serverInstances"` + RunEndpoints []domain.RunEndpoint `json:"runEndpoints"` + Jobs []domain.Job `json:"jobs"` + Artifacts []domain.Artifact `json:"artifacts"` + LogStreams []domain.LogStream `json:"logStreams"` + AuditEvents []domain.AuditEvent `json:"auditEvents"` +} + +type FileStore struct { + *MemoryStore + path string + persistMu sync.Mutex +} + +func NewFileStore(path string) (*FileStore, error) { + path = strings.TrimSpace(path) + if path == "" { + return nil, fmt.Errorf("metadata path is required") + } + store := &FileStore{ + MemoryStore: NewMemoryStore(), + path: path, + } + if err := store.load(); err != nil { + return nil, err + } + return store, nil +} + +func (store *FileStore) MetadataPath() string { + return store.path +} + +func (store *FileStore) Users() UserRepository { + return &persistentRepository[domain.User, domain.UserFilter]{repository: store.MemoryStore.users, persist: store.persist} +} + +func (store *FileStore) AIProviders() AIProviderRepository { + return &persistentRepository[domain.AIProvider, domain.AIProviderFilter]{repository: store.MemoryStore.aiProviders, persist: store.persist} +} + +func (store *FileStore) GamePlugins() GamePluginRepository { + return &persistentRepository[domain.GamePlugin, domain.GamePluginFilter]{repository: store.MemoryStore.gamePlugins, persist: store.persist} +} + +func (store *FileStore) ServerInstances() ServerInstanceRepository { + return &persistentRepository[domain.ServerInstance, domain.ServerInstanceFilter]{repository: store.MemoryStore.serverInstances, persist: store.persist} +} + +func (store *FileStore) RunEndpoints() RunEndpointRepository { + return &persistentRepository[domain.RunEndpoint, domain.RunEndpointFilter]{repository: store.MemoryStore.runEndpoints, persist: store.persist} +} + +func (store *FileStore) Jobs() JobRepository { + return &persistentJobRepository{ + persistentRepository: &persistentRepository[domain.Job, domain.JobFilter]{repository: store.MemoryStore.jobs, persist: store.persist}, + repository: store.MemoryStore.jobs, + } +} + +func (store *FileStore) Artifacts() ArtifactRepository { + return &persistentRepository[domain.Artifact, domain.ArtifactFilter]{repository: store.MemoryStore.artifacts, persist: store.persist} +} + +func (store *FileStore) LogStreams() LogStreamRepository { + return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist} +} + +func (store *FileStore) AuditEvents() AuditEventRepository { + return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist} +} + +func (store *FileStore) load() error { + data, err := os.ReadFile(store.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read metadata snapshot: %w", err) + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil + } + var snapshot StoreSnapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return fmt.Errorf("decode metadata snapshot: %w", err) + } + store.loadSnapshot(snapshot) + return nil +} + +func (store *FileStore) persist() error { + store.persistMu.Lock() + defer store.persistMu.Unlock() + + snapshot := store.snapshot() + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return fmt.Errorf("encode metadata snapshot: %w", err) + } + if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil { + return fmt.Errorf("create metadata directory: %w", err) + } + tmpPath := store.path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + return fmt.Errorf("write metadata snapshot: %w", err) + } + if err := os.Rename(tmpPath, store.path); err != nil { + return fmt.Errorf("replace metadata snapshot: %w", err) + } + return nil +} + +func (store *FileStore) snapshot() StoreSnapshot { + return StoreSnapshot{ + Users: snapshotRepository(store.MemoryStore.users), + AIProviders: snapshotRepository(store.MemoryStore.aiProviders), + GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), + ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), + RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), + Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), + Artifacts: snapshotRepository(store.MemoryStore.artifacts), + LogStreams: snapshotRepository(store.MemoryStore.logStreams), + AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), + } +} + +func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { + loadRepository(store.MemoryStore.users, snapshot.Users) + loadRepository(store.MemoryStore.aiProviders, snapshot.AIProviders) + loadRepository(store.MemoryStore.gamePlugins, snapshot.GamePlugins) + loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances) + loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints) + loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs) + loadRepository(store.MemoryStore.artifacts, snapshot.Artifacts) + loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams) + loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents) +} + +type mutableRepository[T any, F any] interface { + Create(T) error + Get(string) (T, error) + List(F) ([]T, error) + Update(T) error +} + +type persistentRepository[T any, F any] struct { + repository mutableRepository[T, F] + persist func() error +} + +func (repository *persistentRepository[T, F]) Create(value T) error { + if err := repository.repository.Create(value); err != nil { + return err + } + return repository.persist() +} + +func (repository *persistentRepository[T, F]) Get(id string) (T, error) { + return repository.repository.Get(id) +} + +func (repository *persistentRepository[T, F]) List(filter F) ([]T, error) { + return repository.repository.List(filter) +} + +func (repository *persistentRepository[T, F]) Update(value T) error { + if err := repository.repository.Update(value); err != nil { + return err + } + return repository.persist() +} + +type persistentJobRepository struct { + *persistentRepository[domain.Job, domain.JobFilter] + repository JobRepository +} + +func (repository *persistentJobRepository) GetByIdempotency(runEndpointID string, idempotencyKey string) (domain.Job, error) { + return repository.repository.GetByIdempotency(runEndpointID, idempotencyKey) +} + +func snapshotRepository[T any, F any](repository *memoryRepository[T, F]) []T { + repository.mu.RLock() + defer repository.mu.RUnlock() + + ids := make([]string, 0, len(repository.byID)) + for id := range repository.byID { + ids = append(ids, id) + } + sort.Strings(ids) + values := make([]T, 0, len(ids)) + for _, id := range ids { + values = append(values, repository.copyOf(repository.byID[id])) + } + return values +} + +func loadRepository[T any, F any](repository *memoryRepository[T, F], values []T) { + repository.mu.Lock() + defer repository.mu.Unlock() + + repository.byID = map[string]T{} + for _, value := range values { + repository.byID[repository.idOf(value)] = repository.copyOf(value) + } +} diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go new file mode 100644 index 0000000..8eb29ff --- /dev/null +++ b/platform/repo/mysql_store.go @@ -0,0 +1,174 @@ +package repo + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "browser.local/platform/domain" + + _ "github.com/go-sql-driver/mysql" +) + +const mysqlSnapshotID = "current" + +type MySQLStore struct { + *MemoryStore + db *sql.DB + persistMu sync.Mutex +} + +func NewMySQLStore(dsn string) (*MySQLStore, error) { + dsn = strings.TrimSpace(dsn) + if dsn == "" { + return nil, fmt.Errorf("PLATFORM_MYSQL_DSN is required when PLATFORM_STORAGE_BACKEND=mysql") + } + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("open mysql metadata store: %w", err) + } + store := &MySQLStore{ + MemoryStore: NewMemoryStore(), + db: db, + } + if err := store.initialize(); err != nil { + _ = db.Close() + return nil, err + } + if err := store.load(); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +func (store *MySQLStore) Close() error { + return store.db.Close() +} + +func (store *MySQLStore) Users() UserRepository { + return &persistentRepository[domain.User, domain.UserFilter]{repository: store.MemoryStore.users, persist: store.persist} +} + +func (store *MySQLStore) AIProviders() AIProviderRepository { + return &persistentRepository[domain.AIProvider, domain.AIProviderFilter]{repository: store.MemoryStore.aiProviders, persist: store.persist} +} + +func (store *MySQLStore) GamePlugins() GamePluginRepository { + return &persistentRepository[domain.GamePlugin, domain.GamePluginFilter]{repository: store.MemoryStore.gamePlugins, persist: store.persist} +} + +func (store *MySQLStore) ServerInstances() ServerInstanceRepository { + return &persistentRepository[domain.ServerInstance, domain.ServerInstanceFilter]{repository: store.MemoryStore.serverInstances, persist: store.persist} +} + +func (store *MySQLStore) RunEndpoints() RunEndpointRepository { + return &persistentRepository[domain.RunEndpoint, domain.RunEndpointFilter]{repository: store.MemoryStore.runEndpoints, persist: store.persist} +} + +func (store *MySQLStore) Jobs() JobRepository { + return &persistentJobRepository{ + persistentRepository: &persistentRepository[domain.Job, domain.JobFilter]{repository: store.MemoryStore.jobs, persist: store.persist}, + repository: store.MemoryStore.jobs, + } +} + +func (store *MySQLStore) Artifacts() ArtifactRepository { + return &persistentRepository[domain.Artifact, domain.ArtifactFilter]{repository: store.MemoryStore.artifacts, persist: store.persist} +} + +func (store *MySQLStore) LogStreams() LogStreamRepository { + return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist} +} + +func (store *MySQLStore) AuditEvents() AuditEventRepository { + return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist} +} + +func (store *MySQLStore) initialize() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := store.db.PingContext(ctx); err != nil { + return fmt.Errorf("connect mysql metadata store: %w", err) + } + _, err := store.db.ExecContext(ctx, ` +CREATE TABLE IF NOT EXISTS platform_metadata_snapshots ( + id VARCHAR(64) PRIMARY KEY, + snapshot_json JSON NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +)`) + if err != nil { + return fmt.Errorf("create mysql metadata snapshot table: %w", err) + } + return nil +} + +func (store *MySQLStore) load() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var payload []byte + err := store.db.QueryRowContext(ctx, "SELECT snapshot_json FROM platform_metadata_snapshots WHERE id = ?", mysqlSnapshotID).Scan(&payload) + if err != nil { + if err == sql.ErrNoRows { + return nil + } + return fmt.Errorf("read mysql metadata snapshot: %w", err) + } + var snapshot StoreSnapshot + if err := json.Unmarshal(payload, &snapshot); err != nil { + return fmt.Errorf("decode mysql metadata snapshot: %w", err) + } + store.loadSnapshot(snapshot) + return nil +} + +func (store *MySQLStore) persist() error { + store.persistMu.Lock() + defer store.persistMu.Unlock() + + snapshot := store.snapshot() + payload, err := json.Marshal(snapshot) + if err != nil { + return fmt.Errorf("encode mysql metadata snapshot: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err = store.db.ExecContext(ctx, ` +INSERT INTO platform_metadata_snapshots (id, snapshot_json) +VALUES (?, ?) +ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysqlSnapshotID, string(payload), string(payload)) + if err != nil { + return fmt.Errorf("write mysql metadata snapshot: %w", err) + } + return nil +} + +func (store *MySQLStore) snapshot() StoreSnapshot { + return StoreSnapshot{ + Users: snapshotRepository(store.MemoryStore.users), + AIProviders: snapshotRepository(store.MemoryStore.aiProviders), + GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), + ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), + RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), + Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), + Artifacts: snapshotRepository(store.MemoryStore.artifacts), + LogStreams: snapshotRepository(store.MemoryStore.logStreams), + AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), + } +} + +func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { + loadRepository(store.MemoryStore.users, snapshot.Users) + loadRepository(store.MemoryStore.aiProviders, snapshot.AIProviders) + loadRepository(store.MemoryStore.gamePlugins, snapshot.GamePlugins) + loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances) + loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints) + loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs) + loadRepository(store.MemoryStore.artifacts, snapshot.Artifacts) + loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams) + loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents) +} diff --git a/platform/repo/resources.go b/platform/repo/resources.go new file mode 100644 index 0000000..b3e3932 --- /dev/null +++ b/platform/repo/resources.go @@ -0,0 +1,315 @@ +package repo + +import ( + "errors" + "sort" + "sync" + + "browser.local/platform/domain" +) + +var ( + ErrDuplicate = errors.New("resource already exists") + ErrNotFound = errors.New("resource not found") +) + +type UserRepository interface { + Create(domain.User) error + Get(id string) (domain.User, error) + List(domain.UserFilter) ([]domain.User, error) + Update(domain.User) error +} + +type AIProviderRepository interface { + Create(domain.AIProvider) error + Get(id string) (domain.AIProvider, error) + List(domain.AIProviderFilter) ([]domain.AIProvider, error) + Update(domain.AIProvider) error +} + +type GamePluginRepository interface { + Create(domain.GamePlugin) error + Get(id string) (domain.GamePlugin, error) + List(domain.GamePluginFilter) ([]domain.GamePlugin, error) + Update(domain.GamePlugin) error +} + +type ServerInstanceRepository interface { + Create(domain.ServerInstance) error + Get(id string) (domain.ServerInstance, error) + List(domain.ServerInstanceFilter) ([]domain.ServerInstance, error) + Update(domain.ServerInstance) error +} + +type RunEndpointRepository interface { + Create(domain.RunEndpoint) error + Get(id string) (domain.RunEndpoint, error) + List(domain.RunEndpointFilter) ([]domain.RunEndpoint, error) + Update(domain.RunEndpoint) error +} + +type JobRepository interface { + Create(domain.Job) error + Get(id string) (domain.Job, error) + GetByIdempotency(runEndpointID string, idempotencyKey string) (domain.Job, error) + List(domain.JobFilter) ([]domain.Job, error) + Update(domain.Job) error +} + +type ArtifactRepository interface { + Create(domain.Artifact) error + Get(id string) (domain.Artifact, error) + List(domain.ArtifactFilter) ([]domain.Artifact, error) + Update(domain.Artifact) error +} + +type LogStreamRepository interface { + Create(domain.LogStream) error + Get(id string) (domain.LogStream, error) + List(domain.LogStreamFilter) ([]domain.LogStream, error) + Update(domain.LogStream) error +} + +type AuditEventRepository interface { + Create(domain.AuditEvent) error + Get(id string) (domain.AuditEvent, error) + List(domain.AuditEventFilter) ([]domain.AuditEvent, error) + Update(domain.AuditEvent) error +} + +type Store interface { + Users() UserRepository + AIProviders() AIProviderRepository + GamePlugins() GamePluginRepository + ServerInstances() ServerInstanceRepository + RunEndpoints() RunEndpointRepository + Jobs() JobRepository + Artifacts() ArtifactRepository + LogStreams() LogStreamRepository + AuditEvents() AuditEventRepository +} + +type MemoryStore struct { + users *memoryRepository[domain.User, domain.UserFilter] + aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter] + gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter] + serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter] + runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter] + jobs *memoryJobRepository + artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter] + logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] + auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + users: newMemoryRepository( + func(user domain.User) string { return user.ID }, + domain.CopyUser, + matchUser, + ), + aiProviders: newMemoryRepository( + func(provider domain.AIProvider) string { return provider.ID }, + domain.CopyAIProvider, + matchAIProvider, + ), + gamePlugins: newMemoryRepository( + func(plugin domain.GamePlugin) string { return plugin.ID }, + domain.CopyGamePlugin, + matchGamePlugin, + ), + serverInstances: newMemoryRepository( + func(instance domain.ServerInstance) string { return instance.ID }, + domain.CopyServerInstance, + matchServerInstance, + ), + runEndpoints: newMemoryRepository( + func(endpoint domain.RunEndpoint) string { return endpoint.ID }, + domain.CopyRunEndpoint, + matchRunEndpoint, + ), + jobs: newMemoryJobRepository(), + artifacts: newMemoryRepository( + func(artifact domain.Artifact) string { return artifact.ID }, + domain.CopyArtifact, + matchArtifact, + ), + logStreams: newMemoryRepository( + func(stream domain.LogStream) string { return stream.ID }, + domain.CopyLogStream, + matchLogStream, + ), + auditEvents: newMemoryRepository( + func(event domain.AuditEvent) string { return event.ID }, + domain.CopyAuditEvent, + matchAuditEvent, + ), + } +} + +func (store *MemoryStore) Users() UserRepository { return store.users } +func (store *MemoryStore) AIProviders() AIProviderRepository { return store.aiProviders } +func (store *MemoryStore) GamePlugins() GamePluginRepository { return store.gamePlugins } +func (store *MemoryStore) ServerInstances() ServerInstanceRepository { return store.serverInstances } +func (store *MemoryStore) RunEndpoints() RunEndpointRepository { return store.runEndpoints } +func (store *MemoryStore) Jobs() JobRepository { return store.jobs } +func (store *MemoryStore) Artifacts() ArtifactRepository { return store.artifacts } +func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams } +func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents } + +type memoryRepository[T any, F any] struct { + mu sync.RWMutex + byID map[string]T + idOf func(T) string + copyOf func(T) T + match func(T, F) bool +} + +func newMemoryRepository[T any, F any](idOf func(T) string, copyOf func(T) T, match func(T, F) bool) *memoryRepository[T, F] { + return &memoryRepository[T, F]{ + byID: map[string]T{}, + idOf: idOf, + copyOf: copyOf, + match: match, + } +} + +func (repository *memoryRepository[T, F]) Create(value T) error { + repository.mu.Lock() + defer repository.mu.Unlock() + + id := repository.idOf(value) + if _, exists := repository.byID[id]; exists { + return ErrDuplicate + } + repository.byID[id] = repository.copyOf(value) + return nil +} + +func (repository *memoryRepository[T, F]) Get(id string) (T, error) { + repository.mu.RLock() + defer repository.mu.RUnlock() + + value, exists := repository.byID[id] + if !exists { + var zero T + return zero, ErrNotFound + } + return repository.copyOf(value), nil +} + +func (repository *memoryRepository[T, F]) List(filter F) ([]T, error) { + repository.mu.RLock() + defer repository.mu.RUnlock() + + ids := make([]string, 0, len(repository.byID)) + for id := range repository.byID { + ids = append(ids, id) + } + sort.Strings(ids) + + values := make([]T, 0, len(ids)) + for _, id := range ids { + value := repository.byID[id] + if repository.match(value, filter) { + values = append(values, repository.copyOf(value)) + } + } + return values, nil +} + +func (repository *memoryRepository[T, F]) Update(value T) error { + repository.mu.Lock() + defer repository.mu.Unlock() + + id := repository.idOf(value) + if _, exists := repository.byID[id]; !exists { + return ErrNotFound + } + repository.byID[id] = repository.copyOf(value) + return nil +} + +type memoryJobRepository struct { + *memoryRepository[domain.Job, domain.JobFilter] +} + +func newMemoryJobRepository() *memoryJobRepository { + return &memoryJobRepository{ + memoryRepository: newMemoryRepository( + func(job domain.Job) string { return job.ID }, + domain.CopyJob, + matchJob, + ), + } +} + +func (repository *memoryJobRepository) GetByIdempotency(runEndpointID string, idempotencyKey string) (domain.Job, error) { + repository.mu.RLock() + defer repository.mu.RUnlock() + + for _, job := range repository.byID { + if job.RunEndpointID == runEndpointID && job.IdempotencyKey == idempotencyKey { + return domain.CopyJob(job), nil + } + } + return domain.Job{}, ErrNotFound +} + +func matchUser(user domain.User, filter domain.UserFilter) bool { + return filter.Status == "" || user.Status == filter.Status +} + +func matchAIProvider(provider domain.AIProvider, filter domain.AIProviderFilter) bool { + return (filter.Kind == "" || provider.Kind == filter.Kind) && + (filter.Status == "" || provider.Status == filter.Status) +} + +func matchGamePlugin(plugin domain.GamePlugin, filter domain.GamePluginFilter) bool { + return (filter.ServerType == "" || plugin.ServerType == filter.ServerType) && + (filter.Status == "" || plugin.Status == filter.Status) +} + +func matchServerInstance(instance domain.ServerInstance, filter domain.ServerInstanceFilter) bool { + return (filter.PluginID == "" || instance.PluginID == filter.PluginID) && + (filter.RunEndpointID == "" || instance.RunEndpointID == filter.RunEndpointID) && + (filter.State == "" || instance.State == filter.State) && + (filter.VisibleToUserID == "" || instance.OwnerUserID == filter.VisibleToUserID || containsString(instance.AdminUserIDs, filter.VisibleToUserID)) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func matchRunEndpoint(endpoint domain.RunEndpoint, filter domain.RunEndpointFilter) bool { + return filter.Status == "" || endpoint.Status == filter.Status +} + +func matchJob(job domain.Job, filter domain.JobFilter) bool { + return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) && + (filter.RunEndpointID == "" || job.RunEndpointID == filter.RunEndpointID) && + (filter.State == "" || job.State == filter.State) +} + +func matchArtifact(artifact domain.Artifact, filter domain.ArtifactFilter) bool { + return (filter.OwnerKind == "" || artifact.OwnerKind == filter.OwnerKind) && + (filter.OwnerID == "" || artifact.OwnerID == filter.OwnerID) && + (filter.State == "" || artifact.State == filter.State) +} + +func matchLogStream(stream domain.LogStream, filter domain.LogStreamFilter) bool { + return (filter.ServerInstanceID == "" || stream.ServerInstanceID == filter.ServerInstanceID) && + (filter.StreamKey == "" || stream.StreamKey == filter.StreamKey) +} + +func matchAuditEvent(event domain.AuditEvent, filter domain.AuditEventFilter) bool { + return (filter.ActorID == "" || event.ActorID == filter.ActorID) && + (filter.ResourceKind == "" || event.ResourceKind == filter.ResourceKind) && + (filter.ResourceID == "" || event.ResourceID == filter.ResourceID) && + (filter.Result == "" || event.Result == filter.Result) +} diff --git a/platform/repo/resources_test.go b/platform/repo/resources_test.go new file mode 100644 index 0000000..ef01628 --- /dev/null +++ b/platform/repo/resources_test.go @@ -0,0 +1,145 @@ +package repo + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "browser.local/platform/domain" +) + +func TestMemoryRepositoryRejectsDuplicateAndCopiesValues(t *testing.T) { + store := NewMemoryStore() + user := domain.User{ + ID: "user-1", + DisplayName: "Mary", + Status: domain.UserStatusActive, + Roles: []string{"admin"}, + } + + if err := store.Users().Create(user); err != nil { + t.Fatalf("create user: %v", err) + } + if err := store.Users().Create(user); !errors.Is(err, ErrDuplicate) { + t.Fatalf("expected duplicate error, got %v", err) + } + + got, err := store.Users().Get(user.ID) + if err != nil { + t.Fatalf("get user: %v", err) + } + got.Roles[0] = "mutated" + + again, err := store.Users().Get(user.ID) + if err != nil { + t.Fatalf("get user again: %v", err) + } + if again.Roles[0] != "admin" { + t.Fatalf("expected stored roles to be isolated, got %+v", again.Roles) + } +} + +func TestMemoryRepositoryListFiltersAndSorts(t *testing.T) { + store := NewMemoryStore() + users := []domain.User{ + {ID: "user-c", DisplayName: "C", Status: domain.UserStatusDisabled}, + {ID: "user-b", DisplayName: "B", Status: domain.UserStatusActive}, + {ID: "user-a", DisplayName: "A", Status: domain.UserStatusActive}, + } + for _, user := range users { + if err := store.Users().Create(user); err != nil { + t.Fatalf("create user %s: %v", user.ID, err) + } + } + + active, err := store.Users().List(domain.UserFilter{Status: domain.UserStatusActive}) + if err != nil { + t.Fatalf("list users: %v", err) + } + if len(active) != 2 || active[0].ID != "user-a" || active[1].ID != "user-b" { + t.Fatalf("expected sorted active users, got %+v", active) + } +} + +func TestMemoryJobRepositoryFindsIdempotencyKey(t *testing.T) { + store := NewMemoryStore() + job := domain.Job{ + ID: "job-1", + RunEndpointID: "run-local", + Capability: "process.start", + IdempotencyKey: "idem-1", + State: domain.JobStateQueued, + } + + if err := store.Jobs().Create(job); err != nil { + t.Fatalf("create job: %v", err) + } + + got, err := store.Jobs().GetByIdempotency("run-local", "idem-1") + if err != nil { + t.Fatalf("get by idempotency: %v", err) + } + if got.ID != job.ID { + t.Fatalf("expected job %q, got %q", job.ID, got.ID) + } + + _, err = store.Jobs().GetByIdempotency("run-local", "missing") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected not found for missing idempotency key, got %v", err) + } +} + +func TestFileStorePersistsAndReloadsResources(t *testing.T) { + path := filepath.Join(t.TempDir(), "metadata.json") + store, err := NewFileStore(path) + if err != nil { + t.Fatalf("create file store: %v", err) + } + user := domain.User{ + ID: "user-1", + DisplayName: "Durable User", + Status: domain.UserStatusActive, + Roles: []string{"platform-admin"}, + } + if err := store.Users().Create(user); err != nil { + t.Fatalf("create user: %v", err) + } + job := domain.Job{ + ID: "job-1", + RunEndpointID: "run-local", + ServerInstanceID: "server-1", + Capability: "process.start", + IdempotencyKey: "idem-1", + State: domain.JobStateQueued, + } + if err := store.Jobs().Create(job); err != nil { + t.Fatalf("create job: %v", err) + } + + reloaded, err := NewFileStore(path) + if err != nil { + t.Fatalf("reload file store: %v", err) + } + got, err := reloaded.Users().Get("user-1") + if err != nil { + t.Fatalf("get reloaded user: %v", err) + } + if got.DisplayName != user.DisplayName || got.Roles[0] != "platform-admin" { + t.Fatalf("unexpected reloaded user: %+v", got) + } + gotJob, err := reloaded.Jobs().GetByIdempotency("run-local", "idem-1") + if err != nil { + t.Fatalf("get reloaded job by idempotency: %v", err) + } + if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" { + t.Fatalf("unexpected reloaded job: %+v", gotJob) + } +} + +func TestMySQLStoreRequiresDSN(t *testing.T) { + _, err := NewMySQLStore("") + if err == nil || !strings.Contains(err.Error(), "PLATFORM_MYSQL_DSN") { + t.Fatalf("expected missing MySQL DSN error, got %v", err) + } +} diff --git a/platform/service/ai_invocation.go b/platform/service/ai_invocation.go new file mode 100644 index 0000000..e7c0809 --- /dev/null +++ b/platform/service/ai_invocation.go @@ -0,0 +1,185 @@ +package service + +import ( + "errors" + "strings" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +type AIProviderClient interface { + Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) +} + +type MockAIProviderClient struct{} + +func (MockAIProviderClient) Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) { + model := request.Model + if model == "" { + model = provider.DefaultModel + } + if model == "" && len(provider.Models) > 0 { + model = provider.Models[0] + } + recommendation := "Mock AI recommendation for " + request.Purpose + ": review the proposed change before dispatch." + result := domain.AIProviderInvocationResult{ + Recommendation: recommendation, + Usage: domain.AIInvocationUsage{ + ProviderID: provider.ID, + Model: model, + InputTokens: boundedTokenEstimate(request.Prompt + request.CurrentConfig), + OutputTokens: boundedTokenEstimate(recommendation), + Mocked: true, + }, + } + if request.Purpose == "config.suggest" || request.Purpose == "config.generate" { + result.SuggestedConfig = buildSuggestedConfig(request.CurrentConfig, request.Prompt) + } + return result, nil +} + +func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIInvocationRequest) (domain.AIInvocationResponse, error) { + request = domain.CopyAIInvocationRequest(request) + if err := validator.ValidateAIInvocationRequest(request); err != nil { + return domain.AIInvocationResponse{}, err + } + if _, err := svc.GetCurrentUser(sessionID); err != nil { + return domain.AIInvocationResponse{}, err + } + if request.ServerInstanceID != "" { + instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) + if err != nil { + return domain.AIInvocationResponse{}, err + } + if request.PluginID != "" && instance.PluginID != request.PluginID { + return safeAIDenial(request, "plugin scope does not match server instance"), nil + } + } + if request.PluginID != "" { + plugin, err := svc.store.GamePlugins().Get(request.PluginID) + if err != nil { + return domain.AIInvocationResponse{}, err + } + authorization, err := validator.AuthorizePluginBridgeAction(plugin, domain.PluginBridgeAuthorizeRequest{ + PluginID: request.PluginID, + RouteKey: request.RouteKey, + ServerInstanceID: request.ServerInstanceID, + Action: domain.PluginBridgeActionAIInvoke, + AIPurpose: request.Purpose, + }) + if err != nil { + return domain.AIInvocationResponse{}, err + } + if !authorization.Allowed { + return safeAIDenial(request, authorization.Reason), nil + } + } + provider, err := svc.selectAIProvider(request.ProviderID) + if err != nil { + return domain.AIInvocationResponse{}, err + } + result, err := svc.aiProviderClient.Invoke(provider, request) + if err != nil { + return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{ + RequestID: request.RequestID, + Purpose: request.Purpose, + ProviderID: provider.ID, + Model: safeModel(request.Model, provider), + Status: "error", + Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider), Mocked: true}, + Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: safeBridgeReason(err.Error())}, + }), nil + } + response := domain.AIInvocationResponse{ + RequestID: request.RequestID, + Purpose: request.Purpose, + ProviderID: provider.ID, + Model: result.Usage.Model, + Status: "ok", + Recommendation: result.Recommendation, + Usage: result.Usage, + } + if result.SuggestedConfig != "" { + response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: "server.properties", SuggestedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch"} + } + if err := validator.ValidateAIInvocationResponse(response); err != nil { + return domain.AIInvocationResponse{}, err + } + return domain.CopyAIInvocationResponse(response), nil +} + +func (svc *CoreService) selectAIProvider(id string) (domain.AIProvider, error) { + if id != "" { + provider, err := svc.store.AIProviders().Get(id) + if err != nil { + return domain.AIProvider{}, err + } + if provider.Status != domain.AIProviderStatusActive { + return domain.AIProvider{}, ErrForbidden + } + return provider, nil + } + providers, err := svc.store.AIProviders().List(domain.AIProviderFilter{Status: domain.AIProviderStatusActive}) + if err != nil { + return domain.AIProvider{}, err + } + if len(providers) == 0 { + return domain.AIProvider{}, repo.ErrNotFound + } + return providers[0], nil +} + +func safeAIDenial(request domain.AIInvocationRequest, reason string) domain.AIInvocationResponse { + return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{ + RequestID: request.RequestID, + Purpose: request.Purpose, + Status: "denied", + Error: &domain.AIInvocationSafeError{Code: "permission_denied", Message: safeBridgeReason(reason)}, + }) +} + +func safeModel(model string, provider domain.AIProvider) string { + if strings.TrimSpace(model) != "" { + return model + } + if provider.DefaultModel != "" { + return provider.DefaultModel + } + if len(provider.Models) > 0 { + return provider.Models[0] + } + return "mock-model" +} + +func buildSuggestedConfig(currentConfig string, prompt string) string { + base := strings.TrimRight(currentConfig, "\n") + if base == "" { + base = "# generated server config" + } + if strings.Contains(strings.ToLower(prompt), "pvp") && !strings.Contains(base, "pvp=") { + base += "\npvp=false" + } + return base + "\n# ai.recommendation=review-required\n" +} + +func boundedTokenEstimate(value string) int { + count := len([]rune(value)) / 4 + if count < 1 { + return 1 + } + if count > 4096 { + return 4096 + } + return count +} + +type failingAIProviderClient struct{ err error } + +func (client failingAIProviderClient) Invoke(domain.AIProvider, domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) { + if client.err == nil { + return domain.AIProviderInvocationResult{}, errors.New("provider failed") + } + return domain.AIProviderInvocationResult{}, client.err +} diff --git a/platform/service/artifact_download.go b/platform/service/artifact_download.go new file mode 100644 index 0000000..ee23645 --- /dev/null +++ b/platform/service/artifact_download.go @@ -0,0 +1,188 @@ +package service + +import ( + "fmt" + "net/url" + "sort" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +const artifactDownloadStorageBehavior = "platform-memory-transfer-session" + +func (svc *CoreService) GetArtifactForSession(sessionID string, artifactID string) (domain.Artifact, error) { + artifact, err := svc.store.Artifacts().Get(strings.TrimSpace(artifactID)) + if err != nil { + return domain.Artifact{}, err + } + if err := svc.authorizeArtifactAccess(sessionID, artifact); err != nil { + return domain.Artifact{}, err + } + return domain.CopyArtifact(artifact), nil +} + +func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error) { + request = domain.CopyArtifactDownloadReferenceRequest(request) + if err := validator.ValidateArtifactDownloadReferenceRequest(request); err != nil { + return domain.ArtifactDownloadReference{}, err + } + artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID) + if err != nil { + return domain.ArtifactDownloadReference{}, err + } + if artifact.State != domain.ArtifactStateAvailable { + return domain.ArtifactDownloadReference{}, validationError("artifact must be available before download") + } + + reference := domain.ArtifactDownloadReference{ + ArtifactID: artifact.ID, + OwnerKind: artifact.OwnerKind, + OwnerID: artifact.OwnerID, + Filename: artifactDownloadFilename(artifact.ID), + ContentType: "application/octet-stream", + SizeBytes: artifact.SizeBytes, + Checksum: artifact.Checksum, + State: artifact.State, + DownloadURL: "/api/v1/artifacts/" + url.PathEscape(artifact.ID) + "/content", + ExpiresAt: svc.now().Add(15 * time.Minute), + RangeSupported: true, + ChunkSizeBytes: validator.MaxArtifactDownloadBytes, + StorageBehavior: artifactDownloadStorageBehavior, + } + if err := validator.ValidateArtifactDownloadReference(reference); err != nil { + return domain.ArtifactDownloadReference{}, err + } + return domain.CopyArtifactDownloadReference(reference), nil +} + +func (svc *CoreService) ReadArtifactContentForSession(sessionID string, request domain.ArtifactContentRequest) (domain.ArtifactContent, error) { + request = domain.CopyArtifactContentRequest(request) + if err := validator.ValidateArtifactContentRequest(request); err != nil { + return domain.ArtifactContent{}, err + } + artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID) + if err != nil { + return domain.ArtifactContent{}, err + } + if artifact.State != domain.ArtifactStateAvailable { + return domain.ArtifactContent{}, validationError("artifact must be available before download") + } + payload, err := svc.artifactPayload(artifact.ID) + if err != nil { + return domain.ArtifactContent{}, err + } + if int64(len(payload)) != artifact.SizeBytes { + return domain.ArtifactContent{}, validationError("artifact content size does not match metadata") + } + if checksum := validator.BytesChecksum(payload); checksum != artifact.Checksum { + return domain.ArtifactContent{}, validationError("artifact content checksum does not match metadata") + } + if request.Offset >= artifact.SizeBytes { + return domain.ArtifactContent{}, validationError("offset must be inside artifact content") + } + limit := request.Limit + if limit == 0 { + limit = validator.MaxArtifactDownloadBytes + } + remaining := artifact.SizeBytes - request.Offset + if int64(limit) > remaining { + limit = int(remaining) + } + end := int(request.Offset) + limit + part := domain.CopyBytes(payload[int(request.Offset):end]) + content := domain.ArtifactContent{ + ArtifactID: artifact.ID, + Filename: artifactDownloadFilename(artifact.ID), + ContentType: "application/octet-stream", + Offset: request.Offset, + SizeBytes: int64(len(part)), + TotalSizeBytes: artifact.SizeBytes, + Checksum: artifact.Checksum, + ContentChecksum: validator.BytesChecksum(part), + Partial: request.Offset != 0 || int64(len(part)) != artifact.SizeBytes, + RangeSupported: true, + Payload: part, + StorageBehavior: artifactDownloadStorageBehavior, + ServedAt: svc.now(), + } + if err := validator.ValidateArtifactContent(content); err != nil { + return domain.ArtifactContent{}, err + } + return domain.CopyArtifactContent(content), nil +} + +func (svc *CoreService) authorizeArtifactAccess(sessionID string, artifact domain.Artifact) error { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return err + } + switch artifact.OwnerKind { + case domain.ArtifactOwnerKindJob: + job, err := svc.store.Jobs().Get(artifact.OwnerID) + if err != nil { + return err + } + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if err != nil { + return err + } + if !canAccessServer(user, instance) { + return ErrForbidden + } + case domain.ArtifactOwnerKindServerInstance: + instance, err := svc.store.ServerInstances().Get(artifact.OwnerID) + if err != nil { + return err + } + if !canAccessServer(user, instance) { + return ErrForbidden + } + case domain.ArtifactOwnerKindPlatform, domain.ArtifactOwnerKindPlugin: + if !isPlatformAdmin(user) { + return ErrForbidden + } + default: + return validationError("artifact ownerKind is invalid") + } + return nil +} + +func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) { + svc.artifactMu.Lock() + defer svc.artifactMu.Unlock() + + sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers)) + for _, session := range svc.artifactTransfers { + if session.ArtifactID == artifactID && session.Completed { + sessions = append(sessions, domain.CopyArtifactTransferSession(session)) + } + } + if len(sessions) == 0 { + return nil, validationError("artifact content is not available from platform storage") + } + sort.Slice(sessions, func(i int, j int) bool { return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) }) + session := sessions[0] + payload := make([]byte, 0, int(session.SizeBytes)) + for index := 0; index < session.TotalChunks; index++ { + record, exists := session.ReceivedChunks[index] + if !exists { + return nil, validationError("artifact content has missing chunks") + } + payload = append(payload, record.Payload...) + } + if int64(len(payload)) != session.SizeBytes { + return nil, validationError("artifact content size does not match transfer") + } + return payload, nil +} + +func artifactDownloadFilename(artifactID string) string { + name := strings.TrimSpace(artifactID) + if name == "" || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "://") { + return "artifact.bin" + } + return fmt.Sprintf("%s.bin", name) +} diff --git a/platform/service/artifact_transfer.go b/platform/service/artifact_transfer.go new file mode 100644 index 0000000..52016d1 --- /dev/null +++ b/platform/service/artifact_transfer.go @@ -0,0 +1,412 @@ +package service + +import ( + "bytes" + "errors" + "fmt" + "sort" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +func (svc *CoreService) OpenArtifactTransfer(open domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error) { + open = domain.CopyArtifactTransferOpen(open) + if err := validator.ValidateArtifactTransferOpen(open); err != nil { + return domain.ArtifactTransferOpenResult{}, err + } + if err := svc.validateRunSession(open.RunEndpointID, open.SessionToken); err != nil { + return domain.ArtifactTransferOpenResult{}, err + } + + stamp := svc.now() + svc.artifactMu.Lock() + defer svc.artifactMu.Unlock() + + if session, exists := svc.findArtifactTransferByIdempotency(open.RunEndpointID, open.IdempotencyKey); exists { + if err := validateArtifactTransferOpenMatchesSession(open, session); err != nil { + return domain.ArtifactTransferOpenResult{}, err + } + artifact, err := svc.store.Artifacts().Get(session.ArtifactID) + if err != nil { + return domain.ArtifactTransferOpenResult{}, err + } + return artifactTransferOpenResult(session, artifact, true, stamp), nil + } + + if err := svc.validateArtifactTransferOwner(open); err != nil { + return domain.ArtifactTransferOpenResult{}, err + } + + artifact, err := svc.store.Artifacts().Get(open.ArtifactID) + if err != nil { + if !errors.Is(err, repo.ErrNotFound) { + return domain.ArtifactTransferOpenResult{}, err + } + artifact = domain.Artifact{ + ID: open.ArtifactID, + OwnerKind: open.OwnerKind, + OwnerID: open.OwnerID, + SizeBytes: open.SizeBytes, + Checksum: open.Checksum, + State: domain.ArtifactStateUploading, + CreatedAt: stamp, + UpdatedAt: stamp, + } + if err := validator.ValidateArtifact(artifact); err != nil { + return domain.ArtifactTransferOpenResult{}, err + } + if err := svc.store.Artifacts().Create(artifact); err != nil { + return domain.ArtifactTransferOpenResult{}, err + } + } else if err := validateArtifactMatchesTransferOpen(artifact, open); err != nil { + return domain.ArtifactTransferOpenResult{}, err + } + + svc.artifactTransferSeq++ + session := domain.ArtifactTransferSession{ + TransferID: fmt.Sprintf("artifact-transfer:%s:%d:%d", open.ArtifactID, stamp.UnixNano(), svc.artifactTransferSeq), + RunEndpointID: open.RunEndpointID, + ArtifactID: open.ArtifactID, + Direction: open.Direction, + OwnerKind: open.OwnerKind, + OwnerID: open.OwnerID, + SizeBytes: open.SizeBytes, + ChunkSizeBytes: open.ChunkSizeBytes, + Checksum: open.Checksum, + IdempotencyKey: open.IdempotencyKey, + TotalChunks: artifactTotalChunks(open.SizeBytes, open.ChunkSizeBytes), + ReceivedChunks: map[int]domain.ArtifactChunkRecord{}, + CreatedAt: stamp, + UpdatedAt: stamp, + } + svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) + return artifactTransferOpenResult(session, artifact, false, stamp), nil +} + +func (svc *CoreService) UploadArtifactChunk(chunk domain.ArtifactChunkUpload) (domain.ArtifactChunkUploadResult, error) { + chunk = domain.CopyArtifactChunkUpload(chunk) + if err := validator.ValidateArtifactChunkUpload(chunk); err != nil { + return domain.ArtifactChunkUploadResult{}, err + } + if err := svc.validateRunSession(chunk.RunEndpointID, chunk.SessionToken); err != nil { + return domain.ArtifactChunkUploadResult{}, err + } + + stamp := svc.now() + svc.artifactMu.Lock() + defer svc.artifactMu.Unlock() + + session, err := svc.getArtifactTransferSession(chunk.TransferID) + if err != nil { + return domain.ArtifactChunkUploadResult{}, err + } + if err := validateArtifactChunkMatchesSession(chunk, session); err != nil { + return domain.ArtifactChunkUploadResult{}, err + } + + if existing, exists := session.ReceivedChunks[chunk.ChunkIndex]; exists { + if existing.Offset == chunk.Offset && existing.SizeBytes == chunk.SizeBytes && existing.Checksum == chunk.Checksum && bytes.Equal(existing.Payload, chunk.Payload) { + return artifactChunkUploadResult(session, chunk.ChunkIndex, true, stamp), nil + } + return domain.ArtifactChunkUploadResult{}, validationError("artifact chunk conflicts with acknowledged chunk") + } + + session.ReceivedChunks[chunk.ChunkIndex] = domain.ArtifactChunkRecord{ + ChunkIndex: chunk.ChunkIndex, + Offset: chunk.Offset, + SizeBytes: chunk.SizeBytes, + Checksum: chunk.Checksum, + Payload: domain.CopyBytes(chunk.Payload), + ReceivedAt: stamp, + } + session.UpdatedAt = stamp + svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) + return artifactChunkUploadResult(session, chunk.ChunkIndex, false, stamp), nil +} + +func (svc *CoreService) QueryArtifactTransferStatus(query domain.ArtifactTransferStatusQuery) (domain.ArtifactTransferStatusResult, error) { + if err := validator.ValidateArtifactTransferStatusQuery(query); err != nil { + return domain.ArtifactTransferStatusResult{}, err + } + if err := svc.validateRunSession(query.RunEndpointID, query.SessionToken); err != nil { + return domain.ArtifactTransferStatusResult{}, err + } + + stamp := svc.now() + svc.artifactMu.Lock() + defer svc.artifactMu.Unlock() + + session, err := svc.getArtifactTransferSession(query.TransferID) + if err != nil { + return domain.ArtifactTransferStatusResult{}, err + } + if err := validateArtifactTransferStatusMatchesSession(query, session); err != nil { + return domain.ArtifactTransferStatusResult{}, err + } + return artifactTransferStatusResult(session, stamp), nil +} + +func (svc *CoreService) CompleteArtifactTransfer(complete domain.ArtifactTransferComplete) (domain.ArtifactTransferCompleteResult, error) { + if err := validator.ValidateArtifactTransferComplete(complete); err != nil { + return domain.ArtifactTransferCompleteResult{}, err + } + if err := svc.validateRunSession(complete.RunEndpointID, complete.SessionToken); err != nil { + return domain.ArtifactTransferCompleteResult{}, err + } + + stamp := svc.now() + svc.artifactMu.Lock() + defer svc.artifactMu.Unlock() + + session, err := svc.getArtifactTransferSession(complete.TransferID) + if err != nil { + return domain.ArtifactTransferCompleteResult{}, err + } + if err := validateArtifactCompleteMatchesSession(complete, session); err != nil { + return domain.ArtifactTransferCompleteResult{}, err + } + artifact, err := svc.store.Artifacts().Get(session.ArtifactID) + if err != nil { + return domain.ArtifactTransferCompleteResult{}, err + } + if session.Completed { + return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil + } + if len(session.ReceivedChunks) != session.TotalChunks { + return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer has missing chunks") + } + + payload := make([]byte, 0, int(session.SizeBytes)) + for index := 0; index < session.TotalChunks; index++ { + record, exists := session.ReceivedChunks[index] + if !exists { + return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer has missing chunks") + } + payload = append(payload, record.Payload...) + } + if int64(len(payload)) != session.SizeBytes { + return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer size does not match metadata") + } + if checksum := validator.BytesChecksum(payload); checksum != session.Checksum { + return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer checksum does not match metadata") + } + + artifact.SizeBytes = session.SizeBytes + artifact.Checksum = session.Checksum + artifact.State = domain.ArtifactStateAvailable + artifact.UpdatedAt = stamp + if err := validator.ValidateArtifact(artifact); err != nil { + return domain.ArtifactTransferCompleteResult{}, err + } + if err := svc.store.Artifacts().Update(artifact); err != nil { + return domain.ArtifactTransferCompleteResult{}, err + } + session.Completed = true + session.UpdatedAt = stamp + svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) + return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil +} + +func (svc *CoreService) getArtifactTransferSession(transferID string) (domain.ArtifactTransferSession, error) { + session, exists := svc.artifactTransfers[transferID] + if !exists { + return domain.ArtifactTransferSession{}, repo.ErrNotFound + } + return domain.CopyArtifactTransferSession(session), nil +} + +func (svc *CoreService) findArtifactTransferByIdempotency(runEndpointID string, idempotencyKey string) (domain.ArtifactTransferSession, bool) { + transferIDs := make([]string, 0, len(svc.artifactTransfers)) + for transferID := range svc.artifactTransfers { + transferIDs = append(transferIDs, transferID) + } + sort.Strings(transferIDs) + for _, transferID := range transferIDs { + session := svc.artifactTransfers[transferID] + if session.RunEndpointID == runEndpointID && session.IdempotencyKey == idempotencyKey { + return domain.CopyArtifactTransferSession(session), true + } + } + return domain.ArtifactTransferSession{}, false +} + +func (svc *CoreService) validateArtifactTransferOwner(open domain.ArtifactTransferOpen) error { + switch open.OwnerKind { + case domain.ArtifactOwnerKindJob: + job, err := svc.store.Jobs().Get(open.OwnerID) + if err != nil { + return err + } + if job.RunEndpointID != open.RunEndpointID { + return validationError("artifact owner job must belong to runEndpointId") + } + case domain.ArtifactOwnerKindServerInstance: + instance, err := svc.store.ServerInstances().Get(open.OwnerID) + if err != nil { + return err + } + if instance.RunEndpointID != open.RunEndpointID { + return validationError("artifact owner server instance must belong to runEndpointId") + } + if instance.State == domain.ServerInstanceStateDeleted { + return validationError("artifact owner server instance must not be deleted") + } + default: + return validationError("ownerKind must be job or server-instance for run uploads") + } + return nil +} + +func validateArtifactMatchesTransferOpen(artifact domain.Artifact, open domain.ArtifactTransferOpen) error { + if artifact.OwnerKind != open.OwnerKind { + return validationError("artifact ownerKind must match transfer") + } + if artifact.OwnerID != open.OwnerID { + return validationError("artifact ownerId must match transfer") + } + if artifact.SizeBytes != open.SizeBytes { + return validationError("artifact sizeBytes must match transfer") + } + if artifact.Checksum != open.Checksum { + return validationError("artifact checksum must match transfer") + } + if artifact.State != domain.ArtifactStateUploading { + return validationError("artifact must be uploading") + } + return nil +} + +func validateArtifactTransferOpenMatchesSession(open domain.ArtifactTransferOpen, session domain.ArtifactTransferSession) error { + if session.ArtifactID != open.ArtifactID || session.Direction != open.Direction || session.OwnerKind != open.OwnerKind || session.OwnerID != open.OwnerID || session.SizeBytes != open.SizeBytes || session.ChunkSizeBytes != open.ChunkSizeBytes || session.Checksum != open.Checksum { + return validationError("artifact transfer idempotency key conflicts with existing transfer") + } + return nil +} + +func validateArtifactChunkMatchesSession(chunk domain.ArtifactChunkUpload, session domain.ArtifactTransferSession) error { + if session.Completed { + return validationError("artifact transfer is already complete") + } + if session.RunEndpointID != chunk.RunEndpointID { + return validationError("runEndpointId must match artifact transfer") + } + if session.ArtifactID != chunk.ArtifactID { + return validationError("artifactId must match artifact transfer") + } + if chunk.ChunkIndex >= session.TotalChunks { + return validationError("chunkIndex exceeds transfer chunk count") + } + expectedOffset := int64(chunk.ChunkIndex) * int64(session.ChunkSizeBytes) + if chunk.Offset != expectedOffset { + return validationError("offset must match chunk index") + } + expectedSize := expectedChunkSize(session, chunk.ChunkIndex) + if chunk.SizeBytes != expectedSize { + return validationError("sizeBytes must match expected chunk size") + } + return nil +} + +func validateArtifactTransferStatusMatchesSession(query domain.ArtifactTransferStatusQuery, session domain.ArtifactTransferSession) error { + if session.RunEndpointID != query.RunEndpointID { + return validationError("runEndpointId must match artifact transfer") + } + if session.ArtifactID != query.ArtifactID { + return validationError("artifactId must match artifact transfer") + } + return nil +} + +func validateArtifactCompleteMatchesSession(complete domain.ArtifactTransferComplete, session domain.ArtifactTransferSession) error { + if session.RunEndpointID != complete.RunEndpointID { + return validationError("runEndpointId must match artifact transfer") + } + if session.ArtifactID != complete.ArtifactID { + return validationError("artifactId must match artifact transfer") + } + if session.SizeBytes != complete.SizeBytes { + return validationError("sizeBytes must match artifact transfer") + } + if session.Checksum != complete.Checksum { + return validationError("checksum must match artifact transfer") + } + return nil +} + +func artifactTransferOpenResult(session domain.ArtifactTransferSession, artifact domain.Artifact, duplicate bool, stamp time.Time) domain.ArtifactTransferOpenResult { + return domain.ArtifactTransferOpenResult{ + Accepted: true, + TransferID: session.TransferID, + Direction: session.Direction, + Artifact: artifact, + TotalChunks: session.TotalChunks, + ChunkSizeBytes: session.ChunkSizeBytes, + ReceivedChunkIndexes: receivedArtifactChunkIndexes(session), + NextMissingChunkIndex: nextMissingArtifactChunkIndex(session), + Completed: session.Completed, + Duplicate: duplicate, + ServerTime: stamp, + } +} + +func artifactChunkUploadResult(session domain.ArtifactTransferSession, chunkIndex int, duplicate bool, stamp time.Time) domain.ArtifactChunkUploadResult { + return domain.ArtifactChunkUploadResult{ + Accepted: true, + TransferID: session.TransferID, + ArtifactID: session.ArtifactID, + ChunkIndex: chunkIndex, + ReceivedChunkIndexes: receivedArtifactChunkIndexes(session), + NextMissingChunkIndex: nextMissingArtifactChunkIndex(session), + Duplicate: duplicate, + ServerTime: stamp, + } +} + +func artifactTransferStatusResult(session domain.ArtifactTransferSession, stamp time.Time) domain.ArtifactTransferStatusResult { + return domain.ArtifactTransferStatusResult{ + Accepted: true, + TransferID: session.TransferID, + ArtifactID: session.ArtifactID, + Direction: session.Direction, + TotalChunks: session.TotalChunks, + ChunkSizeBytes: session.ChunkSizeBytes, + ReceivedChunkIndexes: receivedArtifactChunkIndexes(session), + NextMissingChunkIndex: nextMissingArtifactChunkIndex(session), + Completed: session.Completed, + ServerTime: stamp, + } +} + +func artifactTotalChunks(sizeBytes int64, chunkSizeBytes int) int { + return int((sizeBytes + int64(chunkSizeBytes) - 1) / int64(chunkSizeBytes)) +} + +func expectedChunkSize(session domain.ArtifactTransferSession, chunkIndex int) int { + offset := int64(chunkIndex) * int64(session.ChunkSizeBytes) + remaining := session.SizeBytes - offset + if remaining < int64(session.ChunkSizeBytes) { + return int(remaining) + } + return session.ChunkSizeBytes +} + +func receivedArtifactChunkIndexes(session domain.ArtifactTransferSession) []int { + indexes := make([]int, 0, len(session.ReceivedChunks)) + for index := range session.ReceivedChunks { + indexes = append(indexes, index) + } + sort.Ints(indexes) + return indexes +} + +func nextMissingArtifactChunkIndex(session domain.ArtifactTransferSession) int { + for index := 0; index < session.TotalChunks; index++ { + if _, exists := session.ReceivedChunks[index]; !exists { + return index + } + } + return session.TotalChunks +} diff --git a/platform/service/artifact_transfer_test.go b/platform/service/artifact_transfer_test.go new file mode 100644 index 0000000..eda2b73 --- /dev/null +++ b/platform/service/artifact_transfer_test.go @@ -0,0 +1,205 @@ +package service + +import ( + "strings" + "testing" + + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +func TestCoreServiceArtifactTransferWorkflow(t *testing.T) { + svc, sessionToken := newRegisteredArtifactTransferService(t) + payload := []byte("artifact payload for upload") + openRequest := validArtifactTransferOpen(sessionToken, payload, 8) + + opened, err := svc.OpenArtifactTransfer(openRequest) + if err != nil { + t.Fatalf("open artifact transfer: %v", err) + } + if !opened.Accepted || opened.TransferID == "" || opened.TotalChunks != 4 || opened.NextMissingChunkIndex != 0 { + t.Fatalf("unexpected open response: %+v", opened) + } + if opened.Artifact.State != domain.ArtifactStateUploading { + t.Fatalf("expected uploading artifact, got %+v", opened.Artifact) + } + + duplicateOpen, err := svc.OpenArtifactTransfer(openRequest) + if err != nil { + t.Fatalf("duplicate open artifact transfer: %v", err) + } + if !duplicateOpen.Duplicate || duplicateOpen.TransferID != opened.TransferID { + t.Fatalf("expected duplicate open response, got %+v", duplicateOpen) + } + + firstChunk := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8) + firstAck, err := svc.UploadArtifactChunk(firstChunk) + if err != nil { + t.Fatalf("upload first chunk: %v", err) + } + if !firstAck.Accepted || firstAck.NextMissingChunkIndex != 1 || len(firstAck.ReceivedChunkIndexes) != 1 || firstAck.ReceivedChunkIndexes[0] != 0 { + t.Fatalf("unexpected first chunk ack: %+v", firstAck) + } + + duplicateAck, err := svc.UploadArtifactChunk(firstChunk) + if err != nil { + t.Fatalf("upload duplicate chunk: %v", err) + } + if !duplicateAck.Duplicate { + t.Fatalf("expected duplicate chunk ack, got %+v", duplicateAck) + } + + status, err := svc.QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1"}) + if err != nil { + t.Fatalf("query artifact transfer status: %v", err) + } + if status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 { + t.Fatalf("unexpected status after first chunk: %+v", status) + } + + for index := 1; index < opened.TotalChunks; index++ { + if _, err := svc.UploadArtifactChunk(validArtifactChunk(sessionToken, opened.TransferID, payload, index, 8)); err != nil { + t.Fatalf("upload chunk %d: %v", index, err) + } + } + completed, err := svc.CompleteArtifactTransfer(domain.ArtifactTransferComplete{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))}) + if err != nil { + t.Fatalf("complete artifact transfer: %v", err) + } + if !completed.Accepted || !completed.Completed || completed.Artifact.State != domain.ArtifactStateAvailable { + t.Fatalf("unexpected complete response: %+v", completed) + } + artifact, err := svc.GetArtifact("artifact-1") + if err != nil { + t.Fatalf("get completed artifact: %v", err) + } + if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != validator.BytesChecksum(payload) { + t.Fatalf("expected available artifact, got %+v", artifact) + } +} + +func TestCoreServiceRejectsInvalidArtifactTransferChunks(t *testing.T) { + svc, sessionToken := newRegisteredArtifactTransferService(t) + payload := []byte("artifact payload") + opened, err := svc.OpenArtifactTransfer(validArtifactTransferOpen(sessionToken, payload, 8)) + if err != nil { + t.Fatalf("open artifact transfer: %v", err) + } + + badChecksum := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8) + badChecksum.Checksum = validator.BytesChecksum([]byte("different")) + _, err = svc.UploadArtifactChunk(badChecksum) + if err == nil || !strings.Contains(err.Error(), "checksum") { + t.Fatalf("expected checksum rejection, got %v", err) + } + + firstChunk := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8) + if _, err := svc.UploadArtifactChunk(firstChunk); err != nil { + t.Fatalf("upload first chunk: %v", err) + } + conflict := firstChunk + conflict.Payload = []byte("ARTIFACT") + conflict.Checksum = validator.BytesChecksum(conflict.Payload) + _, err = svc.UploadArtifactChunk(conflict) + if err == nil || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("expected conflicting chunk rejection, got %v", err) + } + + _, err = svc.CompleteArtifactTransfer(domain.ArtifactTransferComplete{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))}) + if err == nil || !strings.Contains(err.Error(), "missing chunks") { + t.Fatalf("expected missing chunk completion rejection, got %v", err) + } +} + +func TestCoreServiceRejectsInvalidArtifactTransferOwnerAndSession(t *testing.T) { + svc, sessionToken := newRegisteredArtifactTransferService(t) + payload := []byte("artifact payload") + invalidSession := validArtifactTransferOpen("stale-token", payload, 8) + _, err := svc.OpenArtifactTransfer(invalidSession) + if err == nil || !strings.Contains(err.Error(), "sessionToken") { + t.Fatalf("expected invalid session rejection, got %v", err) + } + + otherHello := validRunControlHello() + otherHello.RunEndpointID = "run-other" + otherHello.DisplayName = "Other Run" + otherHello.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.start", "logs.read"} + otherHello.CapabilityReport.Fingerprint = "cap-other" + other, err := svc.RegisterRunHello(otherHello) + if err != nil { + t.Fatalf("register other run: %v", err) + } + invalidOwner := validArtifactTransferOpen(other.SessionToken, payload, 8) + invalidOwner.RunEndpointID = "run-other" + invalidOwner.ArtifactID = "artifact-other" + invalidOwner.IdempotencyKey = "artifact-upload-other" + _, err = svc.OpenArtifactTransfer(invalidOwner) + if err == nil || !strings.Contains(err.Error(), "owner job") { + t.Fatalf("expected owner mismatch rejection, got %v", err) + } + + validOwner := validArtifactTransferOpen(sessionToken, payload, 8) + validOwner.OwnerKind = domain.ArtifactOwnerKindPlatform + _, err = svc.OpenArtifactTransfer(validOwner) + if err == nil || !strings.Contains(err.Error(), "ownerKind") { + t.Fatalf("expected owner kind rejection, got %v", err) + } +} + +func newRegisteredArtifactTransferService(t *testing.T) (*CoreService, string) { + t.Helper() + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + instance, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-1", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "SCUM #1", + }) + if err != nil { + t.Fatalf("create server instance: %v", err) + } + if _, err := svc.CreateJob(domain.Job{ID: "job-1", ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: "process.start", IdempotencyKey: "idem-start"}); err != nil { + t.Fatalf("create job: %v", err) + } + hello, err := svc.RegisterRunHello(validRunControlHello()) + if err != nil { + t.Fatalf("register run hello: %v", err) + } + return svc, hello.SessionToken +} + +func validArtifactTransferOpen(sessionToken string, payload []byte, chunkSize int) domain.ArtifactTransferOpen { + return domain.ArtifactTransferOpen{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + ArtifactID: "artifact-1", + Direction: domain.ArtifactTransferDirectionUpload, + OwnerKind: domain.ArtifactOwnerKindJob, + OwnerID: "job-1", + SizeBytes: int64(len(payload)), + ChunkSizeBytes: chunkSize, + Checksum: validator.BytesChecksum(payload), + IdempotencyKey: "artifact-upload-1", + } +} + +func validArtifactChunk(sessionToken string, transferID string, payload []byte, index int, chunkSize int) domain.ArtifactChunkUpload { + offset := index * chunkSize + end := offset + chunkSize + if end > len(payload) { + end = len(payload) + } + chunkPayload := payload[offset:end] + return domain.ArtifactChunkUpload{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + TransferID: transferID, + ArtifactID: "artifact-1", + ChunkIndex: index, + Offset: int64(offset), + SizeBytes: len(chunkPayload), + Checksum: validator.BytesChecksum(chunkPayload), + Payload: chunkPayload, + } +} diff --git a/platform/service/control.go b/platform/service/control.go new file mode 100644 index 0000000..0559b28 --- /dev/null +++ b/platform/service/control.go @@ -0,0 +1,121 @@ +package service + +import ( + "errors" + "fmt" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +const ( + defaultHeartbeatIntervalSeconds = 15 +) + +func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.RunControlHelloResult, error) { + hello = domain.CopyRunControlHello(hello) + if err := validator.ValidateRunControlHello(hello); err != nil { + return domain.RunControlHelloResult{}, err + } + + stamp := svc.now() + endpoint := domain.RunEndpoint{ + ID: hello.RunEndpointID, + DisplayName: hello.DisplayName, + Version: hello.Version, + Status: domain.RunEndpointStatusOnline, + Capabilities: domain.CopyStringSlice(hello.CapabilityReport.Capabilities), + Capacity: hello.Capacity, + LastHeartbeatAt: stamp, + } + if err := validator.ValidateRunEndpoint(endpoint); err != nil { + return domain.RunControlHelloResult{}, err + } + + svc.controlMu.Lock() + defer svc.controlMu.Unlock() + + if err := svc.upsertRunEndpoint(endpoint); err != nil { + return domain.RunControlHelloResult{}, err + } + sessionToken := svc.nextSessionToken(hello.RunEndpointID, stamp) + svc.runSessions[hello.RunEndpointID] = domain.RunControlSession{ + RunEndpointID: hello.RunEndpointID, + SessionToken: sessionToken, + CapabilityFingerprint: hello.CapabilityReport.Fingerprint, + HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds, + CreatedAt: stamp, + UpdatedAt: stamp, + } + + return domain.CopyRunControlHelloResult(domain.RunControlHelloResult{ + Accepted: true, + RunEndpointID: hello.RunEndpointID, + SessionToken: sessionToken, + ServerTime: stamp, + HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds, + FeatureFlags: []string{"control.hello", "control.heartbeat"}, + }), nil +} + +func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) { + heartbeat = domain.CopyRunControlHeartbeat(heartbeat) + if err := validator.ValidateRunControlHeartbeat(heartbeat); err != nil { + return domain.RunControlHeartbeatResult{}, err + } + + stamp := svc.now() + + svc.controlMu.Lock() + defer svc.controlMu.Unlock() + + session, exists := svc.runSessions[heartbeat.RunEndpointID] + if !exists || session.SessionToken != heartbeat.SessionToken { + return domain.RunControlHeartbeatResult{}, validationError("sessionToken is invalid") + } + + endpoint, err := svc.store.RunEndpoints().Get(heartbeat.RunEndpointID) + if err != nil { + return domain.RunControlHeartbeatResult{}, err + } + endpoint.Version = heartbeat.Version + endpoint.Status = heartbeat.Status + endpoint.Capacity = heartbeat.Capacity + endpoint.LastHeartbeatAt = stamp + if err := validator.ValidateRunEndpoint(endpoint); err != nil { + return domain.RunControlHeartbeatResult{}, err + } + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + return domain.RunControlHeartbeatResult{}, err + } + + refreshCapabilities := session.CapabilityFingerprint != heartbeat.CapabilityFingerprint + session.CapabilityFingerprint = heartbeat.CapabilityFingerprint + session.UpdatedAt = stamp + svc.runSessions[heartbeat.RunEndpointID] = session + + return domain.CopyRunControlHeartbeatResult(domain.RunControlHeartbeatResult{ + Accepted: true, + RunEndpointID: heartbeat.RunEndpointID, + NextHeartbeatSeconds: session.HeartbeatIntervalSeconds, + RefreshCapabilities: refreshCapabilities, + ServerTime: stamp, + }), nil +} + +func (svc *CoreService) upsertRunEndpoint(endpoint domain.RunEndpoint) error { + if _, err := svc.store.RunEndpoints().Get(endpoint.ID); err != nil { + if errors.Is(err, repo.ErrNotFound) { + return svc.store.RunEndpoints().Create(endpoint) + } + return err + } + return svc.store.RunEndpoints().Update(endpoint) +} + +func (svc *CoreService) nextSessionToken(runEndpointID string, stamp time.Time) string { + svc.runSessionSeq++ + return fmt.Sprintf("session:%s:%d:%d", runEndpointID, stamp.UnixNano(), svc.runSessionSeq) +} diff --git a/platform/service/control_test.go b/platform/service/control_test.go new file mode 100644 index 0000000..a50c497 --- /dev/null +++ b/platform/service/control_test.go @@ -0,0 +1,189 @@ +package service + +import ( + "errors" + "strings" + "testing" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +func TestCoreServiceRegistersNewRunControlSession(t *testing.T) { + svc := newTestCoreService() + + result, err := svc.RegisterRunHello(validRunControlHello()) + if err != nil { + t.Fatalf("register hello: %v", err) + } + if !result.Accepted || result.SessionToken == "" || result.HeartbeatIntervalSeconds <= 0 { + t.Fatalf("expected accepted hello response, got %+v", result) + } + + endpoint, err := svc.GetRunEndpoint("run-local") + if err != nil { + t.Fatalf("get registered endpoint: %v", err) + } + if endpoint.Status != domain.RunEndpointStatusOnline || !endpoint.LastHeartbeatAt.Equal(fixedTime) { + t.Fatalf("expected online endpoint with heartbeat time, got %+v", endpoint) + } + if len(endpoint.Capabilities) != 2 || endpoint.Capacity.MaxJobs != 4 { + t.Fatalf("expected capabilities and capacity, got %+v", endpoint) + } +} + +func TestCoreServiceReRegistersExistingRunEndpoint(t *testing.T) { + svc := newTestCoreService() + first, err := svc.RegisterRunHello(validRunControlHello()) + if err != nil { + t.Fatalf("register first hello: %v", err) + } + + hello := validRunControlHello() + hello.DisplayName = "Local Run Updated" + hello.Version = "0.2.0" + hello.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "jobs.claim"} + hello.CapabilityReport.Fingerprint = "cap-v2" + second, err := svc.RegisterRunHello(hello) + if err != nil { + t.Fatalf("register second hello: %v", err) + } + if second.SessionToken == first.SessionToken { + t.Fatalf("expected re-registration to issue a new token, got %q", second.SessionToken) + } + + endpoint, err := svc.GetRunEndpoint("run-local") + if err != nil { + t.Fatalf("get re-registered endpoint: %v", err) + } + if endpoint.DisplayName != "Local Run Updated" || endpoint.Version != "0.2.0" || len(endpoint.Capabilities) != 3 { + t.Fatalf("expected endpoint metadata update, got %+v", endpoint) + } +} + +func TestCoreServiceAcceptsRunHeartbeat(t *testing.T) { + svc := newTestCoreService() + hello, err := svc.RegisterRunHello(validRunControlHello()) + if err != nil { + t.Fatalf("register hello: %v", err) + } + + result, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Version: "0.1.1", + Status: domain.RunEndpointStatusDegraded, + CapabilityFingerprint: "cap-v1", + Capacity: domain.RunCapacity{MaxJobs: 4, RunningJobs: 2, QueuedJobs: 1}, + }) + if err != nil { + t.Fatalf("accept heartbeat: %v", err) + } + if !result.Accepted || result.RefreshCapabilities { + t.Fatalf("expected accepted heartbeat without refresh, got %+v", result) + } + + endpoint, err := svc.GetRunEndpoint("run-local") + if err != nil { + t.Fatalf("get heartbeat endpoint: %v", err) + } + if endpoint.Status != domain.RunEndpointStatusDegraded || endpoint.Version != "0.1.1" || endpoint.Capacity.RunningJobs != 2 { + t.Fatalf("expected heartbeat metadata update, got %+v", endpoint) + } +} + +func TestCoreServiceRejectsInvalidRunHeartbeatToken(t *testing.T) { + svc := newTestCoreService() + if _, err := svc.RegisterRunHello(validRunControlHello()); err != nil { + t.Fatalf("register hello: %v", err) + } + + _, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{ + RunEndpointID: "run-local", + SessionToken: "stale-token", + Version: "0.1.1", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-v1", + Capacity: domain.RunCapacity{MaxJobs: 4, RunningJobs: 3}, + }) + if err == nil || !strings.Contains(err.Error(), "sessionToken is invalid") { + t.Fatalf("expected invalid token rejection, got %v", err) + } + + endpoint, err := svc.GetRunEndpoint("run-local") + if err != nil { + t.Fatalf("get endpoint after rejected heartbeat: %v", err) + } + if endpoint.Capacity.RunningJobs != 0 || endpoint.Version != "0.1.0" { + t.Fatalf("heartbeat with invalid token must not update endpoint, got %+v", endpoint) + } +} + +func TestCoreServiceRejectsInvalidRunControlHello(t *testing.T) { + svc := newTestCoreService() + invalid := validRunControlHello() + invalid.RegistrationToken = "" + invalid.Capacity.RunningJobs = 8 + + _, err := svc.RegisterRunHello(invalid) + if err == nil || !strings.Contains(err.Error(), "registrationToken") || !strings.Contains(err.Error(), "runningJobs") { + t.Fatalf("expected validation errors, got %v", err) + } + if _, err := svc.GetRunEndpoint("run-local"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("invalid hello must not create endpoint, got %v", err) + } +} + +func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) { + svc := newTestCoreService() + hello, err := svc.RegisterRunHello(validRunControlHello()) + if err != nil { + t.Fatalf("register hello: %v", err) + } + + result, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Version: "0.1.0", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-v2", + Capacity: domain.RunCapacity{MaxJobs: 4}, + }) + if err != nil { + t.Fatalf("accept drift heartbeat: %v", err) + } + if !result.RefreshCapabilities { + t.Fatalf("expected capability refresh request, got %+v", result) + } + + result, err = svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Version: "0.1.0", + Status: domain.RunEndpointStatusOnline, + CapabilityFingerprint: "cap-v2", + Capacity: domain.RunCapacity{MaxJobs: 4}, + }) + if err != nil { + t.Fatalf("accept stable heartbeat: %v", err) + } + if result.RefreshCapabilities { + t.Fatalf("expected refreshed fingerprint to become known, got %+v", result) + } +} + +func validRunControlHello() domain.RunControlHello { + return domain.RunControlHello{ + RegistrationToken: "registration-token", + RunEndpointID: "run-local", + DisplayName: "Local Run", + Version: "0.1.0", + Status: domain.RunEndpointStatusOnline, + Platform: "darwin/arm64", + CapabilityReport: domain.RunCapabilityReport{ + Capabilities: []string{"control.hello", "control.heartbeat"}, + Fingerprint: "cap-v1", + }, + Capacity: domain.RunCapacity{MaxJobs: 4}, + } +} diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go new file mode 100644 index 0000000..c907ba6 --- /dev/null +++ b/platform/service/job_channel.go @@ -0,0 +1,408 @@ +package service + +import ( + "fmt" + "sort" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +const defaultJobPollSeconds = 2 + +func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClaimResult, error) { + claim = domain.CopyRunJobClaim(claim) + if err := validator.ValidateRunJobClaim(claim); err != nil { + return domain.RunJobClaimResult{}, err + } + if err := svc.validateRunSession(claim.RunEndpointID, claim.SessionToken); err != nil { + return domain.RunJobClaimResult{}, err + } + + stamp := svc.now() + svc.jobMu.Lock() + defer svc.jobMu.Unlock() + + jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID, State: domain.JobStateQueued}) + if err != nil { + return domain.RunJobClaimResult{}, err + } + job, ok := firstSupportedJob(jobs, claim.Capabilities) + if !ok { + return domain.RunJobClaimResult{ + Accepted: true, + RunEndpointID: claim.RunEndpointID, + NextPollSeconds: defaultJobPollSeconds, + ServerTime: stamp, + }, nil + } + + lease := svc.newJobLease(job.ID, claim.RunEndpointID, claim.SessionToken, stamp) + svc.jobLeases[job.ID] = lease + job.State = domain.JobStateAccepted + job.UpdatedAt = stamp + if err := validator.ValidateJob(job); err != nil { + return domain.RunJobClaimResult{}, err + } + if err := svc.store.Jobs().Update(job); err != nil { + return domain.RunJobClaimResult{}, err + } + assignment := assignmentFromJob(job, lease) + return domain.CopyRunJobClaimResult(domain.RunJobClaimResult{ + Accepted: true, + RunEndpointID: claim.RunEndpointID, + HasJob: true, + Job: &assignment, + NextPollSeconds: defaultJobPollSeconds, + ServerTime: stamp, + }), nil +} + +func (svc *CoreService) AckRunJob(ack domain.RunJobAck) (domain.RunJobAckResult, error) { + if err := validator.ValidateRunJobAck(ack); err != nil { + return domain.RunJobAckResult{}, err + } + if err := svc.validateRunSession(ack.RunEndpointID, ack.SessionToken); err != nil { + return domain.RunJobAckResult{}, err + } + + stamp := svc.now() + svc.jobMu.Lock() + defer svc.jobMu.Unlock() + + job, lease, err := svc.activeLeasedJob(ack.RunEndpointID, ack.SessionToken, ack.JobID, ack.LeaseToken, ack.Attempt) + if err != nil { + return domain.RunJobAckResult{}, err + } + if isTerminalJobState(job.State) { + return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil + } + if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { + return domain.RunJobAckResult{}, validationError("job is not claimable for ack") + } + job.State = domain.JobStateRunning + if strings.TrimSpace(ack.Message) != "" { + job.Progress.Message = ack.Message + } + job.UpdatedAt = stamp + if err := validator.ValidateJob(job); err != nil { + return domain.RunJobAckResult{}, err + } + if err := svc.store.Jobs().Update(job); err != nil { + return domain.RunJobAckResult{}, err + } + lease.UpdatedAt = stamp + svc.jobLeases[job.ID] = lease + return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil +} + +func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (domain.RunJobProgressResult, error) { + if err := validator.ValidateRunJobProgress(progress); err != nil { + return domain.RunJobProgressResult{}, err + } + if err := svc.validateRunSession(progress.RunEndpointID, progress.SessionToken); err != nil { + return domain.RunJobProgressResult{}, err + } + + stamp := svc.now() + svc.jobMu.Lock() + defer svc.jobMu.Unlock() + + job, lease, err := svc.activeLeasedJob(progress.RunEndpointID, progress.SessionToken, progress.JobID, progress.LeaseToken, progress.Attempt) + if err != nil { + return domain.RunJobProgressResult{}, err + } + if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { + return domain.RunJobProgressResult{}, validationError("job is not active") + } + job.State = domain.JobStateRunning + job.Progress = domain.JobProgress{Percent: progress.Progress.Percent, Message: progress.Progress.Message} + job.UpdatedAt = stamp + if err := validator.ValidateJob(job); err != nil { + return domain.RunJobProgressResult{}, err + } + if err := svc.store.Jobs().Update(job); err != nil { + return domain.RunJobProgressResult{}, err + } + lease.UpdatedAt = stamp + svc.jobLeases[job.ID] = lease + return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil +} + +func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJobResultResult, error) { + if err := validator.ValidateRunJobResult(result); err != nil { + return domain.RunJobResultResult{}, err + } + if err := svc.validateRunSession(result.RunEndpointID, result.SessionToken); err != nil { + return domain.RunJobResultResult{}, err + } + + stamp := svc.now() + svc.jobMu.Lock() + defer svc.jobMu.Unlock() + + job, lease, err := svc.activeLeasedJob(result.RunEndpointID, result.SessionToken, result.JobID, result.LeaseToken, result.Attempt) + if err != nil { + return domain.RunJobResultResult{}, err + } + fingerprint := terminalFingerprint(result) + if isTerminalJobState(job.State) { + if lease.TerminalFingerprint != "" && lease.TerminalFingerprint == fingerprint { + if err := svc.projectLifecycleJobResult(job, stamp); err != nil { + return domain.RunJobResultResult{}, err + } + return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil + } + return domain.RunJobResultResult{}, validationError("terminal result conflicts with existing job result") + } + + job.State = result.State + job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)} + job.ResultRef = result.ResultRef + job.UpdatedAt = stamp + if err := validator.ValidateJob(job); err != nil { + return domain.RunJobResultResult{}, err + } + if err := svc.store.Jobs().Update(job); err != nil { + return domain.RunJobResultResult{}, err + } + if err := svc.projectLifecycleJobResult(job, stamp); err != nil { + return domain.RunJobResultResult{}, err + } + lease.TerminalFingerprint = fingerprint + lease.UpdatedAt = stamp + svc.jobLeases[job.ID] = lease + return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil +} + +func (svc *CoreService) RequestRunJobCancel(request domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) { + if err := validator.ValidateRunJobCancelRequest(request); err != nil { + return domain.RunJobCancelRequestResult{}, err + } + + stamp := svc.now() + svc.jobMu.Lock() + defer svc.jobMu.Unlock() + + job, err := svc.store.Jobs().Get(request.JobID) + if err != nil { + return domain.RunJobCancelRequestResult{}, err + } + if !isActiveJobState(job.State) { + return domain.RunJobCancelRequestResult{}, validationError("job is not active") + } + lease, exists := svc.jobLeases[job.ID] + if !exists { + return domain.RunJobCancelRequestResult{}, validationError("job lease is missing") + } + lease.CancelReason = request.Reason + lease.CancelRequestedAt = stamp + lease.UpdatedAt = stamp + svc.jobLeases[job.ID] = lease + return domain.RunJobCancelRequestResult{Accepted: true, JobID: job.ID, Reason: request.Reason, RequestedAt: stamp}, nil +} + +func (svc *CoreService) PollRunJobCancel(poll domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) { + if err := validator.ValidateRunJobCancelPoll(poll); err != nil { + return domain.RunJobCancelPollResult{}, err + } + if err := svc.validateRunSession(poll.RunEndpointID, poll.SessionToken); err != nil { + return domain.RunJobCancelPollResult{}, err + } + + stamp := svc.now() + svc.jobMu.Lock() + defer svc.jobMu.Unlock() + + lease, ok := svc.findCancelLease(poll) + if !ok { + return domain.RunJobCancelPollResult{Accepted: true, RunEndpointID: poll.RunEndpointID, ServerTime: stamp}, nil + } + return domain.RunJobCancelPollResult{ + Accepted: true, + RunEndpointID: poll.RunEndpointID, + HasCancel: true, + JobID: lease.JobID, + Reason: lease.CancelReason, + RequestedAt: lease.CancelRequestedAt, + ServerTime: stamp, + }, nil +} + +func (svc *CoreService) ReconcileRunJobs(reconcile domain.RunJobReconcile) (domain.RunJobReconcileResult, error) { + reconcile = domain.CopyRunJobReconcile(reconcile) + if err := validator.ValidateRunJobReconcile(reconcile); err != nil { + return domain.RunJobReconcileResult{}, err + } + if err := svc.validateRunSession(reconcile.RunEndpointID, reconcile.SessionToken); err != nil { + return domain.RunJobReconcileResult{}, err + } + + stamp := svc.now() + svc.jobMu.Lock() + defer svc.jobMu.Unlock() + + jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: reconcile.RunEndpointID}) + if err != nil { + return domain.RunJobReconcileResult{}, err + } + activeByID := map[string]domain.Job{} + for _, job := range jobs { + if isActiveJobState(job.State) { + activeByID[job.ID] = job + } + } + + activeJobs := make([]domain.RunJobAssignment, 0, len(activeByID)) + ids := make([]string, 0, len(activeByID)) + for id := range activeByID { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + job := activeByID[id] + lease := svc.jobLeases[job.ID] + if lease.JobID == "" || lease.SessionToken != reconcile.SessionToken { + lease = svc.newJobLease(job.ID, reconcile.RunEndpointID, reconcile.SessionToken, stamp) + } else { + lease.UpdatedAt = stamp + } + svc.jobLeases[job.ID] = lease + activeJobs = append(activeJobs, assignmentFromJob(job, lease)) + } + + unknown := make([]string, 0) + for _, reportedID := range reconcile.ActiveJobIDs { + if _, exists := activeByID[reportedID]; !exists { + unknown = append(unknown, reportedID) + } + } + sort.Strings(unknown) + return domain.CopyRunJobReconcileResult(domain.RunJobReconcileResult{ + Accepted: true, + RunEndpointID: reconcile.RunEndpointID, + ActiveJobs: activeJobs, + UnknownJobIDs: unknown, + ServerTime: stamp, + }), nil +} + +func (svc *CoreService) validateRunSession(runEndpointID string, sessionToken string) error { + svc.controlMu.Lock() + defer svc.controlMu.Unlock() + session, exists := svc.runSessions[runEndpointID] + if !exists || session.SessionToken != sessionToken { + return validationError("sessionToken is invalid") + } + return nil +} + +func (svc *CoreService) newJobLease(jobID string, runEndpointID string, sessionToken string, stamp time.Time) domain.RunJobLease { + svc.jobLeaseSeq++ + return domain.RunJobLease{ + JobID: jobID, + RunEndpointID: runEndpointID, + SessionToken: sessionToken, + LeaseToken: fmt.Sprintf("job-lease:%s:%d:%d", jobID, stamp.UnixNano(), svc.jobLeaseSeq), + Attempt: int(svc.jobLeaseSeq), + CreatedAt: stamp, + UpdatedAt: stamp, + } +} + +func (svc *CoreService) activeLeasedJob(runEndpointID string, sessionToken string, jobID string, leaseToken string, attempt int) (domain.Job, domain.RunJobLease, error) { + job, err := svc.store.Jobs().Get(jobID) + if err != nil { + return domain.Job{}, domain.RunJobLease{}, err + } + if job.RunEndpointID != runEndpointID { + return domain.Job{}, domain.RunJobLease{}, validationError("job runEndpointId does not match request") + } + lease, exists := svc.jobLeases[jobID] + if !exists || lease.SessionToken != sessionToken || lease.LeaseToken != leaseToken || lease.Attempt != attempt { + return domain.Job{}, domain.RunJobLease{}, validationError("leaseToken is invalid") + } + return job, lease, nil +} + +func (svc *CoreService) findCancelLease(poll domain.RunJobCancelPoll) (domain.RunJobLease, bool) { + if poll.JobID != "" { + lease, exists := svc.jobLeases[poll.JobID] + if !exists || lease.RunEndpointID != poll.RunEndpointID || lease.SessionToken != poll.SessionToken { + return domain.RunJobLease{}, false + } + if poll.LeaseToken != "" && lease.LeaseToken != poll.LeaseToken { + return domain.RunJobLease{}, false + } + return lease, lease.CancelReason != "" + } + + ids := make([]string, 0, len(svc.jobLeases)) + for id := range svc.jobLeases { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + lease := svc.jobLeases[id] + if lease.RunEndpointID == poll.RunEndpointID && lease.SessionToken == poll.SessionToken && lease.CancelReason != "" { + return lease, true + } + } + return domain.RunJobLease{}, false +} + +func firstSupportedJob(jobs []domain.Job, capabilities []string) (domain.Job, bool) { + capabilitySet := map[string]struct{}{} + for _, capability := range capabilities { + capabilitySet[capability] = struct{}{} + } + for _, job := range jobs { + if len(capabilitySet) == 0 { + return job, true + } + if _, supported := capabilitySet[job.Capability]; supported { + return job, true + } + } + return domain.Job{}, false +} + +func assignmentFromJob(job domain.Job, lease domain.RunJobLease) domain.RunJobAssignment { + return domain.RunJobAssignment{ + JobID: job.ID, + ServerInstanceID: job.ServerInstanceID, + RunEndpointID: job.RunEndpointID, + Capability: job.Capability, + TargetKey: job.TargetKey, + InputRef: job.InputRef, + IdempotencyKey: job.IdempotencyKey, + State: job.State, + Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message}, + ResultRef: job.ResultRef, + LeaseToken: lease.LeaseToken, + Attempt: lease.Attempt, + CreatedAt: job.CreatedAt, + UpdatedAt: job.UpdatedAt, + } +} + +func terminalFingerprint(result domain.RunJobResult) string { + return fmt.Sprintf("%s|%d|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message) +} + +func terminalMessage(result domain.RunJobResult) string { + if strings.TrimSpace(result.Message) != "" { + return result.Message + } + return result.Progress.Message +} + +func isActiveJobState(state domain.JobState) bool { + return state == domain.JobStateAccepted || state == domain.JobStateRunning +} + +func isTerminalJobState(state domain.JobState) bool { + return state == domain.JobStateSucceeded || state == domain.JobStateFailed || state == domain.JobStateCancelled +} diff --git a/platform/service/job_channel_test.go b/platform/service/job_channel_test.go new file mode 100644 index 0000000..93a1002 --- /dev/null +++ b/platform/service/job_channel_test.go @@ -0,0 +1,263 @@ +package service + +import ( + "strings" + "testing" + + "browser.local/platform/domain" +) + +func TestCoreServiceRunJobLifecycle(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + createQueuedRunJob(t, svc, "job-1", "idem-1") + + claim, err := svc.ClaimRunJob(domain.RunJobClaim{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + Capabilities: []string{"process.start"}, + Capacity: domain.RunCapacity{MaxJobs: 4}, + }) + if err != nil { + t.Fatalf("claim job: %v", err) + } + if !claim.Accepted || !claim.HasJob || claim.Job.JobID != "job-1" || claim.Job.State != domain.JobStateAccepted { + t.Fatalf("expected claimed job, got %+v", claim) + } + + ack, err := svc.AckRunJob(domain.RunJobAck{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + Message: "starting", + }) + if err != nil { + t.Fatalf("ack job: %v", err) + } + if ack.Job.State != domain.JobStateRunning || ack.Job.Progress.Message != "starting" { + t.Fatalf("expected running ack job, got %+v", ack) + } + + progress, err := svc.UpdateRunJobProgress(domain.RunJobProgress{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + Progress: domain.RunJobProgressReport{Percent: 50, Message: "half"}, + }) + if err != nil { + t.Fatalf("progress job: %v", err) + } + if progress.Job.Progress.Percent != 50 || progress.Job.Progress.Message != "half" { + t.Fatalf("expected progress update, got %+v", progress) + } + + result, err := svc.CompleteRunJob(domain.RunJobResult{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + State: domain.JobStateSucceeded, + Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"}, + ResultRef: "artifact://jobs/job-1/result", + Message: "done", + }) + if err != nil { + t.Fatalf("complete job: %v", err) + } + if result.Job.State != domain.JobStateSucceeded || result.Job.ResultRef != "artifact://jobs/job-1/result" { + t.Fatalf("expected succeeded result, got %+v", result) + } + + stored, err := svc.GetJob("job-1") + if err != nil { + t.Fatalf("get completed job: %v", err) + } + if stored.State != domain.JobStateSucceeded || stored.Progress.Percent != 100 { + t.Fatalf("expected stored terminal job, got %+v", stored) + } +} + +func TestCoreServiceRunJobClaimNoJob(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + + claim, err := svc.ClaimRunJob(domain.RunJobClaim{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + Capabilities: []string{"process.start"}, + Capacity: domain.RunCapacity{MaxJobs: 4}, + }) + if err != nil { + t.Fatalf("claim no job: %v", err) + } + if !claim.Accepted || claim.HasJob || claim.Job != nil || claim.NextPollSeconds <= 0 { + t.Fatalf("expected empty claim response, got %+v", claim) + } +} + +func TestCoreServiceRunJobRejectsInvalidSessionAndLease(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + createQueuedRunJob(t, svc, "job-1", "idem-1") + + _, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: "stale", Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err == nil || !strings.Contains(err.Error(), "sessionToken") { + t.Fatalf("expected invalid session rejection, got %v", err) + } + + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil { + t.Fatalf("claim job: %v", err) + } + _, err = svc.UpdateRunJobProgress(domain.RunJobProgress{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + JobID: claim.Job.JobID, + LeaseToken: "bad-lease", + Attempt: claim.Job.Attempt, + Progress: domain.RunJobProgressReport{Percent: 10}, + }) + if err == nil || !strings.Contains(err.Error(), "leaseToken") { + t.Fatalf("expected invalid lease rejection, got %v", err) + } +} + +func TestCoreServiceRunJobRejectsInvalidProgress(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + createQueuedRunJob(t, svc, "job-1", "idem-1") + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil { + t.Fatalf("claim job: %v", err) + } + + _, err = svc.UpdateRunJobProgress(domain.RunJobProgress{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + Progress: domain.RunJobProgressReport{Percent: 101}, + }) + if err == nil || !strings.Contains(err.Error(), "progress.percent") { + t.Fatalf("expected invalid progress rejection, got %v", err) + } +} + +func TestCoreServiceRunJobCancelPoll(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + createQueuedRunJob(t, svc, "job-1", "idem-1") + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil { + t.Fatalf("claim job: %v", err) + } + if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err != nil { + t.Fatalf("ack job: %v", err) + } + + cancel, err := svc.RequestRunJobCancel(domain.RunJobCancelRequest{JobID: "job-1", Reason: "operator requested"}) + if err != nil { + t.Fatalf("request cancel: %v", err) + } + if !cancel.Accepted || cancel.Reason != "operator requested" { + t.Fatalf("unexpected cancel request: %+v", cancel) + } + + poll, err := svc.PollRunJobCancel(domain.RunJobCancelPoll{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: "job-1", LeaseToken: claim.Job.LeaseToken}) + if err != nil { + t.Fatalf("poll cancel: %v", err) + } + if !poll.HasCancel || poll.JobID != "job-1" || poll.Reason != "operator requested" { + t.Fatalf("expected cancel poll result, got %+v", poll) + } +} + +func TestCoreServiceRunJobTerminalResultIsIdempotent(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + createQueuedRunJob(t, svc, "job-1", "idem-1") + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil { + t.Fatalf("claim job: %v", err) + } + + request := domain.RunJobResult{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + State: domain.JobStateSucceeded, + Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"}, + ResultRef: "artifact://jobs/job-1/result", + Message: "done", + } + first, err := svc.CompleteRunJob(request) + if err != nil { + t.Fatalf("complete first: %v", err) + } + second, err := svc.CompleteRunJob(request) + if err != nil { + t.Fatalf("complete duplicate: %v", err) + } + if second.Job.State != first.Job.State || second.Job.ResultRef != first.Job.ResultRef { + t.Fatalf("expected duplicate result to be idempotent, got %+v %+v", first, second) + } + + request.State = domain.JobStateFailed + request.Message = "failed" + _, err = svc.CompleteRunJob(request) + if err == nil || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("expected conflicting result rejection, got %v", err) + } +} + +func TestCoreServiceRunJobReconcile(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + createQueuedRunJob(t, svc, "job-1", "idem-1") + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil { + t.Fatalf("claim job: %v", err) + } + if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err != nil { + t.Fatalf("ack job: %v", err) + } + + reconcile, err := svc.ReconcileRunJobs(domain.RunJobReconcile{RunEndpointID: "run-local", SessionToken: sessionToken, ActiveJobIDs: []string{"job-1", "local-only"}}) + if err != nil { + t.Fatalf("reconcile jobs: %v", err) + } + if len(reconcile.ActiveJobs) != 1 || reconcile.ActiveJobs[0].JobID != "job-1" { + t.Fatalf("expected platform active job, got %+v", reconcile) + } + if len(reconcile.UnknownJobIDs) != 1 || reconcile.UnknownJobIDs[0] != "local-only" { + t.Fatalf("expected unknown local job, got %+v", reconcile.UnknownJobIDs) + } +} + +func newRegisteredRunJobService(t *testing.T) (*CoreService, string) { + t.Helper() + svc := newTestCoreService() + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start") + helloRequest.CapabilityReport.Fingerprint = "cap-jobs" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register run hello: %v", err) + } + return svc, hello.SessionToken +} + +func createQueuedRunJob(t *testing.T, svc *CoreService, id string, idempotencyKey string) domain.Job { + t.Helper() + job, err := svc.CreateJob(domain.Job{ + ID: id, + RunEndpointID: "run-local", + Capability: "process.start", + IdempotencyKey: idempotencyKey, + }) + if err != nil { + t.Fatalf("create queued job: %v", err) + } + return job +} diff --git a/platform/service/log_body_store.go b/platform/service/log_body_store.go new file mode 100644 index 0000000..a1abde3 --- /dev/null +++ b/platform/service/log_body_store.go @@ -0,0 +1,251 @@ +package service + +import ( + "bufio" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +type LogBodyStore interface { + AppendBatch(streamID string, record domain.LogBatchRecord) error + GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) + Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) +} + +type MemoryLogBodyStore struct { + mu sync.Mutex + entries map[string][]domain.LogEntry + batches map[string]map[uint64]domain.LogBatchRecord +} + +func NewMemoryLogBodyStore() *MemoryLogBodyStore { + return &MemoryLogBodyStore{ + entries: map[string][]domain.LogEntry{}, + batches: map[string]map[uint64]domain.LogBatchRecord{}, + } +} + +func (store *MemoryLogBodyStore) AppendBatch(streamID string, record domain.LogBatchRecord) error { + store.mu.Lock() + defer store.mu.Unlock() + + records := store.batches[streamID] + if records == nil { + records = map[uint64]domain.LogBatchRecord{} + store.batches[streamID] = records + } + if existing, exists := records[record.FirstSeq]; exists { + if existing.LastSeq == record.LastSeq && existing.Checksum == record.Checksum { + return nil + } + return validationError("log batch conflicts with acknowledged range") + } + records[record.FirstSeq] = domain.CopyLogBatchRecord(record) + store.entries[streamID] = append(store.entries[streamID], domain.CopyLogEntries(record.Entries)...) + return nil +} + +func (store *MemoryLogBodyStore) GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) { + store.mu.Lock() + defer store.mu.Unlock() + + record, exists := store.batches[streamID][firstSeq] + if !exists { + return domain.LogBatchRecord{}, false, nil + } + return domain.CopyLogBatchRecord(record), true, nil +} + +func (store *MemoryLogBodyStore) Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) { + store.mu.Lock() + defer store.mu.Unlock() + + entries := domain.CopyLogEntries(store.entries[streamID]) + sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq }) + selected := make([]domain.LogEntry, 0, limit) + nextSeq := afterSeq + for _, entry := range entries { + if entry.Seq <= afterSeq { + continue + } + if len(selected) >= limit { + break + } + selected = append(selected, entry) + nextSeq = entry.Seq + } + return selected, nextSeq, nil +} + +type FileLogBodyStore struct { + mu sync.Mutex + rootDir string + memory *MemoryLogBodyStore +} + +func NewFileLogBodyStore(rootDir string) (*FileLogBodyStore, error) { + rootDir = strings.TrimSpace(rootDir) + if rootDir == "" { + return nil, fmt.Errorf("log directory is required") + } + store := &FileLogBodyStore{ + rootDir: rootDir, + memory: NewMemoryLogBodyStore(), + } + if err := os.MkdirAll(rootDir, 0o755); err != nil { + return nil, fmt.Errorf("create log directory: %w", err) + } + if err := store.load(); err != nil { + return nil, err + } + return store, nil +} + +func (store *FileLogBodyStore) RootDir() string { + return store.rootDir +} + +func (store *FileLogBodyStore) AppendBatch(streamID string, record domain.LogBatchRecord) error { + store.mu.Lock() + defer store.mu.Unlock() + + if _, exists, err := store.memory.GetBatch(streamID, record.FirstSeq); err != nil { + return err + } else if exists { + return store.memory.AppendBatch(streamID, record) + } + streamDir := store.streamDir(streamID) + if err := os.MkdirAll(streamDir, 0o755); err != nil { + return fmt.Errorf("create log stream directory: %w", err) + } + segmentPath := store.segmentPath(streamID, record.FirstSeq) + if _, err := os.Stat(segmentPath); err == nil { + return validationError("log batch conflicts with acknowledged range") + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat log segment: %w", err) + } + tmpPath := segmentPath + ".tmp" + file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open log segment: %w", err) + } + encoder := json.NewEncoder(file) + for _, entry := range record.Entries { + if err := encoder.Encode(domain.CopyLogEntry(entry)); err != nil { + _ = file.Close() + return fmt.Errorf("write log segment: %w", err) + } + } + if err := file.Close(); err != nil { + return fmt.Errorf("close log segment: %w", err) + } + if err := os.Rename(tmpPath, segmentPath); err != nil { + return fmt.Errorf("replace log segment: %w", err) + } + return store.memory.AppendBatch(streamID, record) +} + +func (store *FileLogBodyStore) GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) { + return store.memory.GetBatch(streamID, firstSeq) +} + +func (store *FileLogBodyStore) Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) { + return store.memory.Query(streamID, afterSeq, limit) +} + +func (store *FileLogBodyStore) load() error { + entries, err := os.ReadDir(store.rootDir) + if err != nil { + return fmt.Errorf("read log root: %w", err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + streamID, err := url.PathUnescape(entry.Name()) + if err != nil { + return fmt.Errorf("decode log stream directory: %w", err) + } + if err := store.loadStream(streamID, filepath.Join(store.rootDir, entry.Name())); err != nil { + return err + } + } + return nil +} + +func (store *FileLogBodyStore) loadStream(streamID string, streamDir string) error { + segments, err := os.ReadDir(streamDir) + if err != nil { + return fmt.Errorf("read log stream directory: %w", err) + } + sort.SliceStable(segments, func(i, j int) bool { return segments[i].Name() < segments[j].Name() }) + for _, segment := range segments { + if segment.IsDir() || !strings.HasPrefix(segment.Name(), "segment-") || !strings.HasSuffix(segment.Name(), ".jsonl") { + continue + } + segmentPath := filepath.Join(streamDir, segment.Name()) + record, err := readLogSegment(segmentPath) + if err != nil { + return err + } + if len(record.Entries) == 0 { + continue + } + if err := store.memory.AppendBatch(streamID, record); err != nil { + return err + } + } + return nil +} + +func (store *FileLogBodyStore) streamDir(streamID string) string { + return filepath.Join(store.rootDir, url.PathEscape(streamID)) +} + +func (store *FileLogBodyStore) segmentPath(streamID string, firstSeq uint64) string { + return filepath.Join(store.streamDir(streamID), fmt.Sprintf("segment-%020d.jsonl", firstSeq)) +} + +func readLogSegment(path string) (domain.LogBatchRecord, error) { + file, err := os.Open(path) + if err != nil { + return domain.LogBatchRecord{}, fmt.Errorf("open log segment: %w", err) + } + defer file.Close() + + entries := []domain.LogEntry{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var entry domain.LogEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + return domain.LogBatchRecord{}, fmt.Errorf("decode log segment %s: %w", filepath.Base(path), err) + } + entries = append(entries, domain.CopyLogEntry(entry)) + } + if err := scanner.Err(); err != nil { + return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), err) + } + sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq }) + if len(entries) == 0 { + return domain.LogBatchRecord{}, nil + } + checksum, err := validator.LogEntriesChecksum(entries) + if err != nil { + return domain.LogBatchRecord{}, fmt.Errorf("checksum log segment %s: %w", filepath.Base(path), err) + } + return domain.LogBatchRecord{ + Checksum: checksum, + FirstSeq: entries[0].Seq, + LastSeq: entries[len(entries)-1].Seq, + Entries: entries, + }, nil +} diff --git a/platform/service/log_ingest.go b/platform/service/log_ingest.go new file mode 100644 index 0000000..2826d5f --- /dev/null +++ b/platform/service/log_ingest.go @@ -0,0 +1,113 @@ +package service + +import ( + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +const defaultLogQueryLimit = 100 + +func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) { + batch = domain.CopyLogBatchIngest(batch) + if err := validator.ValidateLogBatchIngest(batch); err != nil { + return domain.LogBatchIngestResult{}, err + } + if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil { + return domain.LogBatchIngestResult{}, err + } + + stamp := svc.now() + stream, err := svc.store.LogStreams().Get(batch.LogStreamID) + if err != nil { + return domain.LogBatchIngestResult{}, err + } + if err := validateLogBatchStream(batch, stream); err != nil { + return domain.LogBatchIngestResult{}, err + } + + if batch.LastSeq <= stream.LatestSeq { + record, exists, err := svc.logStore.GetBatch(batch.LogStreamID, batch.FirstSeq) + if err != nil { + return domain.LogBatchIngestResult{}, err + } + if exists && record.LastSeq == batch.LastSeq && record.Checksum == batch.Checksum { + return domain.LogBatchIngestResult{ + Accepted: true, + LogStreamID: batch.LogStreamID, + AcceptedFrom: batch.FirstSeq, + AcceptedTo: batch.LastSeq, + LatestSeq: stream.LatestSeq, + Duplicate: true, + ServerTime: stamp, + }, nil + } + return domain.LogBatchIngestResult{}, validationError("log batch conflicts with acknowledged range") + } + if batch.FirstSeq != stream.LatestSeq+1 { + return domain.LogBatchIngestResult{}, validationError("log batch firstSeq must follow latest acknowledged sequence") + } + + record := domain.CopyLogBatchRecord(domain.LogBatchRecord{ + Checksum: batch.Checksum, + FirstSeq: batch.FirstSeq, + LastSeq: batch.LastSeq, + Entries: batch.Entries, + }) + if err := svc.logStore.AppendBatch(batch.LogStreamID, record); err != nil { + return domain.LogBatchIngestResult{}, err + } + stream.LatestSeq = batch.LastSeq + stream.UpdatedAt = stamp + if err := svc.store.LogStreams().Update(stream); err != nil { + return domain.LogBatchIngestResult{}, err + } + return domain.LogBatchIngestResult{ + Accepted: true, + LogStreamID: batch.LogStreamID, + AcceptedFrom: batch.FirstSeq, + AcceptedTo: batch.LastSeq, + LatestSeq: stream.LatestSeq, + ServerTime: stamp, + }, nil +} + +func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) { + if err := validator.ValidateLogStreamCursorQuery(query); err != nil { + return domain.LogStreamCursorResult{}, err + } + stream, err := svc.store.LogStreams().Get(query.LogStreamID) + if err != nil { + return domain.LogStreamCursorResult{}, err + } + limit := query.Limit + if limit == 0 { + limit = defaultLogQueryLimit + } + + selected, nextSeq, err := svc.logStore.Query(query.LogStreamID, query.AfterSeq, limit) + if err != nil { + return domain.LogStreamCursorResult{}, err + } + return domain.CopyLogStreamCursorResult(domain.LogStreamCursorResult{ + LogStreamID: query.LogStreamID, + Entries: selected, + NextSeq: nextSeq, + LatestSeq: stream.LatestSeq, + }), nil +} + +func validateLogBatchStream(batch domain.LogBatchIngest, stream domain.LogStream) error { + if stream.ID != batch.LogStreamID { + return validationError("logStreamId must match stream") + } + if stream.ServerInstanceID != batch.ServerInstanceID { + return validationError("serverInstanceId must match stream") + } + if stream.StreamKey != batch.StreamKey { + return validationError("streamKey must match stream") + } + if stream.Source != batch.Source { + return validationError("source must match stream") + } + return nil +} diff --git a/platform/service/log_ingest_test.go b/platform/service/log_ingest_test.go new file mode 100644 index 0000000..6c69170 --- /dev/null +++ b/platform/service/log_ingest_test.go @@ -0,0 +1,198 @@ +package service + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +func TestCoreServiceIngestsLogBatchAndQueriesCursor(t *testing.T) { + svc, sessionToken := newRegisteredLogIngestService(t) + createLogStreamFixture(t, svc) + batch := validLogBatch(t, sessionToken, 1, 2) + + ack, err := svc.IngestLogBatch(batch) + if err != nil { + t.Fatalf("ingest log batch: %v", err) + } + if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 2 || ack.LatestSeq != 2 { + t.Fatalf("unexpected ack: %+v", ack) + } + stream, err := svc.GetLogStream("log-1") + if err != nil { + t.Fatalf("get log stream: %v", err) + } + if stream.LatestSeq != 2 { + t.Fatalf("expected latest seq 2, got %+v", stream) + } + + query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: "log-1", AfterSeq: 1, Limit: 10}) + if err != nil { + t.Fatalf("query log stream: %v", err) + } + if len(query.Entries) != 1 || query.Entries[0].Seq != 2 || query.NextSeq != 2 || query.LatestSeq != 2 { + t.Fatalf("unexpected query result: %+v", query) + } +} + +func TestCoreServiceLogBatchDuplicateAck(t *testing.T) { + svc, sessionToken := newRegisteredLogIngestService(t) + createLogStreamFixture(t, svc) + batch := validLogBatch(t, sessionToken, 1, 2) + + if _, err := svc.IngestLogBatch(batch); err != nil { + t.Fatalf("ingest first batch: %v", err) + } + ack, err := svc.IngestLogBatch(batch) + if err != nil { + t.Fatalf("ingest duplicate batch: %v", err) + } + if !ack.Duplicate || ack.LatestSeq != 2 { + t.Fatalf("expected duplicate ack, got %+v", ack) + } +} + +func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) { + svc, sessionToken := newRegisteredLogIngestService(t) + createLogStreamFixture(t, svc) + gap := validLogBatch(t, sessionToken, 2, 2) + + _, err := svc.IngestLogBatch(gap) + if err == nil || !strings.Contains(err.Error(), "firstSeq") { + t.Fatalf("expected out-of-order rejection, got %v", err) + } + + batch := validLogBatch(t, sessionToken, 1, 2) + if _, err := svc.IngestLogBatch(batch); err != nil { + t.Fatalf("ingest first batch: %v", err) + } + conflict := batch + conflict.Entries[0].Line = "changed" + conflict.Checksum = checksumForEntries(t, conflict.Entries) + _, err = svc.IngestLogBatch(conflict) + if err == nil || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("expected conflicting duplicate rejection, got %v", err) + } +} + +func TestCoreServiceRejectsMissingLogStream(t *testing.T) { + svc, sessionToken := newRegisteredLogIngestService(t) + _, err := svc.IngestLogBatch(validLogBatch(t, sessionToken, 1, 1)) + if err == nil { + t.Fatal("expected missing stream error") + } +} + +func TestFileLogBodyStoreReloadsBatchesAndCursorEntries(t *testing.T) { + rootDir := filepath.Join(t.TempDir(), "logs") + store, err := NewFileLogBodyStore(rootDir) + if err != nil { + t.Fatalf("create file log store: %v", err) + } + entries := []domain.LogEntry{ + {Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "one"}, + {Seq: 2, Timestamp: time.Date(2026, 7, 3, 12, 0, 2, 0, time.UTC), Level: "warn", Line: "two"}, + } + record := domain.LogBatchRecord{ + Checksum: checksumForEntries(t, entries), + FirstSeq: 1, + LastSeq: 2, + Entries: entries, + } + if err := store.AppendBatch("log-1", record); err != nil { + t.Fatalf("append batch: %v", err) + } + + reloaded, err := NewFileLogBodyStore(rootDir) + if err != nil { + t.Fatalf("reload file log store: %v", err) + } + got, exists, err := reloaded.GetBatch("log-1", 1) + if err != nil { + t.Fatalf("get reloaded batch: %v", err) + } + if !exists || got.Checksum != record.Checksum || got.LastSeq != 2 { + t.Fatalf("unexpected reloaded batch: exists=%v record=%+v", exists, got) + } + selected, nextSeq, err := reloaded.Query("log-1", 1, 10) + if err != nil { + t.Fatalf("query reloaded entries: %v", err) + } + if len(selected) != 1 || selected[0].Seq != 2 || selected[0].Line != "two" || nextSeq != 2 { + t.Fatalf("unexpected reloaded query: entries=%+v next=%d", selected, nextSeq) + } +} + +func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) { + t.Helper() + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + if _, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-1", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "SCUM #1", + }); err != nil { + t.Fatalf("create server instance: %v", err) + } + hello, err := svc.RegisterRunHello(validRunControlHello()) + if err != nil { + t.Fatalf("register run hello: %v", err) + } + return svc, hello.SessionToken +} + +func createLogStreamFixture(t *testing.T, svc *CoreService) domain.LogStream { + t.Helper() + stream, err := svc.CreateLogStream(domain.LogStream{ + ID: "log-1", + ServerInstanceID: "server-1", + Source: domain.LogStreamSourceProcess, + StreamKey: "stdout", + StorageBackend: domain.LogStorageBackendLocalSegments, + RetentionPolicy: "default", + }) + if err != nil { + t.Fatalf("create log stream: %v", err) + } + return stream +} + +func validLogBatch(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) domain.LogBatchIngest { + t.Helper() + entries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1) + for seq := firstSeq; seq <= lastSeq; seq++ { + entries = append(entries, domain.LogEntry{ + Seq: seq, + Timestamp: time.Date(2026, 7, 3, 12, 0, int(seq), 0, time.UTC), + Level: "info", + Line: "line", + }) + } + return domain.LogBatchIngest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + LogStreamID: "log-1", + ServerInstanceID: "server-1", + StreamKey: "stdout", + Source: domain.LogStreamSourceProcess, + FirstSeq: firstSeq, + LastSeq: lastSeq, + Compression: "none", + Checksum: checksumForEntries(t, entries), + Entries: entries, + } +} + +func checksumForEntries(t *testing.T, entries []domain.LogEntry) string { + t.Helper() + checksum, err := validator.LogEntriesChecksum(entries) + if err != nil { + t.Fatalf("checksum entries: %v", err) + } + return checksum +} diff --git a/platform/service/resources.go b/platform/service/resources.go new file mode 100644 index 0000000..d4ef616 --- /dev/null +++ b/platform/service/resources.go @@ -0,0 +1,1754 @@ +package service + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") +) + +type Core interface { + CreateUser(domain.User) (domain.User, error) + UpdateUser(string, domain.User) (domain.User, error) + GetUser(string) (domain.User, error) + ListUsers(domain.UserFilter) ([]domain.User, error) + RegisterUser(domain.UserRegistration) (domain.AuthSession, error) + LoginUser(domain.UserLogin) (domain.AuthSession, error) + LogoutUser(string) error + GetCurrentUser(string) (domain.User, error) + UpdateCurrentUserProfile(string, domain.UserProfile) (domain.User, error) + UpdateCurrentUserTheme(string, domain.UserThemePreference) (domain.UserThemePreference, error) + CreateAIProvider(domain.AIProvider) (domain.AIProvider, error) + UpdateAIProvider(string, domain.AIProvider) (domain.AIProvider, error) + SetAIProviderStatus(string, domain.AIProviderStatus) (domain.AIProvider, error) + TestAIProvider(string) (domain.AIProviderTestResult, error) + ListAIProviderModels(string) (domain.AIProviderModels, error) + GetAIProvider(string) (domain.AIProvider, error) + ListAIProviders(domain.AIProviderFilter) ([]domain.AIProvider, error) + InvokeAIForSession(string, domain.AIInvocationRequest) (domain.AIInvocationResponse, error) + CreateGamePlugin(domain.GamePlugin) (domain.GamePlugin, error) + RegisterGamePluginManifest(domain.GamePluginManifestRegistration) (domain.GamePlugin, error) + GetGamePlugin(string) (domain.GamePlugin, error) + ListGamePlugins(domain.GamePluginFilter) ([]domain.GamePlugin, error) + ListMarketplacePlugins(domain.PluginMarketplaceFilter) ([]domain.PluginMarketplacePlugin, error) + GetMarketplacePlugin(string) (domain.PluginMarketplacePlugin, error) + SetMarketplacePluginState(string, domain.PluginMarketplaceStateAction) (domain.PluginMarketplacePlugin, error) + AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) + ExecutePluginBridgeAction(string, domain.PluginBridgeExecuteRequest) (domain.PluginBridgeExecuteResponse, error) + CreateRunEndpoint(domain.RunEndpoint) (domain.RunEndpoint, error) + GetRunEndpoint(string) (domain.RunEndpoint, error) + ListRunEndpoints(domain.RunEndpointFilter) ([]domain.RunEndpoint, error) + RegisterRunHello(domain.RunControlHello) (domain.RunControlHelloResult, error) + AcceptRunHeartbeat(domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) + CreateServerInstance(domain.ServerInstance) (domain.ServerInstance, error) + CreateServerInstanceForSession(string, domain.ServerInstance) (domain.ServerInstance, error) + CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) + CreateServerInstanceWorkflowForSession(string, domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) + StartServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) + StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) + StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) + StopServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) + GetServerInstance(string) (domain.ServerInstance, error) + GetServerInstanceForSession(string, string) (domain.ServerInstance, error) + ListServerInstances(domain.ServerInstanceFilter) ([]domain.ServerInstance, error) + ListServerInstancesForSession(string, domain.ServerInstanceFilter) ([]domain.ServerInstance, error) + ListServerAdministratorCandidates(string, string) ([]domain.User, error) + AddServerAdministrator(string, string, string) (domain.ServerInstance, error) + RemoveServerAdministrator(string, string, string) (domain.ServerInstance, error) + GetPlatformResourceUsage() (domain.PlatformResourceUsage, error) + ListServerMetricsForSession(string) ([]domain.ServerMetrics, error) + GetServerConfigForSession(string, string) (domain.ServerConfig, error) + PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) + ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) + DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error) + CreateJob(domain.Job) (domain.Job, error) + GetJob(string) (domain.Job, error) + ListJobs(domain.JobFilter) ([]domain.Job, error) + ClaimRunJob(domain.RunJobClaim) (domain.RunJobClaimResult, error) + AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error) + UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error) + CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error) + RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) + PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) + ReconcileRunJobs(domain.RunJobReconcile) (domain.RunJobReconcileResult, error) + CreateArtifact(domain.Artifact) (domain.Artifact, error) + GetArtifact(string) (domain.Artifact, error) + ListArtifacts(domain.ArtifactFilter) ([]domain.Artifact, error) + GetArtifactForSession(string, string) (domain.Artifact, error) + OpenArtifactDownloadForSession(string, domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error) + ReadArtifactContentForSession(string, domain.ArtifactContentRequest) (domain.ArtifactContent, error) + OpenArtifactTransfer(domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error) + UploadArtifactChunk(domain.ArtifactChunkUpload) (domain.ArtifactChunkUploadResult, error) + QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery) (domain.ArtifactTransferStatusResult, error) + CompleteArtifactTransfer(domain.ArtifactTransferComplete) (domain.ArtifactTransferCompleteResult, error) + CreateLogStream(domain.LogStream) (domain.LogStream, error) + GetLogStream(string) (domain.LogStream, error) + ListLogStreams(domain.LogStreamFilter) ([]domain.LogStream, error) + IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error) + QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) + CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error) + GetAuditEvent(string) (domain.AuditEvent, error) + ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) +} + +type CoreService struct { + store repo.Store + now func() time.Time + authMu sync.Mutex + authSessions map[string]string + controlMu sync.Mutex + runSessions map[string]domain.RunControlSession + runSessionSeq uint64 + jobMu sync.Mutex + jobLeases map[string]domain.RunJobLease + jobLeaseSeq uint64 + logStore LogBodyStore + artifactMu sync.Mutex + artifactTransfers map[string]domain.ArtifactTransferSession + artifactTransferSeq uint64 + aiProviderClient AIProviderClient +} + +var _ Core = (*CoreService)(nil) + +func NewCoreService(store repo.Store) *CoreService { + return newCoreService(store, func() time.Time { return time.Now().UTC() }) +} + +func NewCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore) *CoreService { + return newCoreServiceWithLogStore(store, logStore, func() time.Time { return time.Now().UTC() }) +} + +func newCoreService(store repo.Store, now func() time.Time) *CoreService { + return newCoreServiceWithLogStore(store, NewMemoryLogBodyStore(), now) +} + +func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now func() time.Time) *CoreService { + if logStore == nil { + logStore = NewMemoryLogBodyStore() + } + return &CoreService{ + store: store, + now: now, + authSessions: map[string]string{}, + runSessions: map[string]domain.RunControlSession{}, + jobLeases: map[string]domain.RunJobLease{}, + logStore: logStore, + artifactTransfers: map[string]domain.ArtifactTransferSession{}, + aiProviderClient: MockAIProviderClient{}, + } +} + +func (svc *CoreService) CreateUser(user domain.User) (domain.User, error) { + if strings.TrimSpace(user.ID) == "" { + id, err := svc.nextUserID(user) + if err != nil { + return domain.User{}, err + } + user.ID = id + } + if user.Status == "" { + user.Status = domain.UserStatusActive + } + if len(user.Roles) == 0 { + user.Roles = []string{"server-admin"} + } + if strings.TrimSpace(user.PasswordHash) != "" && !strings.Contains(user.PasswordHash, "$") { + hash, err := hashPassword(user.PasswordHash) + if err != nil { + return domain.User{}, err + } + user.PasswordHash = hash + } + stamp := svc.now() + if user.CreatedAt.IsZero() { + user.CreatedAt = stamp + } + if user.UpdatedAt.IsZero() { + user.UpdatedAt = stamp + } + if err := validator.ValidateUser(user); err != nil { + return domain.User{}, err + } + if err := svc.store.Users().Create(user); err != nil { + return domain.User{}, err + } + return domain.CopyUser(user), nil +} + +func (svc *CoreService) UpdateUser(id string, user domain.User) (domain.User, error) { + existing, err := svc.store.Users().Get(id) + if err != nil { + return domain.User{}, err + } + user.ID = id + if user.PasswordHash == "" { + user.PasswordHash = existing.PasswordHash + } else if !strings.Contains(user.PasswordHash, "$") { + hash, err := hashPassword(user.PasswordHash) + if err != nil { + return domain.User{}, err + } + user.PasswordHash = hash + } + if user.CreatedAt.IsZero() { + user.CreatedAt = existing.CreatedAt + } + user.UpdatedAt = svc.now() + if user.Status == "" { + user.Status = existing.Status + } + if user.Roles == nil { + user.Roles = domain.CopyStringSlice(existing.Roles) + } + if err := validator.ValidateUser(user); err != nil { + return domain.User{}, err + } + if err := svc.store.Users().Update(user); err != nil { + return domain.User{}, err + } + return domain.CopyUser(user), nil +} + +func (svc *CoreService) GetUser(id string) (domain.User, error) { + return svc.store.Users().Get(id) +} + +func (svc *CoreService) ListUsers(filter domain.UserFilter) ([]domain.User, error) { + return svc.store.Users().List(filter) +} + +func (svc *CoreService) RegisterUser(registration domain.UserRegistration) (domain.AuthSession, error) { + if len([]rune(registration.Password)) < 6 { + return domain.AuthSession{}, validationError("password must be at least 6 characters") + } + hash, err := hashPassword(registration.Password) + if err != nil { + return domain.AuthSession{}, err + } + users, err := svc.store.Users().List(domain.UserFilter{}) + if err != nil { + return domain.AuthSession{}, err + } + firstUser := len(users) == 0 + user := domain.User{ + ID: userIDFromEmail(registration.Email), + DisplayName: registration.DisplayName, + Email: registration.Email, + Status: domain.UserStatusPending, + Roles: []string{"server-admin"}, + PasswordHash: hash, + Profile: registration.Profile, + } + if firstUser { + user.Status = domain.UserStatusActive + user.Roles = []string{"platform-admin"} + } + created, err := svc.CreateUser(user) + if err != nil { + return domain.AuthSession{}, err + } + if firstUser { + sessionID, err := randomToken() + if err != nil { + return domain.AuthSession{}, err + } + svc.authMu.Lock() + svc.authSessions[sessionID] = created.ID + svc.authMu.Unlock() + return domain.AuthSession{ + SessionID: sessionID, + User: created, + Status: "authenticated", + Message: "首个账号已创建为平台管理员。", + }, nil + } + return domain.AuthSession{ + User: created, + Status: "pending", + Message: "注册申请已提交,等待平台管理员审核。", + }, nil +} + +func (svc *CoreService) LoginUser(login domain.UserLogin) (domain.AuthSession, error) { + var matched domain.User + users, err := svc.store.Users().List(domain.UserFilter{}) + if err != nil { + return domain.AuthSession{}, err + } + account := strings.ToLower(strings.TrimSpace(login.Account)) + for _, user := range users { + if strings.ToLower(user.ID) == account || strings.ToLower(strings.TrimSpace(user.Email)) == account { + matched = user + break + } + } + if matched.ID == "" || !verifyPassword(matched.PasswordHash, login.Password) { + return domain.AuthSession{}, ErrUnauthorized + } + if matched.Status == domain.UserStatusPending { + return domain.AuthSession{}, ErrForbidden + } + if matched.Status == domain.UserStatusDisabled { + return domain.AuthSession{}, ErrForbidden + } + sessionID, err := randomToken() + if err != nil { + return domain.AuthSession{}, err + } + svc.authMu.Lock() + svc.authSessions[sessionID] = matched.ID + svc.authMu.Unlock() + return domain.AuthSession{SessionID: sessionID, User: matched, Status: "authenticated", Message: "登录成功"}, nil +} + +func (svc *CoreService) LogoutUser(sessionID string) error { + if strings.TrimSpace(sessionID) == "" { + return ErrUnauthorized + } + svc.authMu.Lock() + defer svc.authMu.Unlock() + if _, exists := svc.authSessions[sessionID]; !exists { + return ErrUnauthorized + } + delete(svc.authSessions, sessionID) + return nil +} + +func (svc *CoreService) GetCurrentUser(sessionID string) (domain.User, error) { + userID, err := svc.userIDForSession(sessionID) + if err != nil { + return domain.User{}, err + } + return svc.store.Users().Get(userID) +} + +func (svc *CoreService) UpdateCurrentUserProfile(sessionID string, profile domain.UserProfile) (domain.User, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.User{}, err + } + user.Profile = profile + return svc.UpdateUser(user.ID, user) +} + +func (svc *CoreService) UpdateCurrentUserTheme(sessionID string, preference domain.UserThemePreference) (domain.UserThemePreference, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.UserThemePreference{}, err + } + preference.UserID = user.ID + preference.Persistence = "api" + preference.UpdatedAt = svc.now() + user.Theme = preference + if _, err := svc.UpdateUser(user.ID, user); err != nil { + return domain.UserThemePreference{}, err + } + return preference, nil +} + +func (svc *CoreService) SeedLocalPlatformAdmin() error { + const adminEmail = "operator.local@example.test" + _, err := svc.store.Users().Get("user-admin") + if err == nil { + return nil + } + if !errors.Is(err, repo.ErrNotFound) { + return err + } + return svc.store.Users().Create(domain.User{ + ID: "user-admin", + DisplayName: "Operator", + Email: adminEmail, + Status: domain.UserStatusActive, + Roles: []string{"platform-admin"}, + PasswordHash: mustHashPassword("operator-local"), + Profile: domain.UserProfile{ContactNote: "local development admin"}, + CreatedAt: svc.now(), + UpdatedAt: svc.now(), + }) +} + +func (svc *CoreService) CreateAIProvider(provider domain.AIProvider) (domain.AIProvider, error) { + if provider.Status == "" { + provider.Status = domain.AIProviderStatusActive + } + if err := validator.ValidateAIProvider(provider); err != nil { + return domain.AIProvider{}, err + } + if err := svc.store.AIProviders().Create(provider); err != nil { + return domain.AIProvider{}, err + } + return domain.CopyAIProvider(provider), nil +} + +func (svc *CoreService) UpdateAIProvider(id string, provider domain.AIProvider) (domain.AIProvider, error) { + existing, err := svc.store.AIProviders().Get(id) + if err != nil { + return domain.AIProvider{}, err + } + provider.ID = id + provider.Status = existing.Status + if err := validator.ValidateAIProvider(provider); err != nil { + return domain.AIProvider{}, err + } + if err := svc.store.AIProviders().Update(provider); err != nil { + return domain.AIProvider{}, err + } + return domain.CopyAIProvider(provider), nil +} + +func (svc *CoreService) SetAIProviderStatus(id string, status domain.AIProviderStatus) (domain.AIProvider, error) { + if status != domain.AIProviderStatusActive && status != domain.AIProviderStatusDisabled { + return domain.AIProvider{}, validationError("status must be active or disabled") + } + provider, err := svc.store.AIProviders().Get(id) + if err != nil { + return domain.AIProvider{}, err + } + provider.Status = status + if err := validator.ValidateAIProvider(provider); err != nil { + return domain.AIProvider{}, err + } + if err := svc.store.AIProviders().Update(provider); err != nil { + return domain.AIProvider{}, err + } + return domain.CopyAIProvider(provider), nil +} + +func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult, error) { + provider, err := svc.store.AIProviders().Get(id) + if err != nil { + return domain.AIProviderTestResult{}, err + } + + result := domain.AIProviderTestResult{ + ProviderID: provider.ID, + Mode: "metadata", + Success: true, + Message: "metadata validation passed", + } + if err := validator.ValidateAIProvider(provider); err != nil { + result.Success = false + result.Message = "metadata validation failed" + var validationErr validator.ValidationError + if errors.As(err, &validationErr) { + result.Violations = append(result.Violations, validationErr.Violations...) + } else { + result.Violations = append(result.Violations, err.Error()) + } + } + if provider.Status != domain.AIProviderStatusActive { + result.Success = false + result.Message = "metadata validation failed" + result.Violations = append(result.Violations, "provider must be active") + } + return domain.CopyAIProviderTestResult(result), nil +} + +func (svc *CoreService) ListAIProviderModels(id string) (domain.AIProviderModels, error) { + provider, err := svc.store.AIProviders().Get(id) + if err != nil { + return domain.AIProviderModels{}, err + } + return domain.CopyAIProviderModels(domain.AIProviderModels{ + ProviderID: provider.ID, + DefaultModel: provider.DefaultModel, + Models: provider.Models, + }), nil +} + +func (svc *CoreService) GetAIProvider(id string) (domain.AIProvider, error) { + return svc.store.AIProviders().Get(id) +} + +func (svc *CoreService) ListAIProviders(filter domain.AIProviderFilter) ([]domain.AIProvider, error) { + return svc.store.AIProviders().List(filter) +} + +func (svc *CoreService) CreateGamePlugin(plugin domain.GamePlugin) (domain.GamePlugin, error) { + if plugin.Status == "" { + plugin.Status = domain.GamePluginStatusInstalled + } + if err := validator.ValidateGamePlugin(plugin); err != nil { + return domain.GamePlugin{}, err + } + if err := svc.store.GamePlugins().Create(plugin); err != nil { + return domain.GamePlugin{}, err + } + return domain.CopyGamePlugin(plugin), nil +} + +func (svc *CoreService) RegisterGamePluginManifest(registration domain.GamePluginManifestRegistration) (domain.GamePlugin, error) { + if err := validator.ValidateGamePluginManifestRegistration(registration); err != nil { + return domain.GamePlugin{}, err + } + return svc.CreateGamePlugin(gamePluginFromManifestRegistration(registration)) +} + +func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRegistration) domain.GamePlugin { + registration = domain.CopyGamePluginManifestRegistration(registration) + manifest := registration.Manifest + return domain.GamePlugin{ + ID: manifest.ID, + Name: manifest.Name, + Description: manifest.Description, + Version: manifest.Version, + ServerType: manifest.Server.Type, + ServerDisplayName: manifest.Server.DisplayName, + SupportedOS: manifest.Server.SupportedOS, + ManifestRef: registration.ManifestRef, + CreateFormSchemaRef: manifest.Server.CreateFormSchema, + RequiredRunCapabilities: manifest.Capabilities, + DeclaredPermissions: manifest.Permissions, + Permissions: pluginPermissionsFromManifest(manifest.Permissions), + LifecycleActions: manifest.Actions, + BridgeActions: manifest.Bridge.Actions, + Pages: manifest.Pages, + Tags: manifest.Tags, + AIPurposes: manifest.AI.Purposes, + Status: domain.GamePluginStatusInstalled, + } +} + +func (svc *CoreService) AuthorizePluginBridgeAction(request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) { + if err := validator.ValidatePluginBridgeAuthorizeRequest(request); err != nil { + return domain.PluginBridgeAuthorization{}, err + } + plugin, err := svc.store.GamePlugins().Get(request.PluginID) + if err != nil { + return domain.PluginBridgeAuthorization{}, err + } + result, err := validator.AuthorizePluginBridgeAction(plugin, request) + if err != nil { + return domain.PluginBridgeAuthorization{}, err + } + return domain.CopyPluginBridgeAuthorization(result), nil +} + +func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request domain.PluginBridgeExecuteRequest) (domain.PluginBridgeExecuteResponse, error) { + request = domain.CopyPluginBridgeExecuteRequest(request) + if err := validator.ValidatePluginBridgeExecuteRequest(request); err != nil { + return domain.PluginBridgeExecuteResponse{}, err + } + base := domain.PluginBridgeExecuteResponse{ + RequestID: request.RequestID, + PluginID: request.PluginID, + RouteKey: request.RouteKey, + ServerInstanceID: request.ServerInstanceID, + Action: request.Action, + } + plugin, err := svc.store.GamePlugins().Get(request.PluginID) + if err != nil { + return domain.PluginBridgeExecuteResponse{}, err + } + authorization, err := validator.AuthorizePluginBridgeAction(plugin, domain.PluginBridgeAuthorizeRequest{ + PluginID: request.PluginID, + RouteKey: request.RouteKey, + ServerInstanceID: request.ServerInstanceID, + Action: request.Action, + AIPurpose: request.AIPurpose, + }) + if err != nil { + return domain.PluginBridgeExecuteResponse{}, err + } + if !authorization.Allowed { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "permission_denied", Message: safeBridgeReason(authorization.Reason)} + return domain.CopyPluginBridgeExecuteResponse(base), nil + } + + var instance domain.ServerInstance + if request.ServerInstanceID != "" { + instance, err = svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) + if err != nil { + return domain.PluginBridgeExecuteResponse{}, err + } + if instance.PluginID != request.PluginID { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "server_scope_denied", Message: "server instance is outside plugin scope"} + return domain.CopyPluginBridgeExecuteResponse(base), nil + } + } + + switch request.Action { + case domain.PluginBridgeActionServerInstancesRead: + base.Status = "ok" + base.Result = map[string]string{ + "serverInstanceId": instance.ID, + "pluginId": instance.PluginID, + "pluginVersion": instance.PluginVersion, + "runEndpointId": instance.RunEndpointID, + "state": string(instance.State), + "configVersion": strconv.Itoa(instance.ConfigVersion), + } + case domain.PluginBridgeActionJobsDispatch: + base = svc.executeBridgeJobDispatch(sessionID, base, plugin, instance, request.Payload) + case domain.PluginBridgeActionLogsQuery: + base = svc.executeBridgeLogsQuery(base, instance, request.Payload) + case domain.PluginBridgeActionFilesRequest: + base = svc.executeBridgeFileRequest(sessionID, base, request) + case domain.PluginBridgeActionArtifactsOpen: + base = svc.executeBridgeArtifactOpen(sessionID, base, request) + case domain.PluginBridgeActionAIInvoke: + base = svc.executeBridgeAIInvoke(sessionID, base, request) + default: + base.Status = "unsupported" + base.Error = &domain.PluginBridgeSafeError{Code: "unsupported_action", Message: "bridge action is not supported"} + } + return domain.CopyPluginBridgeExecuteResponse(base), nil +} + +func (svc *CoreService) executeBridgeArtifactOpen(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { + artifactID := strings.TrimSpace(request.Payload["artifactId"]) + if artifactID == "" { + base.Status = "error" + base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "artifactId is required"} + return base + } + reference, err := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: artifactID}) + if err != nil { + return bridgeExecutionError(base, err) + } + if reference.OwnerKind == domain.ArtifactOwnerKindJob { + job, err := svc.GetJob(reference.OwnerID) + if err != nil { + return bridgeExecutionError(base, err) + } + if job.ServerInstanceID != request.ServerInstanceID { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "server_scope_denied", Message: "artifact is outside server scope"} + return base + } + } + if reference.OwnerKind == domain.ArtifactOwnerKindServerInstance && reference.OwnerID != request.ServerInstanceID { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "server_scope_denied", Message: "artifact is outside server scope"} + return base + } + base.Status = "ok" + base.Result = map[string]string{ + "artifactId": reference.ArtifactID, + "filename": reference.Filename, + "contentType": reference.ContentType, + "sizeBytes": strconv.FormatInt(reference.SizeBytes, 10), + "checksum": reference.Checksum, + "downloadUrl": reference.DownloadURL, + "expiresAt": reference.ExpiresAt.Format(time.RFC3339), + "rangeSupported": strconv.FormatBool(reference.RangeSupported), + "chunkSizeBytes": strconv.Itoa(reference.ChunkSizeBytes), + "storageBehavior": reference.StorageBehavior, + } + return base +} + +func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { + response, err := svc.InvokeAIForSession(sessionID, domain.AIInvocationRequest{ + RequestID: request.RequestID, + PluginID: request.PluginID, + RouteKey: request.RouteKey, + ServerInstanceID: request.ServerInstanceID, + Purpose: request.AIPurpose, + ProviderID: request.Payload["providerId"], + Model: request.Payload["model"], + Prompt: defaultBridgeValue(request.Payload["prompt"], "Review the current server context and provide a safe recommendation."), + CurrentConfig: request.Payload["currentConfig"], + ContextRefs: map[string]string{"server": "server://" + request.ServerInstanceID}, + }) + if err != nil { + return bridgeExecutionError(base, err) + } + base.Status = response.Status + base.Result = map[string]string{ + "purpose": response.Purpose, + "recommendation": response.Recommendation, + "providerId": response.ProviderID, + "model": response.Model, + "mocked": strconv.FormatBool(response.Usage.Mocked), + } + if response.ConfigRecommendation != nil { + base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig + base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary + } + if response.Error != nil { + base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details} + } + return base +} + +func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { + capability := strings.TrimSpace(payload["capability"]) + if capability == "" { + capability = "process.start" + } + if !containsString(plugin.RequiredRunCapabilities, capability) { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "requested job capability is not declared by plugin"} + return base + } + lifecycleAction := domain.ServerLifecycleAction(strings.TrimSpace(payload["lifecycleAction"])) + if lifecycleAction == domain.ServerLifecycleActionStart || lifecycleAction == domain.ServerLifecycleActionStop { + expectedVersion, _ := strconv.Atoi(payload["expectedConfigVersion"]) + command := domain.ServerLifecycleCommand{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: expectedVersion, + IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), + } + var result domain.ServerLifecycleResult + var err error + switch lifecycleAction { + case domain.ServerLifecycleActionStart: + if capability != domain.LifecycleCapabilityStart { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "lifecycle action must match requested capability"} + return base + } + result, err = svc.StartServerInstanceForSession(sessionID, command) + case domain.ServerLifecycleActionStop: + if capability != domain.LifecycleCapabilityStop { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "lifecycle action must match requested capability"} + return base + } + result, err = svc.StopServerInstanceForSession(sessionID, command) + } + if err != nil { + return bridgeExecutionError(base, err) + } + base.Status = "queued" + base.Result = map[string]string{ + "jobId": result.Job.ID, + "state": string(result.Job.State), + "capability": result.Job.Capability, + "lifecycleAction": string(result.Action), + "serverInstanceId": result.Instance.ID, + } + return base + } + job, err := svc.CreateJob(domain.Job{ + ID: jobIDFromParts("job-bridge", base.RequestID, capability), + ServerInstanceID: instance.ID, + RunEndpointID: instance.RunEndpointID, + Capability: capability, + IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), + Progress: domain.JobProgress{Percent: 0, Message: "plugin bridge job queued"}, + }) + if err != nil { + return bridgeExecutionError(base, err) + } + base.Status = "queued" + base.Result = map[string]string{"jobId": job.ID, "state": string(job.State), "capability": job.Capability} + return base +} + +func (svc *CoreService) executeBridgeLogsQuery(base domain.PluginBridgeExecuteResponse, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { + streamID := strings.TrimSpace(payload["logStreamId"]) + if streamID == "" { + base.Status = "error" + base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "logStreamId is required"} + return base + } + stream, err := svc.GetLogStream(streamID) + if err != nil { + return bridgeExecutionError(base, err) + } + if stream.ServerInstanceID != instance.ID { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "server_scope_denied", Message: "log stream is outside server scope"} + return base + } + afterSeq, _ := strconv.ParseUint(payload["afterSeq"], 10, 64) + limit, _ := strconv.Atoi(payload["limit"]) + result, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: streamID, AfterSeq: afterSeq, Limit: limit}) + if err != nil { + return bridgeExecutionError(base, err) + } + base.Status = "ok" + base.Result = map[string]string{ + "logStreamId": stream.ID, + "entryCount": strconv.Itoa(len(result.Entries)), + "nextSeq": strconv.FormatUint(result.NextSeq, 10), + "latestSeq": strconv.FormatUint(result.LatestSeq, 10), + } + return base +} + +func (svc *CoreService) executeBridgeFileRequest(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { + payload := request.Payload + operation := domain.FileOperationKind(defaultBridgeValue(payload["operation"], string(domain.FileOperationRead))) + expectedVersion, _ := strconv.Atoi(payload["expectedConfigVersion"]) + result, err := svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ + ServerInstanceID: request.ServerInstanceID, + PluginID: request.PluginID, + Operation: operation, + Key: payload["key"], + InputRef: payload["inputRef"], + ExpectedConfigVersion: expectedVersion, + IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], request.RequestID), + }) + if err != nil { + return bridgeExecutionError(base, err) + } + base.Status = "queued" + base.Result = map[string]string{ + "jobId": result.Job.ID, + "state": string(result.Job.State), + "capability": result.Job.Capability, + "targetKey": result.Key, + } + return base +} + +func bridgeExecutionError(base domain.PluginBridgeExecuteResponse, err error) domain.PluginBridgeExecuteResponse { + base.Status = "error" + base.Error = &domain.PluginBridgeSafeError{Code: "execution_failed", Message: safeBridgeReason(err.Error())} + return base +} + +func defaultBridgeValue(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func safeBridgeReason(reason string) string { + reason = strings.TrimSpace(reason) + if reason == "" { + return "bridge action is not allowed" + } + for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "api_key=", "apikey="} { + if strings.Contains(strings.ToLower(reason), strings.ToLower(forbidden)) { + return "bridge action failed safely" + } + } + return reason +} + +func pluginPermissionsFromManifest(permissions []string) domain.PluginPermissions { + var aggregate domain.PluginPermissions + for _, permission := range permissions { + switch permission { + case "ai.invoke": + aggregate.AI = true + case "server.logs.read": + aggregate.Logs = true + case "server.files.read", "server.files.write": + aggregate.Files = true + case "server.lifecycle", "server.create": + aggregate.Jobs = true + case "server.artifacts.read", "server.artifacts.write": + aggregate.Artifacts = true + } + } + return aggregate +} + +func (svc *CoreService) GetGamePlugin(id string) (domain.GamePlugin, error) { + return svc.store.GamePlugins().Get(id) +} + +func (svc *CoreService) ListGamePlugins(filter domain.GamePluginFilter) ([]domain.GamePlugin, error) { + return svc.store.GamePlugins().List(filter) +} + +func (svc *CoreService) ListMarketplacePlugins(filter domain.PluginMarketplaceFilter) ([]domain.PluginMarketplacePlugin, error) { + filter.Keyword = strings.TrimSpace(filter.Keyword) + if err := validator.ValidatePluginMarketplaceFilter(filter); err != nil { + return nil, err + } + plugins, err := svc.store.GamePlugins().List(domain.GamePluginFilter{ + ServerType: filter.ServerType, + Status: filter.Status, + }) + if err != nil { + return nil, err + } + items := make([]domain.PluginMarketplacePlugin, 0, len(plugins)) + for _, plugin := range plugins { + projected := marketplacePluginFromGamePlugin(plugin) + if filter.Capability != "" && !containsString(projected.Capabilities, filter.Capability) && !containsString(projected.BridgeActions, filter.Capability) { + continue + } + if filter.Keyword != "" && !marketplacePluginMatchesKeyword(projected, filter.Keyword) { + continue + } + items = append(items, projected) + } + if err := validator.ValidatePluginMarketplacePlugins(items); err != nil { + return nil, err + } + return domain.CopyPluginMarketplacePluginSlice(items), nil +} + +func (svc *CoreService) GetMarketplacePlugin(id string) (domain.PluginMarketplacePlugin, error) { + if strings.TrimSpace(id) == "" { + return domain.PluginMarketplacePlugin{}, validationError("pluginId is required") + } + plugin, err := svc.store.GamePlugins().Get(id) + if err != nil { + return domain.PluginMarketplacePlugin{}, err + } + projected := marketplacePluginFromGamePlugin(plugin) + if err := validator.ValidatePluginMarketplacePlugin(projected); err != nil { + return domain.PluginMarketplacePlugin{}, err + } + return domain.CopyPluginMarketplacePlugin(projected), nil +} + +func (svc *CoreService) SetMarketplacePluginState(id string, action domain.PluginMarketplaceStateAction) (domain.PluginMarketplacePlugin, error) { + if strings.TrimSpace(id) == "" { + return domain.PluginMarketplacePlugin{}, validationError("pluginId is required") + } + if err := validator.ValidatePluginMarketplaceStateAction(action); err != nil { + return domain.PluginMarketplacePlugin{}, err + } + plugin, err := svc.store.GamePlugins().Get(id) + if err != nil { + return domain.PluginMarketplacePlugin{}, err + } + switch action { + case domain.PluginMarketplaceStateActionInstall, domain.PluginMarketplaceStateActionEnable: + plugin.Status = domain.GamePluginStatusInstalled + case domain.PluginMarketplaceStateActionDisable: + plugin.Status = domain.GamePluginStatusDisabled + } + if err := validator.ValidateGamePlugin(plugin); err != nil { + return domain.PluginMarketplacePlugin{}, err + } + if err := svc.store.GamePlugins().Update(plugin); err != nil { + return domain.PluginMarketplacePlugin{}, err + } + projected := marketplacePluginFromGamePlugin(plugin) + if err := validator.ValidatePluginMarketplacePlugin(projected); err != nil { + return domain.PluginMarketplacePlugin{}, err + } + return domain.CopyPluginMarketplacePlugin(projected), nil +} + +func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMarketplacePlugin { + plugin = domain.CopyGamePlugin(plugin) + return domain.PluginMarketplacePlugin{ + ID: plugin.ID, + Name: plugin.Name, + Description: plugin.Description, + Version: plugin.Version, + ServerType: plugin.ServerType, + ServerDisplayName: plugin.ServerDisplayName, + SupportedOS: plugin.SupportedOS, + ManifestRef: plugin.ManifestRef, + CreateFormSchemaRef: plugin.CreateFormSchemaRef, + Capabilities: plugin.RequiredRunCapabilities, + DeclaredPermissions: plugin.DeclaredPermissions, + Permissions: plugin.Permissions, + LifecycleActions: plugin.LifecycleActions, + BridgeActions: plugin.BridgeActions, + Pages: plugin.Pages, + Tags: plugin.Tags, + AIPurposes: plugin.AIPurposes, + ValidationViolations: plugin.ValidationViolations, + Status: plugin.Status, + Source: "platform-registry", + } +} + +func marketplacePluginMatchesKeyword(plugin domain.PluginMarketplacePlugin, keyword string) bool { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return true + } + fields := []string{plugin.ID, plugin.Name, plugin.Description, plugin.ServerType, plugin.ServerDisplayName, plugin.Version} + fields = append(fields, plugin.Tags...) + fields = append(fields, plugin.Capabilities...) + for _, field := range fields { + if strings.Contains(strings.ToLower(field), keyword) { + return true + } + } + return false +} + +func (svc *CoreService) CreateRunEndpoint(endpoint domain.RunEndpoint) (domain.RunEndpoint, error) { + if endpoint.Status == "" { + endpoint.Status = domain.RunEndpointStatusOnline + } + if err := validator.ValidateRunEndpoint(endpoint); err != nil { + return domain.RunEndpoint{}, err + } + if err := svc.store.RunEndpoints().Create(endpoint); err != nil { + return domain.RunEndpoint{}, err + } + return domain.CopyRunEndpoint(endpoint), nil +} + +func (svc *CoreService) GetRunEndpoint(id string) (domain.RunEndpoint, error) { + return svc.store.RunEndpoints().Get(id) +} + +func (svc *CoreService) ListRunEndpoints(filter domain.RunEndpointFilter) ([]domain.RunEndpoint, error) { + return svc.store.RunEndpoints().List(filter) +} + +func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (domain.ServerInstance, error) { + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.ServerInstance{}, fmt.Errorf("get plugin dependency: %w", err) + } + endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + return domain.ServerInstance{}, fmt.Errorf("get run endpoint dependency: %w", err) + } + + if instance.PluginVersion == "" { + instance.PluginVersion = plugin.Version + } + if instance.State == "" { + instance.State = domain.ServerInstanceStateDraft + } + if instance.ConfigVersion == 0 { + instance.ConfigVersion = 1 + } + stamp := svc.now() + if instance.CreatedAt.IsZero() { + instance.CreatedAt = stamp + } + if instance.UpdatedAt.IsZero() { + instance.UpdatedAt = stamp + } + + if err := validator.ValidateServerInstance(instance); err != nil { + return domain.ServerInstance{}, err + } + if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { + return domain.ServerInstance{}, err + } + if err := svc.store.ServerInstances().Create(instance); err != nil { + return domain.ServerInstance{}, err + } + return domain.CopyServerInstance(instance), nil +} + +func (svc *CoreService) CreateServerInstanceForSession(sessionID string, instance domain.ServerInstance) (domain.ServerInstance, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.ServerInstance{}, err + } + if strings.TrimSpace(instance.OwnerUserID) == "" { + instance.OwnerUserID = user.ID + } + if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID { + return domain.ServerInstance{}, ErrForbidden + } + return svc.CreateServerInstance(instance) +} + +func (svc *CoreService) GetServerInstance(id string) (domain.ServerInstance, error) { + return svc.store.ServerInstances().Get(id) +} + +func (svc *CoreService) GetServerInstanceForSession(sessionID string, id string) (domain.ServerInstance, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.ServerInstance{}, err + } + instance, err := svc.store.ServerInstances().Get(id) + if err != nil { + return domain.ServerInstance{}, err + } + if !canAccessServer(user, instance) { + return domain.ServerInstance{}, ErrForbidden + } + return domain.CopyServerInstance(instance), nil +} + +func (svc *CoreService) ListServerInstances(filter domain.ServerInstanceFilter) ([]domain.ServerInstance, error) { + return svc.store.ServerInstances().List(filter) +} + +func (svc *CoreService) ListServerInstancesForSession(sessionID string, filter domain.ServerInstanceFilter) ([]domain.ServerInstance, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return nil, err + } + if !isPlatformAdmin(user) { + filter.VisibleToUserID = user.ID + } + return svc.store.ServerInstances().List(filter) +} + +func (svc *CoreService) ListServerAdministratorCandidates(sessionID string, serverInstanceID string) ([]domain.User, error) { + user, instance, err := svc.requireServerOwner(sessionID, serverInstanceID) + if err != nil { + return nil, err + } + _ = user + users, err := svc.store.Users().List(domain.UserFilter{Status: domain.UserStatusActive}) + if err != nil { + return nil, err + } + candidates := make([]domain.User, 0, len(users)) + for _, candidate := range users { + if candidate.ID == instance.OwnerUserID || containsString(instance.AdminUserIDs, candidate.ID) || isPlatformAdmin(candidate) { + continue + } + candidates = append(candidates, domain.CopyUser(candidate)) + } + return candidates, nil +} + +func (svc *CoreService) GetPlatformResourceUsage() (domain.PlatformResourceUsage, error) { + instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{}) + if err != nil { + return domain.PlatformResourceUsage{}, err + } + endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{}) + if err != nil { + return domain.PlatformResourceUsage{}, err + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{}) + if err != nil { + return domain.PlatformResourceUsage{}, err + } + + runningServers := 0 + for _, instance := range instances { + if instance.State == domain.ServerInstanceStateRunning { + runningServers++ + } + } + onlineEndpoints := 0 + for _, endpoint := range endpoints { + if endpoint.Status == domain.RunEndpointStatusOnline { + onlineEndpoints++ + } + } + activeJobs := 0 + for _, job := range jobs { + if job.State == domain.JobStateQueued || job.State == domain.JobStateAccepted || job.State == domain.JobStateRunning { + activeJobs++ + } + } + + usage := domain.PlatformResourceUsage{ + CPUPercent: clampPercent(float64(runningServers*18 + activeJobs*6 + onlineEndpoints*4)), + MemoryPercent: clampPercent(float64(runningServers*22 + onlineEndpoints*8 + len(instances)*3)), + DiskPercent: clampPercent(float64(len(instances)*9 + len(jobs)*2)), + Source: "platform-derived", + CollectedAt: svc.now(), + } + if err := validator.ValidatePlatformResourceUsage(usage); err != nil { + return domain.PlatformResourceUsage{}, err + } + return domain.CopyPlatformResourceUsage(usage), nil +} + +func (svc *CoreService) ListServerMetricsForSession(sessionID string) ([]domain.ServerMetrics, error) { + instances, err := svc.ListServerInstancesForSession(sessionID, domain.ServerInstanceFilter{}) + if err != nil { + return nil, err + } + items := make([]domain.ServerMetrics, 0, len(instances)) + for _, instance := range instances { + items = append(items, svc.metricsForServer(instance)) + } + if err := validator.ValidateServerMetricsList(items); err != nil { + return nil, err + } + return domain.CopyServerMetricsSlice(items), nil +} + +func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstanceID string) (domain.ServerConfig, error) { + instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) + if err != nil { + return domain.ServerConfig{}, err + } + config := domain.ServerConfig{ + ServerInstanceID: instance.ID, + ConfigVersion: instance.ConfigVersion, + Format: "properties", + Key: "server.properties", + Source: "platform-derived", + UpdatedAt: instance.UpdatedAt, + } + if config.UpdatedAt.IsZero() { + config.UpdatedAt = svc.now() + } + config.Content = buildLogicalServerConfig(instance) + if err := validator.ValidateServerConfig(config); err != nil { + return domain.ServerConfig{}, err + } + return domain.CopyServerConfig(config), nil +} + +func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) { + if request.Key == "" { + request.Key = "server.properties" + } + if err := validator.ValidateServerConfigDiffRequest(request); err != nil { + return domain.ServerConfigDiffPreview{}, err + } + config, err := svc.GetServerConfigForSession(sessionID, request.ServerInstanceID) + if err != nil { + return domain.ServerConfigDiffPreview{}, err + } + if config.ConfigVersion != request.ExpectedConfigVersion { + return domain.ServerConfigDiffPreview{}, validationError("expectedConfigVersion must match server instance") + } + if config.Key != request.Key { + return domain.ServerConfigDiffPreview{}, validationError("key must match server config") + } + preview := domain.ServerConfigDiffPreview{ + ServerInstanceID: request.ServerInstanceID, + ConfigVersion: config.ConfigVersion, + Key: request.Key, + CurrentContent: config.Content, + ProposedContent: request.ProposedContent, + ProposedContentInputRef: request.ProposedContentInputRef, + Diff: buildConfigDiffLines(config.Content, request.ProposedContent), + Source: "platform-review", + ReviewedAt: svc.now(), + } + preview.HasChanges = config.Content != request.ProposedContent + return domain.CopyServerConfigDiffPreview(preview), nil +} + +func (svc *CoreService) ApproveServerConfigWriteForSession(sessionID string, approval domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) { + if approval.Key == "" { + approval.Key = "server.properties" + } + if approval.ProposedContentInputRef == "" { + approval.ProposedContentInputRef = configWriteInputRef(approval.ServerInstanceID, approval.Key, approval.ExpectedConfigVersion) + } + if err := validator.ValidateServerConfigWriteApproval(approval); err != nil { + return domain.ServerConfigWriteDispatch{}, err + } + preview, err := svc.PreviewServerConfigWriteForSession(sessionID, domain.ServerConfigDiffRequest{ + ServerInstanceID: approval.ServerInstanceID, + ExpectedConfigVersion: approval.ExpectedConfigVersion, + Key: approval.Key, + ProposedContent: approval.ProposedContent, + ProposedContentInputRef: approval.ProposedContentInputRef, + }) + if err != nil { + return domain.ServerConfigWriteDispatch{}, err + } + if !preview.HasChanges { + return domain.ServerConfigWriteDispatch{}, validationError("config diff has no changes") + } + instance, err := svc.GetServerInstanceForSession(sessionID, approval.ServerInstanceID) + if err != nil { + return domain.ServerConfigWriteDispatch{}, err + } + job, err := svc.CreateJob(domain.Job{ + ID: jobIDFromParts("job-config-write", approval.ServerInstanceID, approval.IdempotencyKey), + ServerInstanceID: instance.ID, + RunEndpointID: instance.RunEndpointID, + Capability: domain.JobCapabilityConfigWrite, + TargetKey: approval.Key, + InputRef: approval.ProposedContentInputRef, + IdempotencyKey: approval.IdempotencyKey, + Progress: domain.JobProgress{Percent: 0, Message: "config write queued"}, + }) + if err != nil { + return domain.ServerConfigWriteDispatch{}, err + } + return domain.CopyServerConfigWriteDispatch(domain.ServerConfigWriteDispatch{Preview: preview, Job: job, Status: "queued"}), nil +} + +func (svc *CoreService) DispatchFileOperationForSession(sessionID string, request domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error) { + if err := validator.ValidateFileOperationDispatchRequest(request); err != nil { + return domain.FileOperationDispatchResult{}, err + } + instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) + if err != nil { + return domain.FileOperationDispatchResult{}, err + } + if request.ExpectedConfigVersion > 0 && request.ExpectedConfigVersion != instance.ConfigVersion { + return domain.FileOperationDispatchResult{}, validationError("expectedConfigVersion must match server instance") + } + if request.PluginID != "" { + plugin, err := svc.store.GamePlugins().Get(request.PluginID) + if err != nil { + return domain.FileOperationDispatchResult{}, err + } + if plugin.ID != instance.PluginID { + return domain.FileOperationDispatchResult{}, validationError("pluginId must match server instance") + } + if plugin.Status != domain.GamePluginStatusInstalled { + return domain.FileOperationDispatchResult{}, validationError("plugin must be installed") + } + if request.Operation == domain.FileOperationRead && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") { + return domain.FileOperationDispatchResult{}, ErrForbidden + } + if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") { + return domain.FileOperationDispatchResult{}, ErrForbidden + } + } + capability := domain.JobCapabilityFilesRead + message := "file read queued" + if request.Operation == domain.FileOperationWrite { + capability = domain.JobCapabilityFilesWrite + message = "file write queued" + } + job, err := svc.CreateJob(domain.Job{ + ID: jobIDFromParts("job-file", request.ServerInstanceID, request.IdempotencyKey), + ServerInstanceID: instance.ID, + RunEndpointID: instance.RunEndpointID, + Capability: capability, + TargetKey: request.Key, + InputRef: request.InputRef, + IdempotencyKey: request.IdempotencyKey, + Progress: domain.JobProgress{Percent: 0, Message: message}, + }) + if err != nil { + return domain.FileOperationDispatchResult{}, err + } + return domain.CopyFileOperationDispatchResult(domain.FileOperationDispatchResult{ + ServerInstanceID: request.ServerInstanceID, + PluginID: request.PluginID, + Operation: request.Operation, + Key: request.Key, + InputRef: request.InputRef, + Job: job, + Status: "queued", + }), nil +} + +func (svc *CoreService) metricsForServer(instance domain.ServerInstance) domain.ServerMetrics { + metrics := domain.ServerMetrics{ + ServerInstanceID: instance.ID, + Online: instance.State == domain.ServerInstanceStateRunning, + Source: "platform-derived", + CollectedAt: svc.now(), + } + if !metrics.Online { + return metrics + } + seed := len(instance.ID) + len(instance.Name) + instance.ConfigVersion + playerCount := seed % 20 + maxPlayers := 20 + tps := 18.5 + float64(seed%15)/10 + latency := 35.0 + float64(seed%40) + cpu := clampPercent(25 + float64(seed%45)) + memory := clampPercent(30 + float64(seed%50)) + disk := clampPercent(20 + float64(seed%60)) + metrics.PlayerCount = &playerCount + metrics.MaxPlayers = &maxPlayers + metrics.TPS = &tps + metrics.LatencyMS = &latency + metrics.CPUPercent = &cpu + metrics.MemoryPercent = &memory + metrics.DiskPercent = &disk + return metrics +} + +func buildLogicalServerConfig(instance domain.ServerInstance) string { + lines := []string{ + "# platform logical server config", + "server.id=" + instance.ID, + "server.name=" + instance.Name, + "plugin.id=" + instance.PluginID, + "plugin.version=" + instance.PluginVersion, + fmt.Sprintf("config.version=%d", instance.ConfigVersion), + "state=" + string(instance.State), + } + return strings.Join(lines, "\n") + "\n" +} + +func buildConfigDiffLines(current string, proposed string) []domain.ConfigDiffLine { + currentLines := strings.Split(current, "\n") + proposedLines := strings.Split(proposed, "\n") + maxLen := len(currentLines) + if len(proposedLines) > maxLen { + maxLen = len(proposedLines) + } + lines := make([]domain.ConfigDiffLine, 0, maxLen*2) + for i := 0; i < maxLen; i++ { + oldExists := i < len(currentLines) + newExists := i < len(proposedLines) + oldLine := "" + newLine := "" + if oldExists { + oldLine = currentLines[i] + } + if newExists { + newLine = proposedLines[i] + } + if oldExists && newExists && oldLine == newLine { + lines = append(lines, domain.ConfigDiffLine{Kind: "context", OldNumber: i + 1, NewNumber: i + 1, Content: oldLine}) + continue + } + if oldExists { + lines = append(lines, domain.ConfigDiffLine{Kind: "removed", OldNumber: i + 1, Content: oldLine}) + } + if newExists { + lines = append(lines, domain.ConfigDiffLine{Kind: "added", NewNumber: i + 1, Content: newLine}) + } + } + return lines +} + +func configWriteInputRef(serverInstanceID string, key string, version int) string { + return fmt.Sprintf("input://server-config/%s/%s/v%d", serverInstanceID, strings.ReplaceAll(key, "/", "-"), version) +} + +func jobIDFromParts(prefix string, resourceID string, idempotencyKey string) string { + return fmt.Sprintf("%s-%s-%d", prefix, resourceID, stableStringNumber(idempotencyKey)) +} + +func stableStringNumber(value string) int { + sum := 0 + for _, char := range value { + sum = sum*31 + int(char) + if sum < 0 { + sum = -sum + } + } + return sum +} + +func clampPercent(value float64) float64 { + if value < 0 { + return 0 + } + if value > 100 { + return 100 + } + return value +} + +func (svc *CoreService) AddServerAdministrator(sessionID string, serverInstanceID string, userID string) (domain.ServerInstance, error) { + _, instance, err := svc.requireServerOwner(sessionID, serverInstanceID) + if err != nil { + return domain.ServerInstance{}, err + } + member, err := svc.store.Users().Get(userID) + if err != nil { + return domain.ServerInstance{}, err + } + if member.Status != domain.UserStatusActive || isPlatformAdmin(member) || member.ID == instance.OwnerUserID { + return domain.ServerInstance{}, ErrForbidden + } + if !containsString(instance.AdminUserIDs, member.ID) { + instance.AdminUserIDs = append(instance.AdminUserIDs, member.ID) + instance.UpdatedAt = svc.now() + if err := validator.ValidateServerInstance(instance); err != nil { + return domain.ServerInstance{}, err + } + if err := svc.store.ServerInstances().Update(instance); err != nil { + return domain.ServerInstance{}, err + } + } + return domain.CopyServerInstance(instance), nil +} + +func (svc *CoreService) RemoveServerAdministrator(sessionID string, serverInstanceID string, userID string) (domain.ServerInstance, error) { + _, instance, err := svc.requireServerOwner(sessionID, serverInstanceID) + if err != nil { + return domain.ServerInstance{}, err + } + member, err := svc.store.Users().Get(userID) + if err != nil { + return domain.ServerInstance{}, err + } + if isPlatformAdmin(member) { + return domain.ServerInstance{}, ErrForbidden + } + nextAdmins := make([]string, 0, len(instance.AdminUserIDs)) + for _, adminID := range instance.AdminUserIDs { + if adminID != userID { + nextAdmins = append(nextAdmins, adminID) + } + } + instance.AdminUserIDs = nextAdmins + instance.UpdatedAt = svc.now() + if err := validator.ValidateServerInstance(instance); err != nil { + return domain.ServerInstance{}, err + } + if err := svc.store.ServerInstances().Update(instance); err != nil { + return domain.ServerInstance{}, err + } + return domain.CopyServerInstance(instance), nil +} + +func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) { + if job.State == "" { + job.State = domain.JobStateQueued + } + stamp := svc.now() + if job.CreatedAt.IsZero() { + job.CreatedAt = stamp + } + if job.UpdatedAt.IsZero() { + job.UpdatedAt = stamp + } + if err := validator.ValidateJob(job); err != nil { + return domain.Job{}, err + } + + existing, err := svc.store.Jobs().GetByIdempotency(job.RunEndpointID, job.IdempotencyKey) + if err == nil { + return existing, nil + } + if !errors.Is(err, repo.ErrNotFound) { + return domain.Job{}, err + } + + endpoint, err := svc.store.RunEndpoints().Get(job.RunEndpointID) + if err != nil { + return domain.Job{}, fmt.Errorf("get run endpoint dependency: %w", err) + } + if err := validateRunnableEndpoint(endpoint, job.Capability); err != nil { + return domain.Job{}, err + } + if job.ServerInstanceID != "" { + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if err != nil { + return domain.Job{}, fmt.Errorf("get server instance dependency: %w", err) + } + if err := validateJobServerTarget(job, instance); err != nil { + return domain.Job{}, err + } + } + + if err := svc.store.Jobs().Create(job); err != nil { + return domain.Job{}, err + } + return domain.CopyJob(job), nil +} + +func (svc *CoreService) GetJob(id string) (domain.Job, error) { + return svc.store.Jobs().Get(id) +} + +func (svc *CoreService) ListJobs(filter domain.JobFilter) ([]domain.Job, error) { + return svc.store.Jobs().List(filter) +} + +func (svc *CoreService) CreateArtifact(artifact domain.Artifact) (domain.Artifact, error) { + if artifact.State == "" { + artifact.State = domain.ArtifactStateUploading + } + stamp := svc.now() + if artifact.CreatedAt.IsZero() { + artifact.CreatedAt = stamp + } + if artifact.UpdatedAt.IsZero() { + artifact.UpdatedAt = stamp + } + if err := validator.ValidateArtifact(artifact); err != nil { + return domain.Artifact{}, err + } + if err := svc.store.Artifacts().Create(artifact); err != nil { + return domain.Artifact{}, err + } + return domain.CopyArtifact(artifact), nil +} + +func (svc *CoreService) GetArtifact(id string) (domain.Artifact, error) { + return svc.store.Artifacts().Get(id) +} + +func (svc *CoreService) ListArtifacts(filter domain.ArtifactFilter) ([]domain.Artifact, error) { + return svc.store.Artifacts().List(filter) +} + +func (svc *CoreService) CreateLogStream(stream domain.LogStream) (domain.LogStream, error) { + instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID) + if err != nil { + return domain.LogStream{}, fmt.Errorf("get server instance dependency: %w", err) + } + if instance.State == domain.ServerInstanceStateDeleted { + return domain.LogStream{}, validationError("server instance must not be deleted") + } + stamp := svc.now() + if stream.CreatedAt.IsZero() { + stream.CreatedAt = stamp + } + if stream.UpdatedAt.IsZero() { + stream.UpdatedAt = stamp + } + if err := validator.ValidateLogStream(stream); err != nil { + return domain.LogStream{}, err + } + if err := svc.store.LogStreams().Create(stream); err != nil { + return domain.LogStream{}, err + } + return domain.CopyLogStream(stream), nil +} + +func (svc *CoreService) GetLogStream(id string) (domain.LogStream, error) { + return svc.store.LogStreams().Get(id) +} + +func (svc *CoreService) ListLogStreams(filter domain.LogStreamFilter) ([]domain.LogStream, error) { + return svc.store.LogStreams().List(filter) +} + +func (svc *CoreService) CreateAuditEvent(event domain.AuditEvent) (domain.AuditEvent, error) { + if event.CreatedAt.IsZero() { + event.CreatedAt = svc.now() + } + if err := validator.ValidateAuditEvent(event); err != nil { + return domain.AuditEvent{}, err + } + if err := svc.store.AuditEvents().Create(event); err != nil { + return domain.AuditEvent{}, err + } + return domain.CopyAuditEvent(event), nil +} + +func (svc *CoreService) GetAuditEvent(id string) (domain.AuditEvent, error) { + return svc.store.AuditEvents().Get(id) +} + +func (svc *CoreService) ListAuditEvents(filter domain.AuditEventFilter) ([]domain.AuditEvent, error) { + return svc.store.AuditEvents().List(filter) +} + +func validateRunnableEndpoint(endpoint domain.RunEndpoint, capability string) error { + if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded { + return validationError("run endpoint must be online or degraded") + } + if len(validator.MissingCapabilities(endpoint.Capabilities, []string{capability})) > 0 { + return validationError("run endpoint missing required capability: " + capability) + } + return nil +} + +func validateJobServerTarget(job domain.Job, instance domain.ServerInstance) error { + if instance.State == domain.ServerInstanceStateDeleted { + return validationError("server instance must not be deleted") + } + if instance.RunEndpointID != job.RunEndpointID { + return validationError("job runEndpointId must match server instance") + } + return nil +} + +func validationError(violation string) error { + return validator.ValidationError{Violations: []string{violation}} +} + +func (svc *CoreService) userIDForSession(sessionID string) (string, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return "", ErrUnauthorized + } + svc.authMu.Lock() + defer svc.authMu.Unlock() + userID, exists := svc.authSessions[sessionID] + if !exists { + return "", ErrUnauthorized + } + return userID, nil +} + +func userIDFromEmail(email string) string { + email = strings.ToLower(strings.TrimSpace(email)) + var b strings.Builder + b.WriteString("user-") + for _, r := range email { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + return strings.Trim(b.String(), "-") +} + +func (svc *CoreService) nextUserID(user domain.User) (string, error) { + base := userIDFromEmail(user.Email) + if base == "user" || base == "" { + base = userIDFromEmail(user.DisplayName) + } + if base == "user" || base == "" { + base = "user-account" + } + if _, err := svc.store.Users().Get(base); errors.Is(err, repo.ErrNotFound) { + return base, nil + } else if err != nil { + return "", err + } + token, err := randomToken() + if err != nil { + return "", err + } + suffix := strings.ToLower(strings.TrimRight(token[:8], "-_")) + if suffix == "" { + suffix = "generated" + } + return base + "-" + suffix, nil +} + +func hashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key, err := pbkdf2.Key(sha256.New, password, salt, 120000, 32) + if err != nil { + return "", err + } + return "pbkdf2-sha256$120000$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil +} + +func mustHashPassword(password string) string { + hash, err := hashPassword(password) + if err != nil { + panic(err) + } + return hash +} + +func randomToken() (string, error) { + token := make([]byte, 32) + if _, err := rand.Read(token); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(token), nil +} + +func verifyPassword(hash string, password string) bool { + parts := strings.Split(hash, "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { + return false + } + var iterations int + if _, err := fmt.Sscanf(parts[1], "%d", &iterations); err != nil || iterations <= 0 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil { + return false + } + got, err := pbkdf2.Key(sha256.New, password, salt, iterations, len(want)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(got, want) == 1 +} diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go new file mode 100644 index 0000000..b7aeb96 --- /dev/null +++ b/platform/service/resources_test.go @@ -0,0 +1,1127 @@ +package service + +import ( + "errors" + "strings" + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +var fixedTime = time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + +func TestCoreServiceCreateListGetWorkflows(t *testing.T) { + svc := newTestCoreService() + + user, err := svc.CreateUser(domain.User{ + ID: "user-1", + DisplayName: "Operator", + Roles: []string{"admin"}, + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + if user.Status != domain.UserStatusActive || !user.CreatedAt.Equal(fixedTime) { + t.Fatalf("expected user defaults, got %+v", user) + } + if _, err := svc.GetUser(user.ID); err != nil { + t.Fatalf("get user: %v", err) + } + users, err := svc.ListUsers(domain.UserFilter{Status: domain.UserStatusActive}) + if err != nil || len(users) != 1 { + t.Fatalf("list users: len=%d err=%v", len(users), err) + } + + generatedUser, err := svc.CreateUser(domain.User{ + DisplayName: "Generated User", + Email: "generated@example.test", + Roles: []string{"server-admin"}, + }) + if err != nil { + t.Fatalf("create generated user: %v", err) + } + if generatedUser.ID != "user-generated-example-test" { + t.Fatalf("expected generated user id from email, got %q", generatedUser.ID) + } + + provider, err := svc.CreateAIProvider(validProvider()) + if err != nil { + t.Fatalf("create provider: %v", err) + } + if provider.APIKeyRef != "secret://providers/openai" { + t.Fatalf("expected provider key reference only, got %+v", provider) + } + if _, err := svc.GetAIProvider(provider.ID); err != nil { + t.Fatalf("get provider: %v", err) + } + providers, err := svc.ListAIProviders(domain.AIProviderFilter{Status: domain.AIProviderStatusActive}) + if err != nil || len(providers) != 1 { + t.Fatalf("list providers: len=%d err=%v", len(providers), err) + } + + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + if _, err := svc.GetGamePlugin(plugin.ID); err != nil { + t.Fatalf("get plugin: %v", err) + } + plugins, err := svc.ListGamePlugins(domain.GamePluginFilter{Status: domain.GamePluginStatusInstalled}) + if err != nil || len(plugins) != 1 { + t.Fatalf("list plugins: len=%d err=%v", len(plugins), err) + } + if _, err := svc.GetRunEndpoint(endpoint.ID); err != nil { + t.Fatalf("get endpoint: %v", err) + } + endpoints, err := svc.ListRunEndpoints(domain.RunEndpointFilter{Status: domain.RunEndpointStatusOnline}) + if err != nil || len(endpoints) != 1 { + t.Fatalf("list endpoints: len=%d err=%v", len(endpoints), err) + } + + instance, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-1", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "SCUM #1", + }) + if err != nil { + t.Fatalf("create server instance: %v", err) + } + if instance.PluginVersion != plugin.Version || instance.ConfigVersion != 1 || instance.State != domain.ServerInstanceStateDraft { + t.Fatalf("expected server defaults, got %+v", instance) + } + if _, err := svc.GetServerInstance(instance.ID); err != nil { + t.Fatalf("get server instance: %v", err) + } + instances, err := svc.ListServerInstances(domain.ServerInstanceFilter{PluginID: plugin.ID}) + if err != nil || len(instances) != 1 { + t.Fatalf("list server instances: len=%d err=%v", len(instances), err) + } + + job, err := svc.CreateJob(domain.Job{ + ID: "job-1", + ServerInstanceID: instance.ID, + RunEndpointID: endpoint.ID, + Capability: "process.start", + IdempotencyKey: "idem-start", + }) + if err != nil { + t.Fatalf("create job: %v", err) + } + if job.State != domain.JobStateQueued || !job.CreatedAt.Equal(fixedTime) { + t.Fatalf("expected job defaults, got %+v", job) + } + if _, err := svc.GetJob(job.ID); err != nil { + t.Fatalf("get job: %v", err) + } + jobs, err := svc.ListJobs(domain.JobFilter{RunEndpointID: endpoint.ID}) + if err != nil || len(jobs) != 1 { + t.Fatalf("list jobs: len=%d err=%v", len(jobs), err) + } + + artifact, err := svc.CreateArtifact(domain.Artifact{ + ID: "artifact-1", + OwnerKind: domain.ArtifactOwnerKindJob, + OwnerID: job.ID, + SizeBytes: 128, + Checksum: "sha256:abc", + }) + if err != nil { + t.Fatalf("create artifact: %v", err) + } + if artifact.State != domain.ArtifactStateUploading { + t.Fatalf("expected artifact default state, got %+v", artifact) + } + if _, err := svc.GetArtifact(artifact.ID); err != nil { + t.Fatalf("get artifact: %v", err) + } + artifacts, err := svc.ListArtifacts(domain.ArtifactFilter{OwnerID: job.ID}) + if err != nil || len(artifacts) != 1 { + t.Fatalf("list artifacts: len=%d err=%v", len(artifacts), err) + } + + stream, err := svc.CreateLogStream(domain.LogStream{ + ID: "log-1", + ServerInstanceID: instance.ID, + Source: domain.LogStreamSourceProcess, + StreamKey: "stdout", + StorageBackend: domain.LogStorageBackendLocalSegments, + RetentionPolicy: "default", + }) + if err != nil { + t.Fatalf("create log stream: %v", err) + } + if _, err := svc.GetLogStream(stream.ID); err != nil { + t.Fatalf("get log stream: %v", err) + } + streams, err := svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID}) + if err != nil || len(streams) != 1 { + t.Fatalf("list log streams: len=%d err=%v", len(streams), err) + } + + audit, err := svc.CreateAuditEvent(domain.AuditEvent{ + ID: "audit-1", + ActorID: user.ID, + Action: "server.create", + ResourceKind: "server-instance", + ResourceID: instance.ID, + Result: domain.AuditResultSuccess, + Summary: "created server instance", + }) + if err != nil { + t.Fatalf("create audit event: %v", err) + } + if !audit.CreatedAt.Equal(fixedTime) { + t.Fatalf("expected audit timestamp default, got %+v", audit) + } + if _, err := svc.GetAuditEvent(audit.ID); err != nil { + t.Fatalf("get audit event: %v", err) + } + auditEvents, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID}) + if err != nil || len(auditEvents) != 1 { + t.Fatalf("list audit events: len=%d err=%v", len(auditEvents), err) + } +} + +func TestCoreServiceRejectsInvalidServerDependencies(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + + disabledPlugin := plugin + disabledPlugin.ID = "server.disabled" + disabledPlugin.Status = domain.GamePluginStatusDisabled + if _, err := svc.CreateGamePlugin(disabledPlugin); err != nil { + t.Fatalf("create disabled plugin fixture: %v", err) + } + + _, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-disabled", + PluginID: disabledPlugin.ID, + RunEndpointID: endpoint.ID, + Name: "Disabled Plugin Server", + }) + if err == nil || !strings.Contains(err.Error(), "plugin must be installed") { + t.Fatalf("expected disabled plugin rejection, got %v", err) + } + + weakEndpoint := endpoint + weakEndpoint.ID = "run-weak" + weakEndpoint.Capabilities = []string{"process.start"} + if _, err := svc.CreateRunEndpoint(weakEndpoint); err != nil { + t.Fatalf("create weak endpoint fixture: %v", err) + } + + _, err = svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-weak", + PluginID: plugin.ID, + RunEndpointID: weakEndpoint.ID, + Name: "Weak Endpoint Server", + }) + if err == nil || !strings.Contains(err.Error(), "logs.read") { + t.Fatalf("expected missing capability rejection, got %v", err) + } +} + +func TestCoreServiceRejectsRawAIProviderSecret(t *testing.T) { + svc := newTestCoreService() + provider := validProvider() + provider.APIKeyRef = "sk-raw-secret" + + _, err := svc.CreateAIProvider(provider) + if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") { + t.Fatalf("expected raw secret rejection, got %v", err) + } +} + +func TestCoreServiceAuthenticatesActiveUsers(t *testing.T) { + svc := newTestCoreService() + created, err := svc.CreateUser(domain.User{ + ID: "user-auth", + DisplayName: "Auth User", + Email: "auth@example.test", + Roles: []string{"platform-admin"}, + PasswordHash: "secret-password", + }) + if err != nil { + t.Fatalf("create auth user: %v", err) + } + if created.PasswordHash == "secret-password" || created.PasswordHash == "" { + t.Fatalf("expected password to be hashed, got %q", created.PasswordHash) + } + + session, err := svc.LoginUser(domain.UserLogin{Account: "auth@example.test", Password: "secret-password"}) + if err != nil { + t.Fatalf("login: %v", err) + } + if session.SessionID == "" || session.Status != "authenticated" || session.User.ID != created.ID { + t.Fatalf("unexpected auth session: %+v", session) + } + + current, err := svc.GetCurrentUser(session.SessionID) + if err != nil { + t.Fatalf("current user: %v", err) + } + if current.ID != created.ID { + t.Fatalf("expected current user %q, got %+v", created.ID, current) + } + + if err := svc.LogoutUser(session.SessionID); err != nil { + t.Fatalf("logout: %v", err) + } + if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("expected logged out session to be unauthorized, got %v", err) + } +} + +func TestCoreServiceFirstRegistrationBootstrapsPlatformAdmin(t *testing.T) { + svc := newTestCoreService() + session, err := svc.RegisterUser(domain.UserRegistration{ + DisplayName: "Bootstrap Admin", + Email: "bootstrap@example.test", + Password: "secret-password", + Profile: domain.UserProfile{Phone: "13800000000", QQ: "10001"}, + }) + if err != nil { + t.Fatalf("register: %v", err) + } + if session.Status != "authenticated" || session.SessionID == "" { + t.Fatalf("expected authenticated bootstrap registration, got %+v", session) + } + if session.User.Status != domain.UserStatusActive || len(session.User.Roles) != 1 || session.User.Roles[0] != "platform-admin" { + t.Fatalf("expected active platform admin user, got %+v", session.User) + } +} + +func TestCoreServiceRegistersLaterUsersAsPendingLowPrivilege(t *testing.T) { + svc := newTestCoreService() + if _, err := svc.CreateUser(domain.User{ID: "user-existing", DisplayName: "Existing Admin", Roles: []string{"platform-admin"}}); err != nil { + t.Fatalf("create existing user: %v", err) + } + session, err := svc.RegisterUser(domain.UserRegistration{ + DisplayName: "Pending Player", + Email: "pending@example.test", + Password: "secret-password", + Profile: domain.UserProfile{Phone: "13800000000", QQ: "10001"}, + }) + if err != nil { + t.Fatalf("register: %v", err) + } + if session.Status != "pending" || session.SessionID != "" { + t.Fatalf("expected pending registration without session token, got %+v", session) + } + if session.User.Status != domain.UserStatusPending || len(session.User.Roles) != 1 || session.User.Roles[0] != "server-admin" { + t.Fatalf("expected low-privilege pending user, got %+v", session.User) + } + if _, err := svc.LoginUser(domain.UserLogin{Account: "pending@example.test", Password: "secret-password"}); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected pending login to be forbidden, got %v", err) + } +} + +func TestCoreServiceScopesServerAccessAndMembership(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ + ID: "user-owner", + DisplayName: "Server Owner", + Email: "owner@example.test", + Roles: []string{"server-owner"}, + PasswordHash: "secret-password", + }) + helperSession := createServiceUserAndLogin(t, svc, domain.User{ + ID: "user-helper", + DisplayName: "Server Helper", + Email: "helper@example.test", + Roles: []string{"server-admin"}, + PasswordHash: "secret-password", + }) + adminSession := createServiceUserAndLogin(t, svc, domain.User{ + ID: "user-platform", + DisplayName: "Platform Admin", + Email: "platform@example.test", + Roles: []string{"platform-admin"}, + PasswordHash: "secret-password", + }) + + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ + ID: "server-owned", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "Owned Server", + State: domain.ServerInstanceStateReady, + }) + if err != nil { + t.Fatalf("create owned server: %v", err) + } + if instance.OwnerUserID != "user-owner" { + t.Fatalf("expected owner to be recorded, got %+v", instance) + } + + ownerServers, err := svc.ListServerInstancesForSession(ownerSession, domain.ServerInstanceFilter{}) + if err != nil || len(ownerServers) != 1 { + t.Fatalf("expected owner server visibility, len=%d err=%v", len(ownerServers), err) + } + helperServers, err := svc.ListServerInstancesForSession(helperSession, domain.ServerInstanceFilter{}) + if err != nil || len(helperServers) != 0 { + t.Fatalf("expected helper to see no servers before invite, len=%d err=%v", len(helperServers), err) + } + adminServers, err := svc.ListServerInstancesForSession(adminSession, domain.ServerInstanceFilter{}) + if err != nil || len(adminServers) != 1 { + t.Fatalf("expected platform admin to see all servers, len=%d err=%v", len(adminServers), err) + } + + candidates, err := svc.ListServerAdministratorCandidates(ownerSession, instance.ID) + if err != nil { + t.Fatalf("list candidates: %v", err) + } + if len(candidates) != 1 || candidates[0].ID != "user-helper" { + t.Fatalf("expected only helper candidate, got %+v", candidates) + } + if _, err := svc.AddServerAdministrator(helperSession, instance.ID, "user-owner"); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected non-owner add to be forbidden, got %v", err) + } + if _, err := svc.AddServerAdministrator(ownerSession, instance.ID, "user-platform"); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected platform admin invite to be forbidden, got %v", err) + } + + updated, err := svc.AddServerAdministrator(ownerSession, instance.ID, "user-helper") + if err != nil { + t.Fatalf("add helper admin: %v", err) + } + if len(updated.AdminUserIDs) != 1 || updated.AdminUserIDs[0] != "user-helper" { + t.Fatalf("expected helper membership, got %+v", updated) + } + helperServers, err = svc.ListServerInstancesForSession(helperSession, domain.ServerInstanceFilter{}) + if err != nil || len(helperServers) != 1 { + t.Fatalf("expected helper to see invited server, len=%d err=%v", len(helperServers), err) + } + if _, err := svc.StartServerInstanceForSession(helperSession, domain.ServerLifecycleCommand{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + IdempotencyKey: "idem-helper-start", + }); err != nil { + t.Fatalf("expected helper lifecycle access: %v", err) + } + + removed, err := svc.RemoveServerAdministrator(ownerSession, instance.ID, "user-helper") + if err != nil { + t.Fatalf("remove helper admin: %v", err) + } + if len(removed.AdminUserIDs) != 0 { + t.Fatalf("expected helper membership removed, got %+v", removed) + } + if _, err := svc.GetServerInstanceForSession(helperSession, instance.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected helper access to be revoked, got %v", err) + } +} + +func TestCoreServiceMetricsAndConfigReadAreRoleScoped(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ + ID: "user-owner-metrics", + DisplayName: "Metrics Owner", + Email: "owner-metrics@example.test", + Roles: []string{"server-owner"}, + PasswordHash: "secret-password", + }) + otherSession := createServiceUserAndLogin(t, svc, domain.User{ + ID: "user-other-metrics", + DisplayName: "Metrics Other", + Email: "other-metrics@example.test", + Roles: []string{"server-admin"}, + PasswordHash: "secret-password", + }) + + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ + ID: "server-metrics", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "Metrics Server", + State: domain.ServerInstanceStateRunning, + }) + if err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := svc.CreateJob(domain.Job{ID: "job-metrics", ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: "process.start", IdempotencyKey: "idem-metrics"}); err != nil { + t.Fatalf("create job: %v", err) + } + + usage, err := svc.GetPlatformResourceUsage() + if err != nil { + t.Fatalf("get platform usage: %v", err) + } + if usage.Source != "platform-derived" || usage.CollectedAt.IsZero() || usage.CPUPercent < 0 || usage.CPUPercent > 100 { + t.Fatalf("unexpected platform usage: %+v", usage) + } + + ownerMetrics, err := svc.ListServerMetricsForSession(ownerSession) + if err != nil { + t.Fatalf("list owner metrics: %v", err) + } + if len(ownerMetrics) != 1 || ownerMetrics[0].ServerInstanceID != instance.ID || !ownerMetrics[0].Online || ownerMetrics[0].CPUPercent == nil { + t.Fatalf("unexpected owner metrics: %+v", ownerMetrics) + } + otherMetrics, err := svc.ListServerMetricsForSession(otherSession) + if err != nil { + t.Fatalf("list other metrics: %v", err) + } + if len(otherMetrics) != 0 { + t.Fatalf("expected other user to see no metrics, got %+v", otherMetrics) + } + + config, err := svc.GetServerConfigForSession(ownerSession, instance.ID) + if err != nil { + t.Fatalf("get config: %v", err) + } + if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics Server") { + t.Fatalf("unexpected config: %+v", config) + } + for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} { + if strings.Contains(config.Content, forbidden) { + t.Fatalf("config content exposed forbidden fragment %q: %s", forbidden, config.Content) + } + } + if _, err := svc.GetServerConfigForSession(otherSession, instance.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected other config access to be forbidden, got %v", err) + } +} + +func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ + ID: "user-owner-config", + DisplayName: "Config Owner", + Email: "owner-config@example.test", + Roles: []string{"server-owner"}, + PasswordHash: "secret-password", + }) + otherSession := createServiceUserAndLogin(t, svc, domain.User{ + ID: "user-other-config", + DisplayName: "Config Other", + Email: "other-config@example.test", + Roles: []string{"server-admin"}, + PasswordHash: "secret-password", + }) + + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ + ID: "server-config", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "Config Server", + State: domain.ServerInstanceStateRunning, + }) + if err != nil { + t.Fatalf("create server: %v", err) + } + current, err := svc.GetServerConfigForSession(ownerSession, instance.ID) + if err != nil { + t.Fatalf("get config: %v", err) + } + proposed := strings.Replace(current.Content, "state=running", "state=running\nmotd=Approved", 1) + + preview, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + Key: current.Key, + ProposedContent: proposed, + }) + if err != nil { + t.Fatalf("preview config write: %v", err) + } + if !preview.HasChanges || preview.Source != "platform-review" || preview.ProposedContent != proposed { + t.Fatalf("unexpected preview: %+v", preview) + } + jobs, err := svc.ListJobs(domain.JobFilter{}) + if err != nil || len(jobs) != 0 { + t.Fatalf("preview must not create jobs, jobs=%+v err=%v", jobs, err) + } + + dispatch, err := svc.ApproveServerConfigWriteForSession(ownerSession, domain.ServerConfigWriteApproval{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + Key: current.Key, + ProposedContent: proposed, + IdempotencyKey: "idem-config-approve", + }) + if err != nil { + t.Fatalf("approve config write: %v", err) + } + if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != current.Key || !strings.HasPrefix(dispatch.Job.InputRef, "input://server-config/") { + t.Fatalf("unexpected config dispatch: %+v", dispatch) + } + + if _, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion + 1, + Key: current.Key, + ProposedContent: proposed, + }); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") { + t.Fatalf("expected stale config version rejection, got %v", err) + } + if _, err := svc.ApproveServerConfigWriteForSession(otherSession, domain.ServerConfigWriteApproval{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + Key: current.Key, + ProposedContent: proposed, + IdempotencyKey: "idem-config-forbidden", + }); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected unauthorized approval rejection, got %v", err) + } + if _, err := svc.PreviewServerConfigWriteForSession(ownerSession, domain.ServerConfigDiffRequest{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + Key: "/Users/tasia/secret.properties", + ProposedContent: proposed, + }); err == nil || !strings.Contains(err.Error(), "key") { + t.Fatalf("expected unsafe key rejection, got %v", err) + } + if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ + ServerInstanceID: instance.ID, + Operation: domain.FileOperationRead, + Key: "../secrets.env", + IdempotencyKey: "idem-file-unsafe", + }); err == nil || !strings.Contains(err.Error(), "key") { + t.Fatalf("expected unsafe file key rejection, got %v", err) + } + + fileDispatch, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ + ServerInstanceID: instance.ID, + PluginID: plugin.ID, + Operation: domain.FileOperationRead, + Key: "logs/latest.log", + IdempotencyKey: "idem-file-read", + }) + if err != nil { + t.Fatalf("dispatch file read: %v", err) + } + if fileDispatch.Job.Capability != domain.JobCapabilityFilesRead || fileDispatch.Job.TargetKey != "logs/latest.log" { + t.Fatalf("unexpected file dispatch: %+v", fileDispatch) + } + jobs, err = svc.ListJobs(domain.JobFilter{}) + if err != nil || len(jobs) != 2 { + t.Fatalf("expected only approved config and file jobs, jobs=%+v err=%v", jobs, err) + } +} + +func TestCoreServiceUpdatesUsersProfileAndTheme(t *testing.T) { + svc := newTestCoreService() + if _, err := svc.CreateUser(domain.User{ + ID: "user-profile", + DisplayName: "Profile User", + Email: "profile@example.test", + Roles: []string{"server-admin"}, + PasswordHash: "secret-password", + }); err != nil { + t.Fatalf("create user: %v", err) + } + session, err := svc.LoginUser(domain.UserLogin{Account: "profile@example.test", Password: "secret-password"}) + if err != nil { + t.Fatalf("login: %v", err) + } + + updated, err := svc.UpdateCurrentUserProfile(session.SessionID, domain.UserProfile{AvatarURL: "avatar://profile", Phone: "13900000000", ContactNote: "primary contact"}) + if err != nil { + t.Fatalf("update profile: %v", err) + } + if updated.Profile.Phone != "13900000000" || updated.Profile.ContactNote != "primary contact" { + t.Fatalf("unexpected profile: %+v", updated.Profile) + } + + theme, err := svc.UpdateCurrentUserTheme(session.SessionID, domain.UserThemePreference{PaletteID: "crystal-moonlight", BackgroundPresetID: "moon"}) + if err != nil { + t.Fatalf("update theme: %v", err) + } + if theme.UserID != "user-profile" || theme.Persistence != "api" || !theme.UpdatedAt.Equal(fixedTime) { + t.Fatalf("unexpected theme preference: %+v", theme) + } + + adminUpdate := updated + adminUpdate.Status = domain.UserStatusDisabled + adminUpdate.Roles = []string{"server-owner"} + adminUpdate.DisplayName = "Profile User Updated" + saved, err := svc.UpdateUser(updated.ID, adminUpdate) + if err != nil { + t.Fatalf("admin update user: %v", err) + } + if saved.Status != domain.UserStatusDisabled || saved.Roles[0] != "server-owner" || saved.DisplayName != "Profile User Updated" { + t.Fatalf("unexpected updated user: %+v", saved) + } +} + +func createServiceUserAndLogin(t *testing.T, svc *CoreService, user domain.User) string { + t.Helper() + if _, err := svc.CreateUser(user); err != nil { + t.Fatalf("create %s: %v", user.ID, err) + } + session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"}) + if err != nil { + t.Fatalf("login %s: %v", user.ID, err) + } + return session.SessionID +} + +func TestCoreServiceManagesAIProviderMetadata(t *testing.T) { + svc := newTestCoreService() + created, err := svc.CreateAIProvider(validProvider()) + if err != nil { + t.Fatalf("create provider: %v", err) + } + + updated := created + updated.Name = "OpenAI Primary" + updated.BaseURL = "https://relay.example.test/v1" + updated.Models = []string{"gpt-4.1-mini"} + updated.DefaultModel = "gpt-4.1-mini" + updated.RelayMode = domain.AIRelayModeRelay + updated.APIKeyRef = "vault://providers/openai-primary" + got, err := svc.UpdateAIProvider(created.ID, updated) + if err != nil { + t.Fatalf("update provider: %v", err) + } + if got.Name != "OpenAI Primary" || got.Status != domain.AIProviderStatusActive || got.APIKeyRef != "vault://providers/openai-primary" { + t.Fatalf("unexpected updated provider: %+v", got) + } + + disabled, err := svc.SetAIProviderStatus(created.ID, domain.AIProviderStatusDisabled) + if err != nil { + t.Fatalf("disable provider: %v", err) + } + if disabled.Status != domain.AIProviderStatusDisabled { + t.Fatalf("expected disabled provider, got %+v", disabled) + } + + testResult, err := svc.TestAIProvider(created.ID) + if err != nil { + t.Fatalf("test provider: %v", err) + } + if testResult.Success || !strings.Contains(strings.Join(testResult.Violations, ","), "provider must be active") { + t.Fatalf("expected disabled provider test failure, got %+v", testResult) + } + + enabled, err := svc.SetAIProviderStatus(created.ID, domain.AIProviderStatusActive) + if err != nil { + t.Fatalf("enable provider: %v", err) + } + if enabled.Status != domain.AIProviderStatusActive { + t.Fatalf("expected active provider, got %+v", enabled) + } + + testResult, err = svc.TestAIProvider(created.ID) + if err != nil { + t.Fatalf("test enabled provider: %v", err) + } + if !testResult.Success || testResult.Mode != "metadata" { + t.Fatalf("expected metadata test success, got %+v", testResult) + } + + models, err := svc.ListAIProviderModels(created.ID) + if err != nil { + t.Fatalf("list provider models: %v", err) + } + if models.ProviderID != created.ID || models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 1 || models.Models[0] != "gpt-4.1-mini" { + t.Fatalf("unexpected provider models: %+v", models) + } +} + +func TestCoreServiceRegistersGamePluginManifest(t *testing.T) { + svc := newTestCoreService() + + plugin, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()) + if err != nil { + t.Fatalf("register manifest: %v", err) + } + if plugin.ID != "game.example" || plugin.ServerType != "example" || plugin.ServerDisplayName != "Example Server" { + t.Fatalf("unexpected registered plugin metadata: %+v", plugin) + } + if plugin.Status != domain.GamePluginStatusInstalled { + t.Fatalf("expected installed status, got %+v", plugin) + } + if !plugin.Permissions.AI || !plugin.Permissions.Logs || !plugin.Permissions.Files || !plugin.Permissions.Artifacts || !plugin.Permissions.Jobs { + t.Fatalf("expected aggregate permissions from manifest, got %+v", plugin.Permissions) + } + if len(plugin.Pages) != 1 || plugin.Pages[0].Permissions[0] != "server.logs.read" { + t.Fatalf("expected page metadata, got %+v", plugin.Pages) + } + if len(plugin.AIPurposes) != 1 || plugin.AIPurposes[0] != "logs.diagnose" { + t.Fatalf("expected AI purposes, got %+v", plugin.AIPurposes) + } + if len(plugin.BridgeActions) != 4 || plugin.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) { + t.Fatalf("expected bridge actions, got %+v", plugin.BridgeActions) + } + + listed, err := svc.ListGamePlugins(domain.GamePluginFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled}) + if err != nil || len(listed) != 1 { + t.Fatalf("list registered plugins: len=%d err=%v", len(listed), err) + } +} + +func TestCoreServiceMarketplacePluginsAreFilteredSafeAndStateful(t *testing.T) { + svc := newTestCoreService() + if _, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()); err != nil { + t.Fatalf("register manifest: %v", err) + } + + listed, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled, Capability: "logs.read", Keyword: "development"}) + if err != nil { + t.Fatalf("list marketplace plugins: %v", err) + } + if len(listed) != 1 || listed[0].ID != "game.example" || listed[0].Source != "platform-registry" { + t.Fatalf("unexpected marketplace list: %+v", listed) + } + if len(listed[0].Capabilities) == 0 || listed[0].Capabilities[0] != "process.install" || len(listed[0].Pages) != 1 || listed[0].AIPurposes[0] != "logs.diagnose" { + t.Fatalf("expected manifest-backed projection, got %+v", listed[0]) + } + + detail, err := svc.GetMarketplacePlugin("game.example") + if err != nil { + t.Fatalf("get marketplace plugin: %v", err) + } + if detail.ManifestRef != "artifact://manifests/game.example/0.1.0" || detail.CreateFormSchemaRef != "schemas/create-form.schema.json" { + t.Fatalf("unexpected marketplace detail refs: %+v", detail) + } + + disabled, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateActionDisable) + if err != nil { + t.Fatalf("disable marketplace plugin: %v", err) + } + if disabled.Status != domain.GamePluginStatusDisabled { + t.Fatalf("expected disabled status, got %+v", disabled) + } + enabled, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateActionEnable) + if err != nil { + t.Fatalf("enable marketplace plugin: %v", err) + } + if enabled.Status != domain.GamePluginStatusInstalled { + t.Fatalf("expected installed status after enable, got %+v", enabled) + } + + missing, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{Keyword: "missing"}) + if err != nil || len(missing) != 0 { + t.Fatalf("expected empty keyword result, len=%d err=%v", len(missing), err) + } + if _, err := svc.GetMarketplacePlugin("missing"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected missing plugin error, got %v", err) + } + if _, err := svc.SetMarketplacePluginState("game.example", domain.PluginMarketplaceStateAction("download")); err == nil || !strings.Contains(err.Error(), "action is not supported") { + t.Fatalf("expected unsupported action validation, got %v", err) + } + if _, err := svc.ListMarketplacePlugins(domain.PluginMarketplaceFilter{Keyword: "sk-raw-secret"}); err == nil || !strings.Contains(err.Error(), "raw credential") { + t.Fatalf("expected unsafe keyword validation, got %v", err) + } +} + +func TestCoreServiceAuthorizesPluginBridgeActions(t *testing.T) { + svc := newTestCoreService() + if _, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()); err != nil { + t.Fatalf("register manifest: %v", err) + } + + allowed, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: domain.PluginBridgeActionLogsQuery, + }) + if err != nil { + t.Fatalf("authorize logs query: %v", err) + } + if !allowed.Allowed || allowed.RequiredPermissions[0] != "server.logs.read" { + t.Fatalf("expected allowed logs bridge action, got %+v", allowed) + } + + missingPermission, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: domain.PluginBridgeActionFilesRequest, + }) + if err != nil { + t.Fatalf("authorize files request: %v", err) + } + if missingPermission.Allowed || !strings.Contains(missingPermission.Reason, "required permission") { + t.Fatalf("expected missing permission denial, got %+v", missingPermission) + } + + aiAllowed, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: domain.PluginBridgeActionAIInvoke, + AIPurpose: "logs.diagnose", + }) + if err != nil { + t.Fatalf("authorize AI request: %v", err) + } + if !aiAllowed.Allowed { + t.Fatalf("expected allowed AI bridge action, got %+v", aiAllowed) + } + + aiDenied, err := svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: domain.PluginBridgeActionAIInvoke, + AIPurpose: "config.suggest", + }) + if err != nil { + t.Fatalf("authorize undeclared AI request: %v", err) + } + if aiDenied.Allowed || !strings.Contains(aiDenied.Reason, "ai purpose") { + t.Fatalf("expected undeclared AI purpose denial, got %+v", aiDenied) + } + + _, err = svc.AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest{ + PluginID: "game.example", + RouteKey: "logs", + Action: domain.PluginBridgeAction("direct.run.socket"), + }) + if err == nil || !strings.Contains(err.Error(), "action is not supported") { + t.Fatalf("expected unsupported action validation error, got %v", err) + } +} + +func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) { + svc := newTestCoreService() + registration := validPluginManifestRegistration() + if _, err := svc.RegisterGamePluginManifest(registration); err != nil { + t.Fatalf("register first manifest: %v", err) + } + + _, err := svc.RegisterGamePluginManifest(registration) + if !errors.Is(err, repo.ErrDuplicate) { + t.Fatalf("expected duplicate plugin registration, got %v", err) + } +} + +func TestCoreServiceRejectsUnsafeGamePluginManifest(t *testing.T) { + svc := newTestCoreService() + registration := validPluginManifestRegistration() + registration.Manifest.Description = "requires direct run socket and raw AI key" + + _, err := svc.RegisterGamePluginManifest(registration) + if err == nil || !strings.Contains(err.Error(), "direct run access") || !strings.Contains(err.Error(), "raw credential") { + t.Fatalf("expected unsafe manifest rejection, got %v", err) + } +} + +func TestCoreServiceRejectsInvalidAIProviderManagement(t *testing.T) { + svc := newTestCoreService() + provider := validProvider() + if _, err := svc.CreateAIProvider(provider); err != nil { + t.Fatalf("create provider: %v", err) + } + + provider.APIKeyRef = "sk-raw-secret" + _, err := svc.UpdateAIProvider(provider.ID, provider) + if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") { + t.Fatalf("expected raw secret rejection, got %v", err) + } + + _, err = svc.SetAIProviderStatus(provider.ID, domain.AIProviderStatusError) + if err == nil || !strings.Contains(err.Error(), "status must be active or disabled") { + t.Fatalf("expected invalid status rejection, got %v", err) + } + + _, err = svc.UpdateAIProvider("missing", provider) + if !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected missing update target, got %v", err) + } + _, err = svc.TestAIProvider("missing") + if !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected missing test target, got %v", err) + } + _, err = svc.ListAIProviderModels("missing") + if !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected missing models target, got %v", err) + } +} + +func TestCoreServiceReturnsExistingJobForDuplicateIdempotencyKey(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + instance, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-1", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "SCUM #1", + }) + if err != nil { + t.Fatalf("create server instance: %v", err) + } + + first, err := svc.CreateJob(domain.Job{ + ID: "job-1", + ServerInstanceID: instance.ID, + RunEndpointID: endpoint.ID, + Capability: "process.start", + IdempotencyKey: "idem-start", + }) + if err != nil { + t.Fatalf("create first job: %v", err) + } + second, err := svc.CreateJob(domain.Job{ + ID: "job-2", + ServerInstanceID: instance.ID, + RunEndpointID: endpoint.ID, + Capability: "process.start", + IdempotencyKey: "idem-start", + }) + if err != nil { + t.Fatalf("create second job: %v", err) + } + if second.ID != first.ID { + t.Fatalf("expected idempotent job %q, got %q", first.ID, second.ID) + } + + jobs, err := svc.ListJobs(domain.JobFilter{RunEndpointID: endpoint.ID}) + if err != nil { + t.Fatalf("list jobs: %v", err) + } + if len(jobs) != 1 { + t.Fatalf("expected one stored job, got %+v", jobs) + } +} + +func TestCoreServiceRejectsJobTargetMismatch(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + otherEndpoint := endpoint + otherEndpoint.ID = "run-other" + if _, err := svc.CreateRunEndpoint(otherEndpoint); err != nil { + t.Fatalf("create other endpoint: %v", err) + } + instance, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-1", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "SCUM #1", + }) + if err != nil { + t.Fatalf("create server instance: %v", err) + } + + _, err = svc.CreateJob(domain.Job{ + ID: "job-1", + ServerInstanceID: instance.ID, + RunEndpointID: otherEndpoint.ID, + Capability: "process.start", + IdempotencyKey: "idem-start", + }) + if err == nil || !strings.Contains(err.Error(), "job runEndpointId must match server instance") { + t.Fatalf("expected target mismatch rejection, got %v", err) + } +} + +func TestCoreServicePropagatesDuplicateErrors(t *testing.T) { + svc := newTestCoreService() + user := domain.User{ID: "user-1", DisplayName: "Operator", Status: domain.UserStatusActive} + if _, err := svc.CreateUser(user); err != nil { + t.Fatalf("create user: %v", err) + } + _, err := svc.CreateUser(user) + if !errors.Is(err, repo.ErrDuplicate) { + t.Fatalf("expected duplicate error, got %v", err) + } +} + +func newTestCoreService() *CoreService { + return newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime }) +} + +func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlugin, domain.RunEndpoint) { + t.Helper() + + plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ + ID: "server.scum", + Name: "SCUM", + Version: "1.0.0", + ServerType: "scum", + ManifestRef: "artifact://manifests/server.scum/1.0.0", + CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", + RequiredRunCapabilities: []string{"process.install", "process.start", "process.stop", "logs.read"}, + DeclaredPermissions: []string{"server.files.read", "server.files.write"}, + LifecycleActions: domain.PluginLifecycleActions{ + Install: "actions/install.json", + Start: "actions/start.json", + Stop: "actions/stop.json", + }, + Permissions: domain.PluginPermissions{ + Logs: true, + Files: true, + Jobs: true, + }, + }) + if err != nil { + t.Fatalf("create plugin fixture: %v", err) + } + + endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ + ID: "run-local", + DisplayName: "Local Run", + Version: "0.1.0", + Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write"}, + Capacity: domain.RunCapacity{MaxJobs: 4}, + }) + if err != nil { + t.Fatalf("create run endpoint fixture: %v", err) + } + + return plugin, endpoint +} + +func validPluginManifestRegistration() domain.GamePluginManifestRegistration { + return domain.GamePluginManifestRegistration{ + ManifestRef: "artifact://manifests/game.example/0.1.0", + Manifest: domain.GamePluginManifest{ + ID: "game.example", + Name: "Example Server", + Description: "Development plugin", + Version: "0.1.0", + Kind: "game-plugin", + Tags: []string{"example", "development"}, + Server: domain.GamePluginManifestServer{ + Type: "example", + DisplayName: "Example Server", + SupportedOS: []string{"linux", "darwin"}, + CreateFormSchema: "schemas/create-form.schema.json", + }, + Bridge: domain.GamePluginBridge{ + Actions: []string{ + string(domain.PluginBridgeActionServerInstancesRead), + string(domain.PluginBridgeActionLogsQuery), + string(domain.PluginBridgeActionFilesRequest), + string(domain.PluginBridgeActionAIInvoke), + }, + }, + Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}, + Permissions: []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"}, + Actions: domain.PluginLifecycleActions{ + Install: "actions/install.json", + Start: "actions/start.json", + Stop: "actions/stop.json", + Restart: "actions/restart.json", + }, + Pages: []domain.GamePluginPage{ + { + Key: "logs", + Title: "Logs", + Path: "/logs", + Permissions: []string{"server.logs.read", "ai.invoke"}, + BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)}, + }, + }, + AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}}, + }, + } +} + +func validProvider() domain.AIProvider { + return domain.AIProvider{ + ID: "ai.openai", + Name: "OpenAI", + Kind: domain.AIProviderKindOpenAI, + BaseURL: "https://api.openai.com/v1", + APIKeyRef: "secret://providers/openai", + Models: []string{"gpt-4.1", "gpt-4.1-mini"}, + DefaultModel: "gpt-4.1", + RelayMode: domain.AIRelayModeDirect, + TimeoutMS: 30000, + RedactionPolicy: "default", + } +} diff --git a/platform/service/server_access.go b/platform/service/server_access.go new file mode 100644 index 0000000..3747ff0 --- /dev/null +++ b/platform/service/server_access.go @@ -0,0 +1,56 @@ +package service + +import "browser.local/platform/domain" + +func (svc *CoreService) authorizeServerLifecycle(sessionID string, serverInstanceID string) error { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return err + } + instance, err := svc.store.ServerInstances().Get(serverInstanceID) + if err != nil { + return err + } + if !canAccessServer(user, instance) { + return ErrForbidden + } + return nil +} + +func (svc *CoreService) requireServerOwner(sessionID string, serverInstanceID string) (domain.User, domain.ServerInstance, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.User{}, domain.ServerInstance{}, err + } + instance, err := svc.store.ServerInstances().Get(serverInstanceID) + if err != nil { + return domain.User{}, domain.ServerInstance{}, err + } + if instance.OwnerUserID != user.ID { + return domain.User{}, domain.ServerInstance{}, ErrForbidden + } + return user, instance, nil +} + +func canAccessServer(user domain.User, instance domain.ServerInstance) bool { + return isPlatformAdmin(user) || instance.OwnerUserID == user.ID || containsString(instance.AdminUserIDs, user.ID) +} + +func isPlatformAdmin(user domain.User) bool { + for _, role := range user.Roles { + switch role { + case "admin", "platform-admin", "platformadmin": + return true + } + } + return false +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go new file mode 100644 index 0000000..7e7e250 --- /dev/null +++ b/platform/service/server_lifecycle.go @@ -0,0 +1,233 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) { + create = domain.CopyServerLifecycleCreate(create) + if err := validator.ValidateServerLifecycleCreate(create); err != nil { + return domain.ServerLifecycleResult{}, err + } + + plugin, endpoint, err := svc.lifecycleDependencies(create.PluginID, create.RunEndpointID) + if err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil { + return domain.ServerLifecycleResult{}, err + } + + stamp := svc.now() + instance := domain.ServerInstance{ + ID: create.ID, + PluginID: create.PluginID, + PluginVersion: plugin.Version, + RunEndpointID: create.RunEndpointID, + Name: create.Name, + OwnerUserID: create.OwnerUserID, + State: domain.ServerInstanceStateInstalling, + ConfigVersion: 1, + CreatedAt: stamp, + UpdatedAt: stamp, + } + if err := validator.ValidateServerInstance(instance); err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := svc.store.ServerInstances().Create(instance); err != nil { + return domain.ServerLifecycleResult{}, err + } + + job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey) + if err != nil { + return domain.ServerLifecycleResult{}, err + } + return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{ + Accepted: true, + Action: domain.ServerLifecycleActionCreate, + Instance: instance, + Job: job, + }), nil +} + +func (svc *CoreService) CreateServerInstanceWorkflowForSession(sessionID string, create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.ServerLifecycleResult{}, err + } + if strings.TrimSpace(create.OwnerUserID) == "" { + create.OwnerUserID = user.ID + } + if !isPlatformAdmin(user) && create.OwnerUserID != user.ID { + return domain.ServerLifecycleResult{}, ErrForbidden + } + return svc.CreateServerInstanceWorkflow(create) +} + +func (svc *CoreService) StartServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { + return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStart, []domain.ServerInstanceState{ + domain.ServerInstanceStateReady, + domain.ServerInstanceStateStopped, + }) +} + +func (svc *CoreService) StartServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { + if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { + return domain.ServerLifecycleResult{}, err + } + return svc.StartServerInstance(command) +} + +func (svc *CoreService) StopServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { + return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStop, []domain.ServerInstanceState{ + domain.ServerInstanceStateRunning, + }) +} + +func (svc *CoreService) StopServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { + if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { + return domain.ServerLifecycleResult{}, err + } + return svc.StopServerInstance(command) +} + +func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLifecycleCommand, action domain.ServerLifecycleAction, allowedStates []domain.ServerInstanceState) (domain.ServerLifecycleResult, error) { + command = domain.CopyServerLifecycleCommand(command) + if err := validator.ValidateServerLifecycleCommand(command); err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := validator.ValidateServerLifecycleAction(action); err != nil { + return domain.ServerLifecycleResult{}, err + } + + instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID) + if err != nil { + return domain.ServerLifecycleResult{}, err + } + if instance.ConfigVersion != command.ExpectedConfigVersion { + return domain.ServerLifecycleResult{}, validationError("expectedConfigVersion must match server instance") + } + if !serverStateAllowed(instance.State, allowedStates) { + return domain.ServerLifecycleResult{}, validationError(fmt.Sprintf("server instance state %q cannot %s", instance.State, action)) + } + + plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID) + if err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := validateLifecycleActionRef(plugin, action); err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(action)); err != nil { + return domain.ServerLifecycleResult{}, err + } + + job, err := svc.dispatchLifecycleJob(instance, action, command.IdempotencyKey) + if err != nil { + return domain.ServerLifecycleResult{}, err + } + return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{ + Accepted: true, + Action: action, + Instance: instance, + Job: job, + }), nil +} + +func (svc *CoreService) lifecycleDependencies(pluginID string, runEndpointID string) (domain.GamePlugin, domain.RunEndpoint, error) { + plugin, err := svc.store.GamePlugins().Get(pluginID) + if err != nil { + return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get plugin dependency: %w", err) + } + endpoint, err := svc.store.RunEndpoints().Get(runEndpointID) + if err != nil { + return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get run endpoint dependency: %w", err) + } + return plugin, endpoint, nil +} + +func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, action domain.ServerLifecycleAction, idempotencyKey string) (domain.Job, error) { + capability := domain.LifecycleCapabilityForAction(action) + job, err := svc.CreateJob(domain.Job{ + ID: lifecycleJobID(instance.ID, action, idempotencyKey), + ServerInstanceID: instance.ID, + RunEndpointID: instance.RunEndpointID, + Capability: capability, + IdempotencyKey: idempotencyKey, + }) + if err != nil { + return domain.Job{}, err + } + if job.ServerInstanceID != instance.ID || job.RunEndpointID != instance.RunEndpointID || job.Capability != capability { + return domain.Job{}, validationError("idempotencyKey is already used for a different lifecycle target") + } + return job, nil +} + +func (svc *CoreService) validateLifecycleIdempotency(runEndpointID string, idempotencyKey string, serverInstanceID string, capability string) error { + existing, err := svc.store.Jobs().GetByIdempotency(runEndpointID, idempotencyKey) + if errors.Is(err, repo.ErrNotFound) { + return nil + } + if err != nil { + return err + } + if existing.ServerInstanceID == serverInstanceID && existing.Capability == capability { + return nil + } + return validationError("idempotencyKey is already used for a different lifecycle target") +} + +func validateLifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleAction) error { + if strings.TrimSpace(lifecycleActionRef(plugin, action)) == "" { + return validationError(fmt.Sprintf("plugin %s lifecycle action is required", action)) + } + return nil +} + +func lifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleAction) string { + switch action { + case domain.ServerLifecycleActionCreate: + return plugin.LifecycleActions.Install + case domain.ServerLifecycleActionStart: + return plugin.LifecycleActions.Start + case domain.ServerLifecycleActionStop: + return plugin.LifecycleActions.Stop + default: + return "" + } +} + +func serverStateAllowed(state domain.ServerInstanceState, allowed []domain.ServerInstanceState) bool { + for _, candidate := range allowed { + if state == candidate { + return true + } + } + return false +} + +func lifecycleJobID(serverInstanceID string, action domain.ServerLifecycleAction, idempotencyKey string) string { + sum := sha256.Sum256([]byte(idempotencyKey)) + return fmt.Sprintf("server-lifecycle:%s:%s:%s", serverInstanceID, action, hex.EncodeToString(sum[:8])) +} diff --git a/platform/service/server_lifecycle_projection.go b/platform/service/server_lifecycle_projection.go new file mode 100644 index 0000000..03ad2fd --- /dev/null +++ b/platform/service/server_lifecycle_projection.go @@ -0,0 +1,47 @@ +package service + +import ( + "time" + + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error { + nextState, ok := lifecycleProjectedState(job.Capability, job.State) + if !ok || job.ServerInstanceID == "" { + return nil + } + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if err != nil { + return err + } + instance.State = nextState + instance.UpdatedAt = stamp + if err := validator.ValidateServerInstance(instance); err != nil { + return err + } + return svc.store.ServerInstances().Update(instance) +} + +func lifecycleProjectedState(capability string, jobState domain.JobState) (domain.ServerInstanceState, bool) { + if capability != domain.LifecycleCapabilityInstall && capability != domain.LifecycleCapabilityStart && capability != domain.LifecycleCapabilityStop { + return "", false + } + if jobState == domain.JobStateFailed || jobState == domain.JobStateCancelled { + return domain.ServerInstanceStateFailed, true + } + if jobState != domain.JobStateSucceeded { + return "", false + } + switch capability { + case domain.LifecycleCapabilityInstall: + return domain.ServerInstanceStateReady, true + case domain.LifecycleCapabilityStart: + return domain.ServerInstanceStateRunning, true + case domain.LifecycleCapabilityStop: + return domain.ServerInstanceStateStopped, true + default: + return "", false + } +} diff --git a/platform/service/server_lifecycle_test.go b/platform/service/server_lifecycle_test.go new file mode 100644 index 0000000..855dd4a --- /dev/null +++ b/platform/service/server_lifecycle_test.go @@ -0,0 +1,294 @@ +package service + +import ( + "strings" + "testing" + + "browser.local/platform/domain" +) + +func TestCoreServiceServerLifecycleWorkflows(t *testing.T) { + svc, sessionToken := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + + created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ + ID: "server-1", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "SCUM #1", + IdempotencyKey: "idem-create", + }) + if err != nil { + t.Fatalf("create lifecycle workflow: %v", err) + } + if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall { + t.Fatalf("expected install workflow result, got %+v", created) + } + + claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded) + ready, err := svc.GetServerInstance("server-1") + if err != nil { + t.Fatalf("get ready instance: %v", err) + } + if ready.State != domain.ServerInstanceStateReady { + t.Fatalf("expected install result to mark ready, got %+v", ready) + } + + started, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ + ServerInstanceID: "server-1", + ExpectedConfigVersion: ready.ConfigVersion, + IdempotencyKey: "idem-start", + }) + if err != nil { + t.Fatalf("start lifecycle workflow: %v", err) + } + if started.Action != domain.ServerLifecycleActionStart || started.Job.Capability != domain.LifecycleCapabilityStart { + t.Fatalf("expected start workflow result, got %+v", started) + } + claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStart, domain.JobStateSucceeded) + running, err := svc.GetServerInstance("server-1") + if err != nil { + t.Fatalf("get running instance: %v", err) + } + if running.State != domain.ServerInstanceStateRunning { + t.Fatalf("expected start result to mark running, got %+v", running) + } + + stopped, err := svc.StopServerInstance(domain.ServerLifecycleCommand{ + ServerInstanceID: "server-1", + ExpectedConfigVersion: running.ConfigVersion, + IdempotencyKey: "idem-stop", + }) + if err != nil { + t.Fatalf("stop lifecycle workflow: %v", err) + } + if stopped.Action != domain.ServerLifecycleActionStop || stopped.Job.Capability != domain.LifecycleCapabilityStop { + t.Fatalf("expected stop workflow result, got %+v", stopped) + } + claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStop, domain.JobStateSucceeded) + final, err := svc.GetServerInstance("server-1") + if err != nil { + t.Fatalf("get stopped instance: %v", err) + } + if final.State != domain.ServerInstanceStateStopped { + t.Fatalf("expected stop result to mark stopped, got %+v", final) + } +} + +func TestCoreServiceServerLifecycleRejectsInvalidCommands(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + instance, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-ready", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "Ready Server", + State: domain.ServerInstanceStateReady, + }) + if err != nil { + t.Fatalf("create ready server: %v", err) + } + + _, err = svc.StartServerInstance(domain.ServerLifecycleCommand{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion + 1, + IdempotencyKey: "idem-stale", + }) + if err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") { + t.Fatalf("expected stale config rejection, got %v", err) + } + + _, err = svc.StopServerInstance(domain.ServerLifecycleCommand{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + IdempotencyKey: "idem-stop-invalid", + }) + if err == nil || !strings.Contains(err.Error(), "cannot stop") { + t.Fatalf("expected invalid stop state rejection, got %v", err) + } + + weakEndpoint := endpoint + weakEndpoint.ID = "run-no-stop" + weakEndpoint.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, "logs.read"} + if _, err := svc.CreateRunEndpoint(weakEndpoint); err != nil { + t.Fatalf("create weak endpoint: %v", err) + } + running, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-running", + PluginID: plugin.ID, + RunEndpointID: weakEndpoint.ID, + Name: "Running Server", + State: domain.ServerInstanceStateRunning, + }) + if err == nil { + _, err = svc.StopServerInstance(domain.ServerLifecycleCommand{ + ServerInstanceID: running.ID, + ExpectedConfigVersion: running.ConfigVersion, + IdempotencyKey: "idem-stop-missing-capability", + }) + } + if err == nil || !strings.Contains(err.Error(), domain.LifecycleCapabilityStop) { + t.Fatalf("expected missing stop capability rejection, got %v", err) + } +} + +func TestCoreServiceLifecycleFailureProjectsFailedState(t *testing.T) { + svc, sessionToken := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ + ID: "server-1", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "SCUM #1", + IdempotencyKey: "idem-create", + }); err != nil { + t.Fatalf("create lifecycle workflow: %v", err) + } + + claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityInstall, domain.JobStateFailed) + instance, err := svc.GetServerInstance("server-1") + if err != nil { + t.Fatalf("get failed instance: %v", err) + } + if instance.State != domain.ServerInstanceStateFailed { + t.Fatalf("expected failed install result to mark failed, got %+v", instance) + } +} + +func TestCoreServicePluginLifecycleManagesMultipleInstancesIndependently(t *testing.T) { + svc, sessionToken := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + + for _, id := range []string{"server-alpha", "server-beta"} { + if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ + ID: id, + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: id, + IdempotencyKey: "idem-create-" + id, + }); err != nil { + t.Fatalf("create %s: %v", id, err) + } + claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, id, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded) + } + + alpha, err := svc.GetServerInstance("server-alpha") + if err != nil { + t.Fatalf("get alpha: %v", err) + } + beta, err := svc.GetServerInstance("server-beta") + if err != nil { + t.Fatalf("get beta: %v", err) + } + if alpha.State != domain.ServerInstanceStateReady || beta.State != domain.ServerInstanceStateReady || alpha.ID == beta.ID || alpha.PluginID != beta.PluginID { + t.Fatalf("expected distinct ready sibling instances, alpha=%+v beta=%+v", alpha, beta) + } + + if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ + ServerInstanceID: alpha.ID, + ExpectedConfigVersion: alpha.ConfigVersion, + IdempotencyKey: "idem-start-alpha", + }); err != nil { + t.Fatalf("start alpha: %v", err) + } + claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, alpha.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded) + + alpha, _ = svc.GetServerInstance("server-alpha") + beta, _ = svc.GetServerInstance("server-beta") + if alpha.State != domain.ServerInstanceStateRunning || beta.State != domain.ServerInstanceStateReady { + t.Fatalf("expected alpha running and beta unchanged, alpha=%+v beta=%+v", alpha, beta) + } + + if _, err := svc.StopServerInstance(domain.ServerLifecycleCommand{ + ServerInstanceID: alpha.ID, + ExpectedConfigVersion: alpha.ConfigVersion, + IdempotencyKey: "idem-stop-alpha", + }); err != nil { + t.Fatalf("stop alpha: %v", err) + } + claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, alpha.ID, domain.LifecycleCapabilityStop, domain.JobStateSucceeded) + + alpha, _ = svc.GetServerInstance("server-alpha") + beta, _ = svc.GetServerInstance("server-beta") + if alpha.State != domain.ServerInstanceStateStopped || beta.State != domain.ServerInstanceStateReady { + t.Fatalf("expected alpha stopped and beta still unchanged, alpha=%+v beta=%+v", alpha, beta) + } +} + +func newLifecycleRunService(t *testing.T) (*CoreService, string) { + t.Helper() + svc := newTestCoreService() + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, + domain.LifecycleCapabilityInstall, + domain.LifecycleCapabilityStart, + domain.LifecycleCapabilityStop, + "logs.read", + "files.read", + ) + helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register run hello: %v", err) + } + return svc, hello.SessionToken +} + +func createLifecyclePlugin(t *testing.T, svc *CoreService) domain.GamePlugin { + t.Helper() + plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ + ID: "server.scum", + Name: "SCUM", + Version: "1.0.0", + ServerType: "scum", + ManifestRef: "artifact://manifests/server.scum/1.0.0", + CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", + RequiredRunCapabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "logs.read"}, + LifecycleActions: domain.PluginLifecycleActions{ + Install: "actions/install.json", + Start: "actions/start.json", + Stop: "actions/stop.json", + }, + Permissions: domain.PluginPermissions{Jobs: true, Logs: true}, + }) + if err != nil { + t.Fatalf("create lifecycle plugin: %v", err) + } + return plugin +} + +func claimAndCompleteLifecycleJob(t *testing.T, svc *CoreService, sessionToken string, capability string, state domain.JobState) { + t.Helper() + claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, "", capability, state) +} + +func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessionToken string, serverInstanceID string, capability string, state domain.JobState) { + t.Helper() + claim, err := svc.ClaimRunJob(domain.RunJobClaim{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + Capabilities: []string{capability}, + Capacity: domain.RunCapacity{MaxJobs: 4}, + }) + if err != nil { + t.Fatalf("claim lifecycle job %s: %v", capability, err) + } + if !claim.HasJob || claim.Job.Capability != capability { + t.Fatalf("expected claimed lifecycle job %s, got %+v", capability, claim) + } + if serverInstanceID != "" && claim.Job.ServerInstanceID != serverInstanceID { + t.Fatalf("expected claimed lifecycle job for %s, got %+v", serverInstanceID, claim.Job) + } + if _, err := svc.CompleteRunJob(domain.RunJobResult{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + State: state, + Progress: domain.RunJobProgressReport{Percent: 100, Message: string(state)}, + Message: string(state), + }); err != nil { + t.Fatalf("complete lifecycle job %s: %v", capability, err) + } +} diff --git a/platform/validator/ai_invocation.go b/platform/validator/ai_invocation.go new file mode 100644 index 0000000..26aeab0 --- /dev/null +++ b/platform/validator/ai_invocation.go @@ -0,0 +1,103 @@ +package validator + +import ( + "fmt" + "strings" + + "browser.local/platform/domain" +) + +const ( + maxAIInvocationPromptSize = 8000 + maxAIInvocationConfigSize = 64 * 1024 + maxAIInvocationContextRefs = 12 +) + +func ValidateAIInvocationRequest(request domain.AIInvocationRequest) error { + request = domain.CopyAIInvocationRequest(request) + var violations []string + violations = appendRequired(violations, "requestId", request.RequestID) + violations = appendRequired(violations, "purpose", request.Purpose) + violations = appendRequired(violations, "prompt", request.Prompt) + if request.Purpose != "" && !validAIPurpose(request.Purpose) { + violations = append(violations, "purpose is not allowed") + } + if len([]byte(request.Prompt)) > maxAIInvocationPromptSize { + violations = append(violations, "prompt is too large") + } + if len([]byte(request.CurrentConfig)) > maxAIInvocationConfigSize { + violations = append(violations, "currentConfig is too large") + } + if request.ProviderID != "" && !safeIdentifier(request.ProviderID) { + violations = append(violations, "providerId is invalid") + } + if request.Model != "" && unsafeAIString(request.Model) { + violations = append(violations, "model is unsafe") + } + if len(request.ContextRefs) > maxAIInvocationContextRefs { + violations = append(violations, "contextRefs has too many keys") + } + for key, value := range request.ContextRefs { + if strings.TrimSpace(key) == "" || key != strings.TrimSpace(key) { + violations = append(violations, "contextRefs key is invalid") + } + if !validAIContextRef(value) { + violations = append(violations, fmt.Sprintf("contextRefs[%s] is invalid", key)) + } + } + for _, value := range []fieldString{ + {field: "requestId", value: request.RequestID}, + {field: "prompt", value: request.Prompt}, + {field: "currentConfig", value: request.CurrentConfig}, + {field: "providerId", value: request.ProviderID}, + {field: "model", value: request.Model}, + } { + if unsafeAIString(value.value) { + violations = append(violations, value.field+" contains unsafe content") + } + } + return finish(violations) +} + +func ValidateAIInvocationResponse(response domain.AIInvocationResponse) error { + var violations []string + violations = appendRequired(violations, "requestId", response.RequestID) + violations = appendRequired(violations, "purpose", response.Purpose) + violations = appendRequired(violations, "status", response.Status) + if unsafeAIString(response.Recommendation) { + violations = append(violations, "recommendation contains unsafe content") + } + if response.ConfigRecommendation != nil && unsafeAIString(response.ConfigRecommendation.SuggestedConfig) { + violations = append(violations, "configRecommendation contains unsafe content") + } + if response.Error != nil && unsafeAIString(response.Error.Message) { + violations = append(violations, "error message contains unsafe content") + } + return finish(violations) +} + +func validAIContextRef(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" || trimmed != value || unsafeAIString(value) { + return false + } + return strings.HasPrefix(value, "server://") || strings.HasPrefix(value, "log://") || strings.HasPrefix(value, "artifact://") || strings.HasPrefix(value, "input://") +} + +func safeIdentifier(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" || trimmed != value || len([]rune(value)) > 160 { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' { + continue + } + return false + } + return true +} + +func unsafeAIString(value string) bool { + return containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "provider base url secret") +} diff --git a/platform/validator/artifact_download.go b/platform/validator/artifact_download.go new file mode 100644 index 0000000..fc3916d --- /dev/null +++ b/platform/validator/artifact_download.go @@ -0,0 +1,140 @@ +package validator + +import ( + "fmt" + "strings" + + "browser.local/platform/domain" +) + +const MaxArtifactDownloadBytes = MaxArtifactChunkBytes + +func ValidateArtifactDownloadReferenceRequest(request domain.ArtifactDownloadReferenceRequest) error { + var violations []string + violations = appendRequired(violations, "artifactId", request.ArtifactID) + violations = appendArtifactIDViolations(violations, request.ArtifactID) + return finish(violations) +} + +func ValidateArtifactDownloadReference(reference domain.ArtifactDownloadReference) error { + var violations []string + violations = appendRequired(violations, "artifactId", reference.ArtifactID) + violations = appendRequired(violations, "ownerId", reference.OwnerID) + violations = appendRequired(violations, "filename", reference.Filename) + violations = appendRequired(violations, "contentType", reference.ContentType) + violations = appendRequired(violations, "checksum", reference.Checksum) + violations = appendRequired(violations, "downloadUrl", reference.DownloadURL) + violations = appendRequired(violations, "storageBehavior", reference.StorageBehavior) + violations = appendArtifactIDViolations(violations, reference.ArtifactID) + if !validArtifactOwnerKind(reference.OwnerKind) { + violations = append(violations, "ownerKind is invalid") + } + if reference.State != domain.ArtifactStateAvailable { + violations = append(violations, "state must be available") + } + if reference.SizeBytes <= 0 { + violations = append(violations, "sizeBytes must be positive") + } + if reference.Checksum != "" && !validSHA256Checksum(reference.Checksum) { + violations = append(violations, "checksum must be sha256:") + } + if reference.ChunkSizeBytes <= 0 || reference.ChunkSizeBytes > MaxArtifactDownloadBytes { + violations = append(violations, fmt.Sprintf("chunkSizeBytes must be between 1 and %d", MaxArtifactDownloadBytes)) + } + if reference.ExpiresAt.IsZero() { + violations = append(violations, "expiresAt is required") + } + for _, value := range []fieldString{ + {field: "filename", value: reference.Filename}, + {field: "contentType", value: reference.ContentType}, + {field: "downloadUrl", value: reference.DownloadURL}, + {field: "storageBehavior", value: reference.StorageBehavior}, + } { + if unsafeArtifactString(value.value) { + violations = append(violations, value.field+" contains unsafe content") + } + } + if !strings.HasPrefix(reference.DownloadURL, "/api/v1/artifacts/") || !strings.HasSuffix(reference.DownloadURL, "/content") { + violations = append(violations, "downloadUrl must be a platform artifact content route") + } + return finish(violations) +} + +func ValidateArtifactContentRequest(request domain.ArtifactContentRequest) error { + var violations []string + violations = appendRequired(violations, "artifactId", request.ArtifactID) + violations = appendArtifactIDViolations(violations, request.ArtifactID) + if request.Offset < 0 { + violations = append(violations, "offset must not be negative") + } + if request.Limit < 0 { + violations = append(violations, "limit must not be negative") + } + if request.Limit > MaxArtifactDownloadBytes { + violations = append(violations, fmt.Sprintf("limit must not exceed %d", MaxArtifactDownloadBytes)) + } + return finish(violations) +} + +func ValidateArtifactContent(content domain.ArtifactContent) error { + content = domain.CopyArtifactContent(content) + var violations []string + violations = appendRequired(violations, "artifactId", content.ArtifactID) + violations = appendRequired(violations, "filename", content.Filename) + violations = appendRequired(violations, "contentType", content.ContentType) + violations = appendRequired(violations, "checksum", content.Checksum) + violations = appendRequired(violations, "contentChecksum", content.ContentChecksum) + violations = appendArtifactIDViolations(violations, content.ArtifactID) + if content.Offset < 0 { + violations = append(violations, "offset must not be negative") + } + if content.SizeBytes < 0 { + violations = append(violations, "sizeBytes must not be negative") + } + if content.TotalSizeBytes <= 0 { + violations = append(violations, "totalSizeBytes must be positive") + } + if content.SizeBytes > MaxArtifactDownloadBytes { + violations = append(violations, fmt.Sprintf("sizeBytes must not exceed %d", MaxArtifactDownloadBytes)) + } + if int64(len(content.Payload)) != content.SizeBytes { + violations = append(violations, "payload size must match sizeBytes") + } + if content.Offset+content.SizeBytes > content.TotalSizeBytes { + violations = append(violations, "range exceeds artifact size") + } + if content.Checksum != "" && !validSHA256Checksum(content.Checksum) { + violations = append(violations, "checksum must be sha256:") + } + if content.ContentChecksum != "" && content.ContentChecksum != BytesChecksum(content.Payload) { + violations = append(violations, "contentChecksum does not match payload") + } + for _, value := range []fieldString{ + {field: "filename", value: content.Filename}, + {field: "contentType", value: content.ContentType}, + {field: "storageBehavior", value: content.StorageBehavior}, + } { + if unsafeArtifactString(value.value) { + violations = append(violations, value.field+" contains unsafe content") + } + } + return finish(violations) +} + +func appendArtifactIDViolations(violations []string, artifactID string) []string { + trimmed := strings.TrimSpace(artifactID) + if trimmed == "" { + return violations + } + if trimmed != artifactID || len([]rune(trimmed)) > 120 || strings.Contains(trimmed, "/") || strings.Contains(trimmed, `\`) || strings.Contains(trimmed, "://") || strings.Contains(trimmed, "..") { + violations = append(violations, "artifactId is invalid") + } + if unsafeArtifactString(trimmed) { + violations = append(violations, "artifactId contains unsafe content") + } + return violations +} + +func unsafeArtifactString(value string) bool { + return containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "file://") +} diff --git a/platform/validator/artifact_transfer.go b/platform/validator/artifact_transfer.go new file mode 100644 index 0000000..e5ab279 --- /dev/null +++ b/platform/validator/artifact_transfer.go @@ -0,0 +1,121 @@ +package validator + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "browser.local/platform/domain" +) + +const MaxArtifactChunkBytes = 1024 * 1024 + +func ValidateArtifactTransferOpen(open domain.ArtifactTransferOpen) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", open.RunEndpointID) + violations = appendRequired(violations, "sessionToken", open.SessionToken) + violations = appendRequired(violations, "artifactId", open.ArtifactID) + violations = appendRequired(violations, "ownerId", open.OwnerID) + violations = appendRequired(violations, "checksum", open.Checksum) + violations = appendRequired(violations, "idempotencyKey", open.IdempotencyKey) + if open.Direction != domain.ArtifactTransferDirectionUpload { + violations = append(violations, "direction must be upload") + } + if !validArtifactOwnerKind(open.OwnerKind) { + violations = append(violations, "ownerKind is invalid") + } + if open.OwnerKind != domain.ArtifactOwnerKindJob && open.OwnerKind != domain.ArtifactOwnerKindServerInstance { + violations = append(violations, "ownerKind must be job or server-instance for run uploads") + } + if open.SizeBytes <= 0 { + violations = append(violations, "sizeBytes must be positive") + } + if open.ChunkSizeBytes <= 0 { + violations = append(violations, "chunkSizeBytes must be positive") + } + if open.ChunkSizeBytes > MaxArtifactChunkBytes { + violations = append(violations, fmt.Sprintf("chunkSizeBytes must not exceed %d", MaxArtifactChunkBytes)) + } + if open.Checksum != "" && !validSHA256Checksum(open.Checksum) { + violations = append(violations, "checksum must be sha256:") + } + return finish(violations) +} + +func ValidateArtifactChunkUpload(chunk domain.ArtifactChunkUpload) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", chunk.RunEndpointID) + violations = appendRequired(violations, "sessionToken", chunk.SessionToken) + violations = appendRequired(violations, "transferId", chunk.TransferID) + violations = appendRequired(violations, "artifactId", chunk.ArtifactID) + violations = appendRequired(violations, "checksum", chunk.Checksum) + if chunk.ChunkIndex < 0 { + violations = append(violations, "chunkIndex must not be negative") + } + if chunk.Offset < 0 { + violations = append(violations, "offset must not be negative") + } + if chunk.SizeBytes <= 0 { + violations = append(violations, "sizeBytes must be positive") + } + if chunk.SizeBytes > MaxArtifactChunkBytes { + violations = append(violations, fmt.Sprintf("sizeBytes must not exceed %d", MaxArtifactChunkBytes)) + } + if len(chunk.Payload) == 0 { + violations = append(violations, "payload must not be empty") + } + if chunk.SizeBytes > 0 && len(chunk.Payload) != chunk.SizeBytes { + violations = append(violations, "sizeBytes must match payload size") + } + if chunk.Checksum != "" { + if !validSHA256Checksum(chunk.Checksum) { + violations = append(violations, "checksum must be sha256:") + } else if chunk.Checksum != BytesChecksum(chunk.Payload) { + violations = append(violations, "checksum does not match payload") + } + } + return finish(violations) +} + +func ValidateArtifactTransferStatusQuery(query domain.ArtifactTransferStatusQuery) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", query.RunEndpointID) + violations = appendRequired(violations, "sessionToken", query.SessionToken) + violations = appendRequired(violations, "transferId", query.TransferID) + violations = appendRequired(violations, "artifactId", query.ArtifactID) + return finish(violations) +} + +func ValidateArtifactTransferComplete(complete domain.ArtifactTransferComplete) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", complete.RunEndpointID) + violations = appendRequired(violations, "sessionToken", complete.SessionToken) + violations = appendRequired(violations, "transferId", complete.TransferID) + violations = appendRequired(violations, "artifactId", complete.ArtifactID) + violations = appendRequired(violations, "checksum", complete.Checksum) + if complete.SizeBytes <= 0 { + violations = append(violations, "sizeBytes must be positive") + } + if complete.Checksum != "" && !validSHA256Checksum(complete.Checksum) { + violations = append(violations, "checksum must be sha256:") + } + return finish(violations) +} + +func BytesChecksum(payload []byte) string { + sum := sha256.Sum256(payload) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func validSHA256Checksum(value string) bool { + if !strings.HasPrefix(value, "sha256:") { + return false + } + hexValue := strings.TrimPrefix(value, "sha256:") + if len(hexValue) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(hexValue) + return err == nil +} diff --git a/platform/validator/control.go b/platform/validator/control.go new file mode 100644 index 0000000..79f4663 --- /dev/null +++ b/platform/validator/control.go @@ -0,0 +1,67 @@ +package validator + +import ( + "fmt" + "strings" + + "browser.local/platform/domain" +) + +func ValidateRunControlHello(hello domain.RunControlHello) error { + var violations []string + violations = appendRequired(violations, "registrationToken", hello.RegistrationToken) + violations = appendRequired(violations, "runEndpointId", hello.RunEndpointID) + violations = appendRequired(violations, "displayName", hello.DisplayName) + violations = appendRequired(violations, "version", hello.Version) + violations = appendRequired(violations, "capabilityReport.fingerprint", hello.CapabilityReport.Fingerprint) + if !validRunControlStatus(hello.Status) { + violations = append(violations, "status is invalid") + } + violations = appendCapacityViolations(violations, hello.Capacity) + violations = appendCapabilitiesViolations(violations, "capabilityReport.capabilities", hello.CapabilityReport.Capabilities) + return finish(violations) +} + +func ValidateRunControlHeartbeat(heartbeat domain.RunControlHeartbeat) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", heartbeat.RunEndpointID) + violations = appendRequired(violations, "sessionToken", heartbeat.SessionToken) + violations = appendRequired(violations, "version", heartbeat.Version) + violations = appendRequired(violations, "capabilityFingerprint", heartbeat.CapabilityFingerprint) + if !validRunControlStatus(heartbeat.Status) { + violations = append(violations, "status is invalid") + } + violations = appendCapacityViolations(violations, heartbeat.Capacity) + return finish(violations) +} + +func appendCapacityViolations(violations []string, capacity domain.RunCapacity) []string { + if capacity.MaxJobs < 0 || capacity.RunningJobs < 0 || capacity.QueuedJobs < 0 { + violations = append(violations, "capacity counts must not be negative") + } + if capacity.MaxJobs > 0 && capacity.RunningJobs > capacity.MaxJobs { + violations = append(violations, "runningJobs must not exceed maxJobs") + } + return violations +} + +func appendCapabilitiesViolations(violations []string, field string, capabilities []string) []string { + if len(capabilities) == 0 { + violations = append(violations, field+" must not be empty") + } + for i, capability := range capabilities { + if strings.TrimSpace(capability) == "" { + violations = append(violations, fmt.Sprintf("%s[%d] is required", field, i)) + } + } + return violations +} + +func validRunControlStatus(status domain.RunEndpointStatus) bool { + switch status { + case domain.RunEndpointStatusOnline, domain.RunEndpointStatusDegraded, domain.RunEndpointStatusOffline: + return true + default: + return false + } +} diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go new file mode 100644 index 0000000..a5c7cd8 --- /dev/null +++ b/platform/validator/job_channel.go @@ -0,0 +1,121 @@ +package validator + +import ( + "fmt" + "strings" + + "browser.local/platform/domain" +) + +const maxJobChannelMessageLength = 256 + +func ValidateRunJobClaim(claim domain.RunJobClaim) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", claim.RunEndpointID) + violations = appendRequired(violations, "sessionToken", claim.SessionToken) + violations = appendCapacityViolations(violations, claim.Capacity) + for i, capability := range claim.Capabilities { + if strings.TrimSpace(capability) == "" { + violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i)) + } + } + return finish(violations) +} + +func ValidateRunJobAck(ack domain.RunJobAck) error { + var violations []string + violations = appendLeaseFields(violations, ack.RunEndpointID, ack.SessionToken, ack.JobID, ack.LeaseToken, ack.Attempt) + violations = appendMessageLength(violations, "message", ack.Message) + return finish(violations) +} + +func ValidateRunJobProgress(progress domain.RunJobProgress) error { + var violations []string + violations = appendLeaseFields(violations, progress.RunEndpointID, progress.SessionToken, progress.JobID, progress.LeaseToken, progress.Attempt) + violations = appendProgressViolations(violations, progress.Progress) + return finish(violations) +} + +func ValidateRunJobResult(result domain.RunJobResult) error { + var violations []string + violations = appendLeaseFields(violations, result.RunEndpointID, result.SessionToken, result.JobID, result.LeaseToken, result.Attempt) + if !validTerminalJobState(result.State) { + violations = append(violations, "state must be succeeded, failed, or cancelled") + } + violations = appendProgressViolations(violations, result.Progress) + violations = appendMessageLength(violations, "message", result.Message) + violations = appendMessageLength(violations, "errorCode", result.ErrorCode) + return finish(violations) +} + +func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error { + var violations []string + violations = appendRequired(violations, "jobId", request.JobID) + violations = appendRequired(violations, "reason", request.Reason) + violations = appendMessageLength(violations, "reason", request.Reason) + return finish(violations) +} + +func ValidateRunJobCancelPoll(poll domain.RunJobCancelPoll) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", poll.RunEndpointID) + violations = appendRequired(violations, "sessionToken", poll.SessionToken) + if poll.LeaseToken != "" && strings.TrimSpace(poll.JobID) == "" { + violations = append(violations, "jobId is required when leaseToken is provided") + } + return finish(violations) +} + +func ValidateRunJobReconcile(reconcile domain.RunJobReconcile) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", reconcile.RunEndpointID) + violations = appendRequired(violations, "sessionToken", reconcile.SessionToken) + seen := map[string]struct{}{} + for i, jobID := range reconcile.ActiveJobIDs { + jobID = strings.TrimSpace(jobID) + if jobID == "" { + violations = append(violations, fmt.Sprintf("activeJobIds[%d] is required", i)) + continue + } + if _, exists := seen[jobID]; exists { + violations = append(violations, fmt.Sprintf("activeJobIds[%d] duplicates %q", i, jobID)) + } + seen[jobID] = struct{}{} + } + return finish(violations) +} + +func appendLeaseFields(violations []string, runEndpointID string, sessionToken string, jobID string, leaseToken string, attempt int) []string { + violations = appendRequired(violations, "runEndpointId", runEndpointID) + violations = appendRequired(violations, "sessionToken", sessionToken) + violations = appendRequired(violations, "jobId", jobID) + violations = appendRequired(violations, "leaseToken", leaseToken) + if attempt <= 0 { + violations = append(violations, "attempt must be positive") + } + return violations +} + +func appendProgressViolations(violations []string, progress domain.RunJobProgressReport) []string { + if progress.Percent < 0 || progress.Percent > 100 { + violations = append(violations, "progress.percent must be between 0 and 100") + } + violations = appendMessageLength(violations, "progress.message", progress.Message) + return violations +} + +func appendMessageLength(violations []string, field string, message string) []string { + if len(message) > maxJobChannelMessageLength { + violations = append(violations, field+" is too long") + } + return violations +} + +func validTerminalJobState(state domain.JobState) bool { + switch state { + case domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled: + return true + default: + return false + } +} diff --git a/platform/validator/log_ingest.go b/platform/validator/log_ingest.go new file mode 100644 index 0000000..f804aaa --- /dev/null +++ b/platform/validator/log_ingest.go @@ -0,0 +1,117 @@ +package validator + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "browser.local/platform/domain" +) + +const ( + MaxLogBatchEntries = 512 + MaxLogLineLength = 8192 + MaxLogQueryLimit = 500 +) + +func ValidateLogBatchIngest(batch domain.LogBatchIngest) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", batch.RunEndpointID) + violations = appendRequired(violations, "sessionToken", batch.SessionToken) + violations = appendRequired(violations, "logStreamId", batch.LogStreamID) + violations = appendRequired(violations, "serverInstanceId", batch.ServerInstanceID) + violations = appendRequired(violations, "streamKey", batch.StreamKey) + violations = appendRequired(violations, "checksum", batch.Checksum) + if !validLogStreamSource(batch.Source) { + violations = append(violations, "source is invalid") + } + if batch.FirstSeq == 0 || batch.LastSeq == 0 { + violations = append(violations, "sequence range must be positive") + } + if batch.FirstSeq > batch.LastSeq { + violations = append(violations, "firstSeq must not exceed lastSeq") + } + if batch.Compression != "" && batch.Compression != "none" { + violations = append(violations, "compression is invalid") + } + if len(batch.Entries) == 0 { + violations = append(violations, "entries must not be empty") + } + if len(batch.Entries) > MaxLogBatchEntries { + violations = append(violations, fmt.Sprintf("entries must not exceed %d", MaxLogBatchEntries)) + } + if len(batch.Entries) > 0 { + expectedCount := int(batch.LastSeq - batch.FirstSeq + 1) + if expectedCount != len(batch.Entries) { + violations = append(violations, "sequence range must match entry count") + } + } + for i, entry := range batch.Entries { + if entry.Seq != batch.FirstSeq+uint64(i) { + violations = append(violations, fmt.Sprintf("entries[%d].seq must be contiguous", i)) + } + if strings.TrimSpace(entry.Line) == "" { + violations = append(violations, fmt.Sprintf("entries[%d].line is required", i)) + } + if len(entry.Line) > MaxLogLineLength { + violations = append(violations, fmt.Sprintf("entries[%d].line is too long", i)) + } + for key := range entry.Fields { + if strings.TrimSpace(key) == "" { + violations = append(violations, fmt.Sprintf("entries[%d].fields key is required", i)) + } + } + } + if batch.Checksum != "" { + computed, err := LogEntriesChecksum(batch.Entries) + if err != nil { + violations = append(violations, "checksum cannot be computed") + } else if batch.Checksum != computed { + violations = append(violations, "checksum does not match entries") + } + } + return finish(violations) +} + +func ValidateLogStreamCursorQuery(query domain.LogStreamCursorQuery) error { + var violations []string + violations = appendRequired(violations, "logStreamId", query.LogStreamID) + if query.Limit < 0 { + violations = append(violations, "limit must not be negative") + } + if query.Limit > MaxLogQueryLimit { + violations = append(violations, fmt.Sprintf("limit must not exceed %d", MaxLogQueryLimit)) + } + return finish(violations) +} + +func LogEntriesChecksum(entries []domain.LogEntry) (string, error) { + stable := make([]logEntryChecksumBody, len(entries)) + for i, entry := range entries { + stable[i] = logEntryChecksumBody{ + Seq: entry.Seq, + Timestamp: entry.Timestamp.UTC().Format("2006-01-02T15:04:05.000000000Z07:00"), + Level: entry.Level, + Line: entry.Line, + Fields: entry.Fields, + Redacted: entry.Redacted, + } + } + encoded, err := json.Marshal(stable) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +type logEntryChecksumBody struct { + Seq uint64 `json:"seq"` + Timestamp string `json:"timestamp"` + Level string `json:"level,omitempty"` + Line string `json:"line"` + Fields map[string]string `json:"fields,omitempty"` + Redacted bool `json:"redacted"` +} diff --git a/platform/validator/resources.go b/platform/validator/resources.go new file mode 100644 index 0000000..a1b1dd0 --- /dev/null +++ b/platform/validator/resources.go @@ -0,0 +1,1372 @@ +package validator + +import ( + "fmt" + "strings" + + "browser.local/platform/domain" +) + +const ( + maxAuditSummaryLength = 512 + maxContactNoteLength = 160 + maxMarketplaceKeywordSize = 80 + maxMarketplaceListSize = 500 + maxPluginDescriptionLength = 240 + maxPluginPageTitleLength = 40 + maxPluginBridgePayloadKeys = 16 + maxPluginBridgePayloadSize = 4096 + maxProgressMessageLength = 256 + maxServerConfigContentSize = 64 * 1024 + maxLogicalFileKeyLength = 160 +) + +type ValidationError struct { + Violations []string +} + +func (err ValidationError) Error() string { + return "validation failed: " + strings.Join(err.Violations, "; ") +} + +func (err ValidationError) IsEmpty() bool { + return len(err.Violations) == 0 +} + +func appendRequired(violations []string, field string, value string) []string { + if strings.TrimSpace(value) == "" { + return append(violations, field+" is required") + } + return violations +} + +func finish(violations []string) error { + if len(violations) == 0 { + return nil + } + return ValidationError{Violations: violations} +} + +func ValidateUser(user domain.User) error { + var violations []string + violations = appendRequired(violations, "id", user.ID) + violations = appendRequired(violations, "displayName", user.DisplayName) + if !validUserStatus(user.Status) { + violations = append(violations, "status is invalid") + } + if strings.TrimSpace(user.Email) != "" && !strings.Contains(user.Email, "@") { + violations = append(violations, "email is invalid") + } + if len(user.Profile.ContactNote) > maxContactNoteLength { + violations = append(violations, "profile.contactNote is too long") + } + for i, role := range user.Roles { + if strings.TrimSpace(role) == "" { + violations = append(violations, fmt.Sprintf("roles[%d] is required", i)) + } + if !validUserRole(role) { + violations = append(violations, fmt.Sprintf("roles[%d] is invalid", i)) + } + } + return finish(violations) +} + +func ValidateAIProvider(provider domain.AIProvider) error { + var violations []string + violations = appendRequired(violations, "id", provider.ID) + violations = appendRequired(violations, "name", provider.Name) + violations = appendRequired(violations, "baseUrl", provider.BaseURL) + if !validAIProviderKind(provider.Kind) { + violations = append(violations, "kind is invalid") + } + if !validAIRelayMode(provider.RelayMode) { + violations = append(violations, "relayMode is invalid") + } + if !validAIProviderStatus(provider.Status) { + violations = append(violations, "status is invalid") + } + if provider.RelayMode == domain.AIRelayModeDirect || provider.RelayMode == domain.AIRelayModeRelay { + violations = appendRequired(violations, "apiKeyRef", provider.APIKeyRef) + } + if looksLikeRawSecret(provider.APIKeyRef) { + violations = append(violations, "apiKeyRef must reference secret storage, not raw key material") + } + violations = appendRequired(violations, "redactionPolicy", provider.RedactionPolicy) + if provider.TimeoutMS <= 0 { + violations = append(violations, "timeoutMs must be positive") + } + if len(provider.Models) == 0 { + violations = append(violations, "models must not be empty") + } + modelSet := map[string]struct{}{} + for i, model := range provider.Models { + model = strings.TrimSpace(model) + if model == "" { + violations = append(violations, fmt.Sprintf("models[%d] is required", i)) + continue + } + if _, exists := modelSet[model]; exists { + violations = append(violations, fmt.Sprintf("models[%d] duplicates %q", i, model)) + } + modelSet[model] = struct{}{} + } + if provider.DefaultModel != "" { + if _, exists := modelSet[provider.DefaultModel]; !exists { + violations = append(violations, "defaultModel must be included in models") + } + } + return finish(violations) +} + +func ValidateGamePlugin(plugin domain.GamePlugin) error { + var violations []string + violations = appendRequired(violations, "id", plugin.ID) + violations = appendRequired(violations, "name", plugin.Name) + if len(plugin.Description) > maxPluginDescriptionLength { + violations = append(violations, "description is too long") + } + violations = appendRequired(violations, "version", plugin.Version) + violations = appendRequired(violations, "serverType", plugin.ServerType) + violations = appendRequired(violations, "manifestRef", plugin.ManifestRef) + violations = appendRequired(violations, "createFormSchemaRef", plugin.CreateFormSchemaRef) + if !validGamePluginStatus(plugin.Status) { + violations = append(violations, "status is invalid") + } + for i, capability := range plugin.RequiredRunCapabilities { + if strings.TrimSpace(capability) == "" { + violations = append(violations, fmt.Sprintf("requiredRunCapabilities[%d] is required", i)) + } else if !validPluginRunCapability(capability) { + violations = append(violations, fmt.Sprintf("requiredRunCapabilities[%d] is not allowed", i)) + } + } + violations = append(violations, duplicateViolations("requiredRunCapabilities", plugin.RequiredRunCapabilities)...) + violations = append(violations, validateDeclaredPluginPermissions(plugin.DeclaredPermissions)...) + violations = append(violations, validateBridgeActions("bridgeActions", plugin.BridgeActions)...) + violations = append(violations, validatePluginPages(plugin.Pages)...) + violations = append(violations, duplicateViolations("tags", plugin.Tags)...) + violations = append(violations, validateAIPurposes(plugin.AIPurposes)...) + violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) + return finish(violations) +} + +func ValidateGamePluginManifestRegistration(registration domain.GamePluginManifestRegistration) error { + registration = domain.CopyGamePluginManifestRegistration(registration) + manifest := registration.Manifest + var violations []string + violations = appendRequired(violations, "manifestRef", registration.ManifestRef) + if !safeRef(registration.ManifestRef) { + violations = append(violations, "manifestRef is unsafe") + } + violations = appendRequired(violations, "manifest.id", manifest.ID) + violations = appendRequired(violations, "manifest.name", manifest.Name) + if len(manifest.Description) > maxPluginDescriptionLength { + violations = append(violations, "manifest.description is too long") + } + violations = appendRequired(violations, "manifest.version", manifest.Version) + if manifest.Kind != "game-plugin" { + violations = append(violations, "manifest.kind must be game-plugin") + } + violations = appendRequired(violations, "manifest.server.type", manifest.Server.Type) + violations = appendRequired(violations, "manifest.server.displayName", manifest.Server.DisplayName) + violations = appendRequired(violations, "manifest.server.createFormSchema", manifest.Server.CreateFormSchema) + if !safeRelativeJSONRef(manifest.Server.CreateFormSchema) { + violations = append(violations, "manifest.server.createFormSchema must be a safe relative JSON reference") + } + for i, osName := range manifest.Server.SupportedOS { + if !validPluginSupportedOS(osName) { + violations = append(violations, fmt.Sprintf("manifest.server.supportedOs[%d] is not allowed", i)) + } + } + violations = append(violations, duplicateViolations("manifest.server.supportedOs", manifest.Server.SupportedOS)...) + for i, capability := range manifest.Capabilities { + if !validPluginRunCapability(capability) { + violations = append(violations, fmt.Sprintf("manifest.capabilities[%d] is not allowed", i)) + } + } + if len(manifest.Capabilities) == 0 { + violations = append(violations, "manifest.capabilities must not be empty") + } + violations = append(violations, duplicateViolations("manifest.capabilities", manifest.Capabilities)...) + if len(manifest.Permissions) == 0 { + violations = append(violations, "manifest.permissions must not be empty") + } + violations = append(violations, validateDeclaredPluginPermissions(manifest.Permissions)...) + violations = append(violations, validateBridgeActions("manifest.bridge.actions", manifest.Bridge.Actions)...) + violations = append(violations, validateLifecycleActions(manifest.Actions)...) + violations = append(violations, validatePluginPages(manifest.Pages)...) + violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...) + violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...) + violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...) + return finish(violations) +} + +func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error { + var violations []string + violations = appendRequired(violations, "pluginId", request.PluginID) + violations = appendRequired(violations, "routeKey", request.RouteKey) + violations = appendRequired(violations, "action", string(request.Action)) + if !validPluginBridgeAction(request.Action) { + violations = append(violations, "action is not supported") + } + if request.Action == domain.PluginBridgeActionAIInvoke { + violations = appendRequired(violations, "aiPurpose", request.AIPurpose) + if strings.TrimSpace(request.AIPurpose) != "" && !validAIPurpose(request.AIPurpose) { + violations = append(violations, "aiPurpose is not allowed") + } + } + return finish(violations) +} + +func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteRequest) error { + request = domain.CopyPluginBridgeExecuteRequest(request) + var violations []string + violations = appendRequired(violations, "requestId", request.RequestID) + violations = appendRequired(violations, "pluginId", request.PluginID) + violations = appendRequired(violations, "routeKey", request.RouteKey) + violations = appendRequired(violations, "action", string(request.Action)) + if !validPluginBridgeAction(request.Action) { + violations = append(violations, "action is not supported") + } + if request.Action != domain.PluginBridgeActionAIInvoke { + violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) + } + if request.Action == domain.PluginBridgeActionAIInvoke { + violations = appendRequired(violations, "aiPurpose", request.AIPurpose) + if strings.TrimSpace(request.AIPurpose) != "" && !validAIPurpose(request.AIPurpose) { + violations = append(violations, "aiPurpose is not allowed") + } + } + if len(request.Payload) > maxPluginBridgePayloadKeys { + violations = append(violations, "payload has too many keys") + } + payloadSize := 0 + for key, value := range request.Payload { + payloadSize += len(key) + len(value) + if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 { + violations = append(violations, "payload key is invalid") + } + if len([]rune(value)) > 1024 { + violations = append(violations, "payload value is too long") + } + for _, reason := range unsafePluginStringReasons(key) { + violations = append(violations, "payload key: "+reason) + } + for _, reason := range unsafePluginStringReasons(value) { + violations = append(violations, "payload."+key+": "+reason) + } + if containsUnsafeRuntimeSecret(value) || strings.Contains(strings.ToLower(value), "unix://") || strings.Contains(strings.ToLower(value), "tcp://") { + violations = append(violations, "payload contains unsafe content") + } + } + if payloadSize > maxPluginBridgePayloadSize { + violations = append(violations, "payload is too large") + } + return finish(violations) +} + +func ValidatePluginMarketplaceFilter(filter domain.PluginMarketplaceFilter) error { + var violations []string + if filter.Status != "" && !validGamePluginStatus(filter.Status) { + violations = append(violations, "status is invalid") + } + if strings.TrimSpace(filter.ServerType) != filter.ServerType { + violations = append(violations, "serverType must not have surrounding whitespace") + } + if strings.TrimSpace(filter.Capability) != filter.Capability { + violations = append(violations, "capability must not have surrounding whitespace") + } + if len([]rune(filter.Keyword)) > maxMarketplaceKeywordSize { + violations = append(violations, "keyword is too long") + } + for _, value := range []fieldString{ + {field: "serverType", value: filter.ServerType}, + {field: "capability", value: filter.Capability}, + {field: "keyword", value: filter.Keyword}, + } { + for _, reason := range unsafePluginStringReasons(value.value) { + violations = append(violations, "filter."+value.field+": "+reason) + } + } + return finish(violations) +} + +func ValidatePluginMarketplaceStateAction(action domain.PluginMarketplaceStateAction) error { + if validPluginMarketplaceStateAction(action) { + return nil + } + return ValidationError{Violations: []string{"action is not supported"}} +} + +func ValidatePluginMarketplacePlugins(plugins []domain.PluginMarketplacePlugin) error { + var violations []string + if len(plugins) > maxMarketplaceListSize { + violations = append(violations, "items is too long") + } + for i, plugin := range plugins { + prefix := fmt.Sprintf("items[%d]", i) + violations = append(violations, validatePluginMarketplacePlugin(prefix, plugin)...) + } + return finish(violations) +} + +func ValidatePluginMarketplacePlugin(plugin domain.PluginMarketplacePlugin) error { + return finish(validatePluginMarketplacePlugin("plugin", plugin)) +} + +func validatePluginMarketplacePlugin(prefix string, plugin domain.PluginMarketplacePlugin) []string { + var violations []string + violations = appendRequired(violations, prefix+".id", plugin.ID) + violations = appendRequired(violations, prefix+".name", plugin.Name) + violations = appendRequired(violations, prefix+".version", plugin.Version) + violations = appendRequired(violations, prefix+".serverType", plugin.ServerType) + violations = appendRequired(violations, prefix+".manifestRef", plugin.ManifestRef) + violations = appendRequired(violations, prefix+".createFormSchemaRef", plugin.CreateFormSchemaRef) + violations = appendRequired(violations, prefix+".source", plugin.Source) + if !validGamePluginStatus(plugin.Status) { + violations = append(violations, prefix+".status is invalid") + } + if len(plugin.Description) > maxPluginDescriptionLength { + violations = append(violations, prefix+".description is too long") + } + if len(plugin.Capabilities) == 0 { + violations = append(violations, prefix+".capabilities must not be empty") + } + for i, capability := range plugin.Capabilities { + if !validPluginRunCapability(capability) { + violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, i)) + } + } + for i, osName := range plugin.SupportedOS { + if !validPluginSupportedOS(osName) { + violations = append(violations, fmt.Sprintf("%s.supportedOs[%d] is not allowed", prefix, i)) + } + } + violations = append(violations, duplicateViolations(prefix+".supportedOs", plugin.SupportedOS)...) + violations = append(violations, duplicateViolations(prefix+".capabilities", plugin.Capabilities)...) + violations = append(violations, validateDeclaredPluginPermissions(plugin.DeclaredPermissions)...) + violations = append(violations, validateBridgeActions(prefix+".bridgeActions", plugin.BridgeActions)...) + violations = append(violations, validatePluginPages(plugin.Pages)...) + violations = append(violations, duplicateViolations(prefix+".tags", plugin.Tags)...) + violations = append(violations, validateAIPurposes(plugin.AIPurposes)...) + violations = append(violations, validateSafePluginStrings(prefix, marketplacePluginSafeStrings(plugin))...) + return violations +} + +func AuthorizePluginBridgeAction(plugin domain.GamePlugin, request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) { + result := domain.PluginBridgeAuthorization{ + PluginID: request.PluginID, + RouteKey: request.RouteKey, + ServerInstanceID: request.ServerInstanceID, + Action: request.Action, + RequiredPermissions: requiredBridgePermissions(request.Action), + EffectivePermissions: effectivePagePermissions(plugin, request.RouteKey), + } + if err := ValidateGamePlugin(plugin); err != nil { + return result, err + } + if err := ValidatePluginBridgeAuthorizeRequest(request); err != nil { + return result, err + } + if plugin.ID != request.PluginID { + result.Reason = "pluginId must match installed plugin" + return result, nil + } + if plugin.Status != domain.GamePluginStatusInstalled { + result.Reason = "plugin must be installed" + return result, nil + } + if !containsString(plugin.BridgeActions, string(request.Action)) { + result.Reason = "bridge action is not declared by plugin" + return result, nil + } + page, pageFound := findPluginPage(plugin.Pages, request.RouteKey) + if !pageFound { + result.Reason = "routeKey is not declared by plugin" + return result, nil + } + if len(page.BridgeActions) > 0 && !containsString(page.BridgeActions, string(request.Action)) { + result.Reason = "bridge action is not declared by page" + return result, nil + } + if !containsAll(result.EffectivePermissions, result.RequiredPermissions) { + result.Reason = "required permission is missing" + return result, nil + } + if request.Action == domain.PluginBridgeActionAIInvoke && !containsString(plugin.AIPurposes, request.AIPurpose) { + result.Reason = "ai purpose is not declared by plugin" + return result, nil + } + result.Allowed = true + return result, nil +} + +func ValidateServerInstance(instance domain.ServerInstance) error { + var violations []string + violations = appendRequired(violations, "id", instance.ID) + violations = appendRequired(violations, "pluginId", instance.PluginID) + violations = appendRequired(violations, "pluginVersion", instance.PluginVersion) + violations = appendRequired(violations, "runEndpointId", instance.RunEndpointID) + violations = appendRequired(violations, "name", instance.Name) + if strings.TrimSpace(instance.OwnerUserID) != instance.OwnerUserID { + violations = append(violations, "ownerUserId must not have surrounding whitespace") + } + for i, userID := range instance.AdminUserIDs { + if strings.TrimSpace(userID) == "" { + violations = append(violations, fmt.Sprintf("adminUserIds[%d] is required", i)) + } + if strings.TrimSpace(userID) != userID { + violations = append(violations, fmt.Sprintf("adminUserIds[%d] must not have surrounding whitespace", i)) + } + if userID == instance.OwnerUserID { + violations = append(violations, fmt.Sprintf("adminUserIds[%d] must not duplicate ownerUserId", i)) + } + } + violations = append(violations, duplicateViolations("adminUserIds", instance.AdminUserIDs)...) + if !validServerInstanceState(instance.State) { + violations = append(violations, "state is invalid") + } + if instance.State == domain.ServerInstanceStateDeleted { + violations = append(violations, "state must not be deleted on create") + } + if instance.ConfigVersion < 0 { + violations = append(violations, "configVersion must not be negative") + } + return finish(violations) +} + +func ValidateServerInstanceDependencies(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint) error { + var violations []string + if plugin.ID == "" { + violations = append(violations, "plugin is required") + } else { + if plugin.ID != instance.PluginID { + violations = append(violations, "pluginId must match plugin") + } + if plugin.Status != domain.GamePluginStatusInstalled { + violations = append(violations, "plugin must be installed") + } + if plugin.Version != instance.PluginVersion { + violations = append(violations, "pluginVersion must match plugin") + } + } + if endpoint.ID == "" { + violations = append(violations, "run endpoint is required") + } else { + if endpoint.ID != instance.RunEndpointID { + violations = append(violations, "runEndpointId must match run endpoint") + } + if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded { + violations = append(violations, "run endpoint must be online or degraded") + } + missing := MissingCapabilities(endpoint.Capabilities, plugin.RequiredRunCapabilities) + if len(missing) > 0 { + violations = append(violations, "run endpoint missing required capabilities: "+strings.Join(missing, ", ")) + } + } + return finish(violations) +} + +func ValidatePlatformResourceUsage(usage domain.PlatformResourceUsage) error { + var violations []string + violations = appendPercentViolation(violations, "cpuPercent", usage.CPUPercent) + violations = appendPercentViolation(violations, "memoryPercent", usage.MemoryPercent) + violations = appendPercentViolation(violations, "diskPercent", usage.DiskPercent) + violations = appendRequired(violations, "source", usage.Source) + if usage.CollectedAt.IsZero() { + violations = append(violations, "collectedAt is required") + } + return finish(violations) +} + +func ValidateServerMetricsList(items []domain.ServerMetrics) error { + var violations []string + if len(items) > 1000 { + violations = append(violations, "items is too long") + } + for i, item := range items { + field := fmt.Sprintf("items[%d]", i) + if strings.TrimSpace(item.ServerInstanceID) == "" { + violations = append(violations, field+".serverInstanceId is required") + } + if item.CollectedAt.IsZero() { + violations = append(violations, field+".collectedAt is required") + } + if strings.TrimSpace(item.Source) == "" { + violations = append(violations, field+".source is required") + } + violations = appendOptionalPercentViolation(violations, field+".cpuPercent", item.CPUPercent) + violations = appendOptionalPercentViolation(violations, field+".memoryPercent", item.MemoryPercent) + violations = appendOptionalPercentViolation(violations, field+".diskPercent", item.DiskPercent) + if item.PlayerCount != nil && *item.PlayerCount < 0 { + violations = append(violations, field+".playerCount must not be negative") + } + if item.MaxPlayers != nil && *item.MaxPlayers < 0 { + violations = append(violations, field+".maxPlayers must not be negative") + } + if item.TPS != nil && (*item.TPS < 0 || *item.TPS > 100) { + violations = append(violations, field+".tps must be between 0 and 100") + } + if item.LatencyMS != nil && *item.LatencyMS < 0 { + violations = append(violations, field+".latencyMs must not be negative") + } + } + return finish(violations) +} + +func ValidateServerConfig(config domain.ServerConfig) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", config.ServerInstanceID) + violations = appendRequired(violations, "format", config.Format) + violations = appendRequired(violations, "key", config.Key) + if config.ConfigVersion <= 0 { + violations = append(violations, "configVersion must be positive") + } + if len([]byte(config.Content)) > maxServerConfigContentSize { + violations = append(violations, "content is too large") + } + if config.UpdatedAt.IsZero() { + violations = append(violations, "updatedAt is required") + } + if containsUnsafeRuntimeSecret(config.Content) { + violations = append(violations, "content must not expose raw secrets, host paths, or direct sockets") + } + return finish(violations) +} + +func ValidateServerConfigDiffRequest(request domain.ServerConfigDiffRequest) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) + violations = appendRequired(violations, "key", request.Key) + if request.ExpectedConfigVersion <= 0 { + violations = append(violations, "expectedConfigVersion must be positive") + } + if !validLogicalFileKey(request.Key) || !validConfigFileKey(request.Key) { + violations = append(violations, "key is not allowed") + } + if len([]byte(request.ProposedContent)) > maxServerConfigContentSize { + violations = append(violations, "proposedContent is too large") + } + if containsUnsafeRuntimeSecret(request.ProposedContent) { + violations = append(violations, "proposedContent must not expose raw secrets, host paths, or direct sockets") + } + if request.ProposedContentInputRef != "" && !validScopedInputRef(request.ProposedContentInputRef) { + violations = append(violations, "proposedContentInputRef is not allowed") + } + return finish(violations) +} + +func ValidateServerConfigWriteApproval(approval domain.ServerConfigWriteApproval) error { + request := domain.ServerConfigDiffRequest{ + ServerInstanceID: approval.ServerInstanceID, + ExpectedConfigVersion: approval.ExpectedConfigVersion, + Key: approval.Key, + ProposedContent: approval.ProposedContent, + ProposedContentInputRef: approval.ProposedContentInputRef, + } + var violations []string + if err := ValidateServerConfigDiffRequest(request); err != nil { + if validationErr, ok := err.(ValidationError); ok { + violations = append(violations, validationErr.Violations...) + } else { + violations = append(violations, err.Error()) + } + } + violations = appendRequired(violations, "idempotencyKey", approval.IdempotencyKey) + if containsUnsafeRuntimeSecret(approval.IdempotencyKey) || looksLikeRawHostPath(approval.IdempotencyKey) { + violations = append(violations, "idempotencyKey is not allowed") + } + return finish(violations) +} + +func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRequest) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) + violations = appendRequired(violations, "key", request.Key) + violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey) + if !validFileOperationKind(request.Operation) { + violations = append(violations, "operation is invalid") + } + if !validLogicalFileKey(request.Key) { + violations = append(violations, "key is not allowed") + } + if request.Operation == domain.FileOperationWrite && request.InputRef == "" { + violations = append(violations, "inputRef is required for writes") + } + if request.InputRef != "" && !validScopedInputRef(request.InputRef) { + violations = append(violations, "inputRef is not allowed") + } + if request.ExpectedConfigVersion < 0 { + violations = append(violations, "expectedConfigVersion must not be negative") + } + for _, value := range []string{request.PluginID, request.IdempotencyKey} { + if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "unix://") { + violations = append(violations, "request contains unsafe content") + } + } + return finish(violations) +} + +func appendPercentViolation(violations []string, field string, value float64) []string { + if value < 0 || value > 100 { + return append(violations, field+" must be between 0 and 100") + } + return violations +} + +func appendOptionalPercentViolation(violations []string, field string, value *float64) []string { + if value == nil { + return violations + } + return appendPercentViolation(violations, field, *value) +} + +func containsUnsafeRuntimeSecret(value string) bool { + lower := strings.ToLower(value) + unsafeFragments := []string{ + "/users/", + "/var/run/", + "unix://", + "tcp://", + "bearer ", + "api_key=", + "apikey=", + "password=", + "secret=", + "sk-", + } + for _, fragment := range unsafeFragments { + if strings.Contains(lower, fragment) { + return true + } + } + return false +} + +func ValidateRunEndpoint(endpoint domain.RunEndpoint) error { + var violations []string + violations = appendRequired(violations, "id", endpoint.ID) + violations = appendRequired(violations, "displayName", endpoint.DisplayName) + violations = appendRequired(violations, "version", endpoint.Version) + if !validRunEndpointStatus(endpoint.Status) { + violations = append(violations, "status is invalid") + } + if endpoint.Capacity.MaxJobs < 0 || endpoint.Capacity.RunningJobs < 0 || endpoint.Capacity.QueuedJobs < 0 { + violations = append(violations, "capacity counts must not be negative") + } + if endpoint.Capacity.MaxJobs > 0 && endpoint.Capacity.RunningJobs > endpoint.Capacity.MaxJobs { + violations = append(violations, "runningJobs must not exceed maxJobs") + } + for i, capability := range endpoint.Capabilities { + if strings.TrimSpace(capability) == "" { + violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i)) + } + } + return finish(violations) +} + +func ValidateJob(job domain.Job) error { + var violations []string + violations = appendRequired(violations, "id", job.ID) + violations = appendRequired(violations, "runEndpointId", job.RunEndpointID) + violations = appendRequired(violations, "capability", job.Capability) + violations = appendRequired(violations, "idempotencyKey", job.IdempotencyKey) + if !validJobState(job.State) { + violations = append(violations, "state is invalid") + } + if job.Progress.Percent < 0 || job.Progress.Percent > 100 { + violations = append(violations, "progress.percent must be between 0 and 100") + } + if len(job.Progress.Message) > maxProgressMessageLength { + violations = append(violations, "progress.message is too long") + } + if job.TargetKey != "" && !validLogicalFileKey(job.TargetKey) { + violations = append(violations, "targetKey is not allowed") + } + if job.InputRef != "" && !validScopedInputRef(job.InputRef) { + violations = append(violations, "inputRef is not allowed") + } + if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite { + if job.ServerInstanceID == "" { + violations = append(violations, "serverInstanceId is required for scoped file jobs") + } + if job.TargetKey == "" { + violations = append(violations, "targetKey is required for scoped file jobs") + } + } + if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesWrite { + if job.InputRef == "" { + violations = append(violations, "inputRef is required for scoped write jobs") + } + } + return finish(violations) +} + +func ValidateArtifact(artifact domain.Artifact) error { + var violations []string + violations = appendRequired(violations, "id", artifact.ID) + violations = appendRequired(violations, "ownerId", artifact.OwnerID) + violations = appendRequired(violations, "checksum", artifact.Checksum) + if !validArtifactOwnerKind(artifact.OwnerKind) { + violations = append(violations, "ownerKind is invalid") + } + if !validArtifactState(artifact.State) { + violations = append(violations, "state is invalid") + } + if artifact.SizeBytes < 0 { + violations = append(violations, "sizeBytes must not be negative") + } + return finish(violations) +} + +func ValidateLogStream(stream domain.LogStream) error { + var violations []string + violations = appendRequired(violations, "id", stream.ID) + violations = appendRequired(violations, "serverInstanceId", stream.ServerInstanceID) + violations = appendRequired(violations, "streamKey", stream.StreamKey) + violations = appendRequired(violations, "retentionPolicy", stream.RetentionPolicy) + if !validLogStreamSource(stream.Source) { + violations = append(violations, "source is invalid") + } + if !validLogStorageBackend(stream.StorageBackend) { + violations = append(violations, "storageBackend is invalid") + } + return finish(violations) +} + +func ValidateAuditEvent(event domain.AuditEvent) error { + var violations []string + violations = appendRequired(violations, "id", event.ID) + violations = appendRequired(violations, "actorId", event.ActorID) + violations = appendRequired(violations, "action", event.Action) + violations = appendRequired(violations, "resourceKind", event.ResourceKind) + violations = appendRequired(violations, "resourceId", event.ResourceID) + violations = appendRequired(violations, "summary", event.Summary) + if !validAuditResult(event.Result) { + violations = append(violations, "result is invalid") + } + if len(event.Summary) > maxAuditSummaryLength { + violations = append(violations, "summary is too long") + } + if looksLikeRawSecret(event.Summary) { + violations = append(violations, "summary must be redacted") + } + return finish(violations) +} + +func MissingCapabilities(actual []string, required []string) []string { + actualSet := make(map[string]struct{}, len(actual)) + for _, capability := range actual { + actualSet[capability] = struct{}{} + } + var missing []string + for _, capability := range required { + if _, exists := actualSet[capability]; !exists { + missing = append(missing, capability) + } + } + return missing +} + +type fieldString struct { + field string + value string +} + +func duplicateViolations(field string, values []string) []string { + seen := map[string]struct{}{} + var violations []string + for i, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + violations = append(violations, fmt.Sprintf("%s[%d] is required", field, i)) + continue + } + if _, exists := seen[trimmed]; exists { + violations = append(violations, fmt.Sprintf("%s[%d] duplicates an earlier value", field, i)) + } + seen[trimmed] = struct{}{} + } + return violations +} + +func validateDeclaredPluginPermissions(permissions []string) []string { + var violations []string + for i, permission := range permissions { + if !validPluginPermission(permission) { + violations = append(violations, fmt.Sprintf("permissions[%d] is not allowed", i)) + } + } + violations = append(violations, duplicateViolations("permissions", permissions)...) + return violations +} + +func validateLifecycleActions(actions domain.PluginLifecycleActions) []string { + var violations []string + required := []fieldString{ + {field: "actions.install", value: actions.Install}, + {field: "actions.start", value: actions.Start}, + {field: "actions.stop", value: actions.Stop}, + } + for _, action := range required { + violations = appendRequired(violations, action.field, action.value) + } + for _, action := range []fieldString{ + {field: "actions.install", value: actions.Install}, + {field: "actions.start", value: actions.Start}, + {field: "actions.stop", value: actions.Stop}, + {field: "actions.restart", value: actions.Restart}, + {field: "actions.status", value: actions.Status}, + } { + if strings.TrimSpace(action.value) != "" && !safeRelativeJSONRef(action.value) { + violations = append(violations, action.field+" must be a safe relative JSON reference") + } + } + return violations +} + +func validatePluginPages(pages []domain.GamePluginPage) []string { + var violations []string + seenKeys := map[string]struct{}{} + for i, page := range pages { + prefix := fmt.Sprintf("pages[%d]", i) + violations = appendRequired(violations, prefix+".key", page.Key) + violations = appendRequired(violations, prefix+".title", page.Title) + violations = appendRequired(violations, prefix+".path", page.Path) + if len(page.Title) > maxPluginPageTitleLength { + violations = append(violations, prefix+".title is too long") + } + if !validPluginPagePath(page.Path) { + violations = append(violations, prefix+".path is invalid") + } + if page.Key != "" { + if _, exists := seenKeys[page.Key]; exists { + violations = append(violations, prefix+".key duplicates another page") + } + seenKeys[page.Key] = struct{}{} + } + for permissionIndex, permission := range page.Permissions { + if !validPluginPermission(permission) { + violations = append(violations, fmt.Sprintf("%s.permissions[%d] is not allowed", prefix, permissionIndex)) + } + } + violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...) + violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...) + } + return violations +} + +func validateBridgeActions(field string, actions []string) []string { + var violations []string + for i, action := range actions { + if !validPluginBridgeAction(domain.PluginBridgeAction(action)) { + violations = append(violations, fmt.Sprintf("%s[%d] is not supported", field, i)) + } + } + violations = append(violations, duplicateViolations(field, actions)...) + return violations +} + +func validateAIPurposes(purposes []string) []string { + var violations []string + for i, purpose := range purposes { + if !validAIPurpose(purpose) { + violations = append(violations, fmt.Sprintf("aiPurposes[%d] is not allowed", i)) + } + } + violations = append(violations, duplicateViolations("aiPurposes", purposes)...) + return violations +} + +func validateSafePluginStrings(prefix string, values []fieldString) []string { + var violations []string + for _, value := range values { + for _, reason := range unsafePluginStringReasons(value.value) { + violations = append(violations, prefix+"."+value.field+": "+reason) + } + } + return violations +} + +func pluginSafeStrings(plugin domain.GamePlugin) []fieldString { + values := []fieldString{ + {field: "id", value: plugin.ID}, + {field: "name", value: plugin.Name}, + {field: "description", value: plugin.Description}, + {field: "serverType", value: plugin.ServerType}, + {field: "serverDisplayName", value: plugin.ServerDisplayName}, + {field: "manifestRef", value: plugin.ManifestRef}, + {field: "createFormSchemaRef", value: plugin.CreateFormSchemaRef}, + {field: "actions.install", value: plugin.LifecycleActions.Install}, + {field: "actions.start", value: plugin.LifecycleActions.Start}, + {field: "actions.stop", value: plugin.LifecycleActions.Stop}, + {field: "actions.restart", value: plugin.LifecycleActions.Restart}, + {field: "actions.status", value: plugin.LifecycleActions.Status}, + } + values = appendStringSliceFields(values, "supportedOs", plugin.SupportedOS) + values = appendStringSliceFields(values, "requiredRunCapabilities", plugin.RequiredRunCapabilities) + values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions) + values = appendStringSliceFields(values, "tags", plugin.Tags) + values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes) + values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions) + for i, page := range plugin.Pages { + prefix := fmt.Sprintf("pages[%d]", i) + values = append(values, + fieldString{field: prefix + ".key", value: page.Key}, + fieldString{field: prefix + ".title", value: page.Title}, + fieldString{field: prefix + ".path", value: page.Path}, + ) + values = appendStringSliceFields(values, prefix+".permissions", page.Permissions) + values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions) + } + return values +} + +func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []fieldString { + manifest := registration.Manifest + values := []fieldString{ + {field: "manifestRef", value: registration.ManifestRef}, + {field: "id", value: manifest.ID}, + {field: "name", value: manifest.Name}, + {field: "description", value: manifest.Description}, + {field: "kind", value: manifest.Kind}, + {field: "server.type", value: manifest.Server.Type}, + {field: "server.displayName", value: manifest.Server.DisplayName}, + {field: "server.createFormSchema", value: manifest.Server.CreateFormSchema}, + {field: "actions.install", value: manifest.Actions.Install}, + {field: "actions.start", value: manifest.Actions.Start}, + {field: "actions.stop", value: manifest.Actions.Stop}, + {field: "actions.restart", value: manifest.Actions.Restart}, + {field: "actions.status", value: manifest.Actions.Status}, + } + values = appendStringSliceFields(values, "tags", manifest.Tags) + values = appendStringSliceFields(values, "server.supportedOs", manifest.Server.SupportedOS) + values = appendStringSliceFields(values, "bridge.actions", manifest.Bridge.Actions) + values = appendStringSliceFields(values, "capabilities", manifest.Capabilities) + values = appendStringSliceFields(values, "permissions", manifest.Permissions) + values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes) + for i, page := range manifest.Pages { + prefix := fmt.Sprintf("pages[%d]", i) + values = append(values, + fieldString{field: prefix + ".key", value: page.Key}, + fieldString{field: prefix + ".title", value: page.Title}, + fieldString{field: prefix + ".path", value: page.Path}, + ) + values = appendStringSliceFields(values, prefix+".permissions", page.Permissions) + values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions) + } + return values +} + +func marketplacePluginSafeStrings(plugin domain.PluginMarketplacePlugin) []fieldString { + values := []fieldString{ + {field: "id", value: plugin.ID}, + {field: "name", value: plugin.Name}, + {field: "description", value: plugin.Description}, + {field: "serverType", value: plugin.ServerType}, + {field: "serverDisplayName", value: plugin.ServerDisplayName}, + {field: "manifestRef", value: plugin.ManifestRef}, + {field: "createFormSchemaRef", value: plugin.CreateFormSchemaRef}, + {field: "actions.install", value: plugin.LifecycleActions.Install}, + {field: "actions.start", value: plugin.LifecycleActions.Start}, + {field: "actions.stop", value: plugin.LifecycleActions.Stop}, + {field: "actions.restart", value: plugin.LifecycleActions.Restart}, + {field: "actions.status", value: plugin.LifecycleActions.Status}, + {field: "source", value: plugin.Source}, + } + values = appendStringSliceFields(values, "supportedOs", plugin.SupportedOS) + values = appendStringSliceFields(values, "capabilities", plugin.Capabilities) + values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions) + values = appendStringSliceFields(values, "tags", plugin.Tags) + values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes) + values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions) + for i, page := range plugin.Pages { + prefix := fmt.Sprintf("pages[%d]", i) + values = append(values, + fieldString{field: prefix + ".key", value: page.Key}, + fieldString{field: prefix + ".title", value: page.Title}, + fieldString{field: prefix + ".path", value: page.Path}, + ) + values = appendStringSliceFields(values, prefix+".permissions", page.Permissions) + values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions) + } + return values +} + +func appendStringSliceFields(values []fieldString, field string, items []string) []fieldString { + for i, item := range items { + values = append(values, fieldString{field: fmt.Sprintf("%s[%d]", field, i), value: item}) + } + return values +} + +func unsafePluginStringReasons(value string) []string { + trimmed := strings.TrimSpace(value) + lowered := strings.ToLower(trimmed) + if trimmed == "" { + return nil + } + var reasons []string + if looksLikeRawSecret(trimmed) || strings.Contains(lowered, "raw api key") || strings.Contains(lowered, "provider key") || strings.Contains(lowered, "ai key") || strings.Contains(lowered, "raw credential") { + reasons = append(reasons, "raw credential or AI/provider key content is not allowed") + } + if strings.Contains(lowered, "direct run") || strings.Contains(lowered, "run socket") || strings.Contains(lowered, "run credential") || strings.Contains(lowered, "run token") || strings.Contains(lowered, "direct socket") { + reasons = append(reasons, "direct run access request is not allowed") + } + if strings.HasPrefix(lowered, "file://") || strings.HasPrefix(trimmed, `\\`) || looksLikeRawHostPath(trimmed) || strings.Contains(lowered, "host path") || strings.Contains(lowered, "raw host path") { + reasons = append(reasons, "raw host path access is not allowed") + } + return reasons +} + +func looksLikeRawHostPath(value string) bool { + if len(value) >= 3 && ((value[1] == ':' && value[2] == '\\') || (value[1] == ':' && value[2] == '/')) { + return true + } + lowered := strings.ToLower(value) + for _, prefix := range []string{"/users/", "/etc/", "/var/", "/tmp/", "/home/", "/root/", "/private/", "/volumes/", "/opt/"} { + if strings.HasPrefix(lowered, prefix) { + return true + } + } + return false +} + +func safeRef(value string) bool { + trimmed := strings.TrimSpace(value) + return strings.HasPrefix(trimmed, "artifact://") || strings.HasPrefix(trimmed, "manifest://") || safeRelativeJSONRef(trimmed) +} + +func safeRelativeJSONRef(value string) bool { + trimmed := strings.TrimSpace(value) + lowered := strings.ToLower(trimmed) + if trimmed == "" || strings.HasPrefix(trimmed, "/") || strings.Contains(trimmed, "..") || strings.Contains(trimmed, "://") || strings.Contains(trimmed, `\`) || !strings.HasSuffix(lowered, ".json") { + return false + } + if len(trimmed) >= 2 && trimmed[1] == ':' { + return false + } + return true +} + +func looksLikeRawSecret(value string) bool { + trimmed := strings.TrimSpace(strings.ToLower(value)) + if trimmed == "" { + return false + } + if strings.HasPrefix(trimmed, "secret://") || strings.HasPrefix(trimmed, "vault://") || strings.HasPrefix(trimmed, "env://") { + return false + } + return strings.HasPrefix(trimmed, "sk-") || + strings.HasPrefix(trimmed, "sk_") || + strings.Contains(trimmed, "api_key=") || + strings.Contains(trimmed, "apikey=") || + strings.Contains(trimmed, "bearer ") +} + +func validUserStatus(status domain.UserStatus) bool { + switch status { + case domain.UserStatusActive, domain.UserStatusDisabled, domain.UserStatusPending: + return true + default: + return false + } +} + +func validUserRole(role string) bool { + switch strings.ToLower(strings.TrimSpace(role)) { + case "admin", "platform-admin", "platformadmin", "server-owner", "owner", "server-admin", "operator": + return true + default: + return false + } +} + +func validAIProviderKind(kind domain.AIProviderKind) bool { + switch kind { + case domain.AIProviderKindOpenAICompatible, domain.AIProviderKindOpenAI, domain.AIProviderKindClaude, domain.AIProviderKindGemini, domain.AIProviderKindOllama, domain.AIProviderKindCustom: + return true + default: + return false + } +} + +func validAIRelayMode(mode domain.AIRelayMode) bool { + switch mode { + case domain.AIRelayModeDirect, domain.AIRelayModeRelay, domain.AIRelayModeLocal: + return true + default: + return false + } +} + +func validAIProviderStatus(status domain.AIProviderStatus) bool { + switch status { + case domain.AIProviderStatusActive, domain.AIProviderStatusDisabled, domain.AIProviderStatusError: + return true + default: + return false + } +} + +func validGamePluginStatus(status domain.GamePluginStatus) bool { + switch status { + case domain.GamePluginStatusInstalled, domain.GamePluginStatusDisabled, domain.GamePluginStatusInvalid, domain.GamePluginStatusUpdating: + return true + default: + return false + } +} + +func validPluginMarketplaceStateAction(action domain.PluginMarketplaceStateAction) bool { + switch action { + case domain.PluginMarketplaceStateActionInstall, domain.PluginMarketplaceStateActionEnable, domain.PluginMarketplaceStateActionDisable: + return true + default: + return false + } +} + +func validPluginRunCapability(capability string) bool { + switch capability { + case "process.install", "process.start", "process.stop", "process.restart", "process.status", + "config.write", + "files.list", "files.read", "files.write", "files.patch", + "file.list", "file.read", "file.write", "file.patch", + "logs.read", "log.query", + "artifacts.read", "artifacts.write", "artifact.read", "artifact.write", + "ai.invoke": + return true + default: + return false + } +} + +func validFileOperationKind(operation domain.FileOperationKind) bool { + switch operation { + case domain.FileOperationRead, domain.FileOperationWrite: + return true + default: + return false + } +} + +func validConfigFileKey(key string) bool { + switch key { + case "server.properties", "config/server.properties": + return true + default: + return strings.HasPrefix(key, "config/") && (strings.HasSuffix(key, ".properties") || strings.HasSuffix(key, ".json")) + } +} + +func validLogicalFileKey(key string) bool { + trimmed := strings.TrimSpace(key) + if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength { + return false + } + if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || looksLikeRawHostPath(key) || containsUnsafeRuntimeSecret(key) { + return false + } + for _, char := range key { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' { + continue + } + return false + } + return true +} + +func validScopedInputRef(ref string) bool { + trimmed := strings.TrimSpace(ref) + if trimmed == "" || trimmed != ref || containsUnsafeRuntimeSecret(ref) || looksLikeRawHostPath(ref) { + return false + } + return strings.HasPrefix(ref, "input://") || strings.HasPrefix(ref, "artifact://") +} + +func validPluginPermission(permission string) bool { + switch permission { + case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "ai.invoke": + return true + default: + return false + } +} + +func validPluginBridgeAction(action domain.PluginBridgeAction) bool { + switch action { + case domain.PluginBridgeActionServerInstancesRead, + domain.PluginBridgeActionJobsDispatch, + domain.PluginBridgeActionLogsQuery, + domain.PluginBridgeActionArtifactsOpen, + domain.PluginBridgeActionFilesRequest, + domain.PluginBridgeActionAIInvoke: + return true + default: + return false + } +} + +func requiredBridgePermissions(action domain.PluginBridgeAction) []string { + switch action { + case domain.PluginBridgeActionServerInstancesRead: + return []string{"server.read"} + case domain.PluginBridgeActionJobsDispatch: + return []string{"server.lifecycle"} + case domain.PluginBridgeActionLogsQuery: + return []string{"server.logs.read"} + case domain.PluginBridgeActionArtifactsOpen: + return []string{"server.artifacts.read"} + case domain.PluginBridgeActionFilesRequest: + return []string{"server.files.read"} + case domain.PluginBridgeActionAIInvoke: + return []string{"ai.invoke"} + default: + return nil + } +} + +func effectivePagePermissions(plugin domain.GamePlugin, routeKey string) []string { + declared := plugin.DeclaredPermissions + page, found := findPluginPage(plugin.Pages, routeKey) + if !found || len(page.Permissions) == 0 { + return domain.CopyStringSlice(declared) + } + var effective []string + for _, permission := range page.Permissions { + if containsString(declared, permission) { + effective = append(effective, permission) + } + } + return effective +} + +func findPluginPage(pages []domain.GamePluginPage, routeKey string) (domain.GamePluginPage, bool) { + for _, page := range pages { + if page.Key == routeKey { + return page, true + } + } + return domain.GamePluginPage{}, false +} + +func containsAll(values []string, required []string) bool { + for _, value := range required { + if !containsString(values, value) { + return false + } + } + return true +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func validPluginSupportedOS(osName string) bool { + switch osName { + case "windows", "linux", "darwin": + return true + default: + return false + } +} + +func validPluginPagePath(path string) bool { + if !strings.HasPrefix(path, "/") || strings.Contains(path, "..") || strings.Contains(path, `\`) || strings.Contains(path, "://") { + return false + } + for _, char := range path[1:] { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '.' || char == '/' || char == '-' { + continue + } + return false + } + return true +} + +func validAIPurpose(purpose string) bool { + switch purpose { + case "config.read", "config.generate", "config.suggest", "logs.diagnose", "files.suggest": + return true + default: + return false + } +} + +func validServerInstanceState(state domain.ServerInstanceState) bool { + switch state { + case domain.ServerInstanceStateDraft, domain.ServerInstanceStateInstalling, domain.ServerInstanceStateReady, domain.ServerInstanceStateRunning, domain.ServerInstanceStateStopped, domain.ServerInstanceStateFailed, domain.ServerInstanceStateDeleted: + return true + default: + return false + } +} + +func validRunEndpointStatus(status domain.RunEndpointStatus) bool { + switch status { + case domain.RunEndpointStatusOnline, domain.RunEndpointStatusOffline, domain.RunEndpointStatusDegraded, domain.RunEndpointStatusDisabled: + return true + default: + return false + } +} + +func validJobState(state domain.JobState) bool { + switch state { + case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled: + return true + default: + return false + } +} + +func validArtifactOwnerKind(kind domain.ArtifactOwnerKind) bool { + switch kind { + case domain.ArtifactOwnerKindPlatform, domain.ArtifactOwnerKindPlugin, domain.ArtifactOwnerKindServerInstance, domain.ArtifactOwnerKindJob: + return true + default: + return false + } +} + +func validArtifactState(state domain.ArtifactState) bool { + switch state { + case domain.ArtifactStateUploading, domain.ArtifactStateAvailable, domain.ArtifactStateExpired, domain.ArtifactStateFailed: + return true + default: + return false + } +} + +func validLogStreamSource(source domain.LogStreamSource) bool { + switch source { + case domain.LogStreamSourceProcess, domain.LogStreamSourceFile, domain.LogStreamSourcePlugin: + return true + default: + return strings.TrimSpace(string(source)) != "" + } +} + +func validLogStorageBackend(backend domain.LogStorageBackend) bool { + switch backend { + case domain.LogStorageBackendLocalSegments, domain.LogStorageBackendLoki, domain.LogStorageBackendClickHouse, domain.LogStorageBackendOpenSearch, domain.LogStorageBackendElasticsearch: + return true + default: + return false + } +} + +func validAuditResult(result domain.AuditResult) bool { + switch result { + case domain.AuditResultSuccess, domain.AuditResultDenied, domain.AuditResultFailed, domain.AuditResultQueued: + return true + default: + return false + } +} diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go new file mode 100644 index 0000000..5f6c65a --- /dev/null +++ b/platform/validator/resources_test.go @@ -0,0 +1,221 @@ +package validator + +import ( + "strings" + "testing" + + "browser.local/platform/domain" +) + +func TestValidateAIProviderRejectsRawSecret(t *testing.T) { + provider := validAIProvider() + provider.APIKeyRef = "sk-test-secret" + + err := ValidateAIProvider(provider) + if err == nil || !strings.Contains(err.Error(), "apiKeyRef must reference secret storage") { + t.Fatalf("expected raw secret rejection, got %v", err) + } +} + +func TestValidateAIProviderRequiresDefaultModelInModels(t *testing.T) { + provider := validAIProvider() + provider.DefaultModel = "missing-model" + + err := ValidateAIProvider(provider) + if err == nil || !strings.Contains(err.Error(), "defaultModel must be included") { + t.Fatalf("expected default model validation, got %v", err) + } +} + +func TestValidateGamePluginManifestRegistration(t *testing.T) { + registration := validGamePluginManifestRegistration() + + if err := ValidateGamePluginManifestRegistration(registration); err != nil { + t.Fatalf("expected manifest registration to validate, got %v", err) + } +} + +func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing.T) { + registration := validGamePluginManifestRegistration() + registration.Manifest.Description = "requires direct run socket and raw AI key material" + registration.ManifestRef = "file:///etc/plugin.json" + + err := ValidateGamePluginManifestRegistration(registration) + if err == nil { + t.Fatal("expected unsafe manifest registration rejection") + } + message := err.Error() + for _, want := range []string{"manifestRef is unsafe", "direct run access", "raw credential or AI/provider key"} { + if !strings.Contains(message, want) { + t.Fatalf("expected validation error to contain %q, got %v", want, err) + } + } +} + +func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) { + registration := validGamePluginManifestRegistration() + registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket") + registration.Manifest.Permissions = append(registration.Manifest.Permissions, "provider.key.read") + + err := ValidateGamePluginManifestRegistration(registration) + if err == nil || !strings.Contains(err.Error(), "manifest.capabilities") || !strings.Contains(err.Error(), "permissions") { + t.Fatalf("expected unsafe capability and permission rejection, got %v", err) + } +} + +func TestValidateServerInstanceDependencies(t *testing.T) { + instance := domain.ServerInstance{ + ID: "server-1", + PluginID: "server.scum", + PluginVersion: "1.0.0", + RunEndpointID: "run-local", + Name: "SCUM #1", + State: domain.ServerInstanceStateDraft, + ConfigVersion: 1, + } + plugin := domain.GamePlugin{ + ID: "server.scum", + Version: "1.0.0", + RequiredRunCapabilities: []string{"process.start", "logs.read"}, + Status: domain.GamePluginStatusInstalled, + } + endpoint := domain.RunEndpoint{ + ID: "run-local", + Status: domain.RunEndpointStatusOnline, + Capabilities: []string{"process.start", "logs.read", "files.read"}, + } + + if err := ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { + t.Fatalf("expected valid dependencies, got %v", err) + } + + endpoint.Capabilities = []string{"process.start"} + err := ValidateServerInstanceDependencies(instance, plugin, endpoint) + if err == nil || !strings.Contains(err.Error(), "logs.read") { + t.Fatalf("expected missing capability error, got %v", err) + } +} + +func TestValidateServerInstanceRejectsDeletedCreateState(t *testing.T) { + instance := domain.ServerInstance{ + ID: "server-1", + PluginID: "server.scum", + PluginVersion: "1.0.0", + RunEndpointID: "run-local", + Name: "SCUM #1", + State: domain.ServerInstanceStateDeleted, + } + + err := ValidateServerInstance(instance) + if err == nil || !strings.Contains(err.Error(), "state must not be deleted") { + t.Fatalf("expected deleted state rejection, got %v", err) + } +} + +func TestValidateJobBoundsProgress(t *testing.T) { + job := domain.Job{ + ID: "job-1", + RunEndpointID: "run-local", + Capability: "process.start", + IdempotencyKey: "idem-1", + State: domain.JobStateQueued, + Progress: domain.JobProgress{Percent: 101}, + } + + err := ValidateJob(job) + if err == nil || !strings.Contains(err.Error(), "progress.percent") { + t.Fatalf("expected progress bounds error, got %v", err) + } +} + +func TestValidateArtifactLogAndAudit(t *testing.T) { + artifact := domain.Artifact{ + ID: "artifact-1", + OwnerKind: domain.ArtifactOwnerKindJob, + OwnerID: "job-1", + SizeBytes: 10, + Checksum: "sha256:abc", + State: domain.ArtifactStateAvailable, + } + if err := ValidateArtifact(artifact); err != nil { + t.Fatalf("expected artifact to validate, got %v", err) + } + + stream := domain.LogStream{ + ID: "log-1", + ServerInstanceID: "server-1", + Source: domain.LogStreamSourceFile, + StreamKey: "server.log", + StorageBackend: domain.LogStorageBackendLocalSegments, + RetentionPolicy: "default", + } + if err := ValidateLogStream(stream); err != nil { + t.Fatalf("expected log stream to validate, got %v", err) + } + + audit := domain.AuditEvent{ + ID: "audit-1", + ActorID: "user-1", + Action: "server.create", + ResourceKind: "server-instance", + ResourceID: "server-1", + Result: domain.AuditResultSuccess, + Summary: "created server instance", + } + if err := ValidateAuditEvent(audit); err != nil { + t.Fatalf("expected audit event to validate, got %v", err) + } + + audit.Summary = "bearer raw-secret" + if err := ValidateAuditEvent(audit); err == nil || !strings.Contains(err.Error(), "summary must be redacted") { + t.Fatalf("expected audit redaction error, got %v", err) + } +} + +func validAIProvider() domain.AIProvider { + return domain.AIProvider{ + ID: "ai.openai", + Name: "OpenAI", + Kind: domain.AIProviderKindOpenAI, + BaseURL: "https://api.openai.com/v1", + APIKeyRef: "secret://providers/openai", + Models: []string{"gpt-4.1", "gpt-4.1-mini"}, + DefaultModel: "gpt-4.1", + RelayMode: domain.AIRelayModeDirect, + TimeoutMS: 30000, + Status: domain.AIProviderStatusActive, + RedactionPolicy: "default", + } +} + +func validGamePluginManifestRegistration() domain.GamePluginManifestRegistration { + return domain.GamePluginManifestRegistration{ + ManifestRef: "artifact://manifests/game.example/0.1.0", + Manifest: domain.GamePluginManifest{ + ID: "game.example", + Name: "Example Server", + Description: "Development plugin", + Version: "0.1.0", + Kind: "game-plugin", + Tags: []string{"example", "development"}, + Server: domain.GamePluginManifestServer{ + Type: "example", + DisplayName: "Example Server", + SupportedOS: []string{"linux", "darwin"}, + CreateFormSchema: "schemas/create-form.schema.json", + }, + Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}, + Permissions: []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"}, + Actions: domain.PluginLifecycleActions{ + Install: "actions/install.json", + Start: "actions/start.json", + Stop: "actions/stop.json", + Restart: "actions/restart.json", + }, + Pages: []domain.GamePluginPage{ + {Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}}, + }, + AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}}, + }, + } +} diff --git a/platform/validator/rules.md b/platform/validator/rules.md new file mode 100644 index 0000000..74b8a08 --- /dev/null +++ b/platform/validator/rules.md @@ -0,0 +1,10 @@ +# Platform Validation Rules + +- API handlers must use named DTOs from `platform/dto`. +- Platform services must not accept raw plugin-provided host paths. +- AI provider secrets must be stored by reference and redacted from logs, audit, and plugin bridge responses. +- Game management plugin installation must validate manifest identity, server type, required run capabilities, pages, permissions, and schema references. +- Server instance creation must validate plugin installation state and run endpoint capability compatibility. +- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress/audit summaries, artifact metadata, log stream cursors, and run capability compatibility. +- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing. +- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs. diff --git a/platform/validator/server_lifecycle.go b/platform/validator/server_lifecycle.go new file mode 100644 index 0000000..b113bcc --- /dev/null +++ b/platform/validator/server_lifecycle.go @@ -0,0 +1,50 @@ +package validator + +import ( + "fmt" + "strings" + + "browser.local/platform/domain" +) + +const maxLifecycleIdempotencyKeyLength = 160 + +func ValidateServerLifecycleCreate(create domain.ServerLifecycleCreate) error { + var violations []string + violations = appendRequired(violations, "id", create.ID) + violations = appendRequired(violations, "pluginId", create.PluginID) + violations = appendRequired(violations, "runEndpointId", create.RunEndpointID) + violations = appendRequired(violations, "name", create.Name) + violations = appendLifecycleIdempotencyViolations(violations, create.IdempotencyKey) + return finish(violations) +} + +func ValidateServerLifecycleCommand(command domain.ServerLifecycleCommand) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", command.ServerInstanceID) + if command.ExpectedConfigVersion <= 0 { + violations = append(violations, "expectedConfigVersion must be positive") + } + violations = appendLifecycleIdempotencyViolations(violations, command.IdempotencyKey) + return finish(violations) +} + +func ValidateServerLifecycleAction(action domain.ServerLifecycleAction) error { + switch action { + case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop: + return nil + default: + return ValidationError{Violations: []string{fmt.Sprintf("action %q is invalid", action)}} + } +} + +func appendLifecycleIdempotencyViolations(violations []string, key string) []string { + violations = appendRequired(violations, "idempotencyKey", key) + if len(key) > maxLifecycleIdempotencyKeyLength { + violations = append(violations, "idempotencyKey is too long") + } + if strings.TrimSpace(key) != key { + violations = append(violations, "idempotencyKey must not have surrounding whitespace") + } + return violations +} diff --git a/platform_web/.env.example b/platform_web/.env.example new file mode 100644 index 0000000..e19796a --- /dev/null +++ b/platform_web/.env.example @@ -0,0 +1,4 @@ +VITE_PLATFORM_API_BASE_URL=/api/v1 +PLATFORM_API_PROXY=http://127.0.0.1:8080 +VITE_ENABLE_LOCAL_AUTH_FALLBACK=true + diff --git a/platform_web/AGENTS.md b/platform_web/AGENTS.md new file mode 100644 index 0000000..5b067c1 --- /dev/null +++ b/platform_web/AGENTS.md @@ -0,0 +1,40 @@ +# AGENTS.md for platform_web + +This file applies to `platform_web/`. + +## Frontend Scope + +Build the actual management console, not a marketing site. Required first-party areas are 首页、服务器管理、插件市场、用户管理、AI 提供商管理. + +## Structure Rules + +Do not define API clients, shared DTOs, route definitions, schemas, or bridge contracts inside page components. Put them in dedicated directories. + +## Interaction Rules + +Do not use fixed left-list/right-detail master-detail layouts for server or plugin details. Use detail routes, modals, or drawers. + +## Visual Style Rules + +The platform_web visual system is a game operations console, not a generic SaaS dashboard. The default theme is black mecha; the selectable alternate theme is magical-girl. Future UI work must preserve the current style contract: + +- Use the shared theme tokens in `theme/tokens.ts` and shared styles in `theme/base.css`; do not add page-local opaque card systems, one-off dark dashboards, or unrelated visual languages. +- Keep major surfaces translucent enough for the selected desktop/background to remain visible: side navigation, account/profile controls, metric cards, console panels, server cards, server detail headers, tables, drawers, dialogs, plugin groups, and operation history. +- Preserve theme-specific materials: black mecha uses dark cockpit panels, angular clipped frames, cyan scanner lines, and amber energy accents; magical-girl uses pink jelly glass, gold star frames, ribbon glow, and visible magic-circle motifs. +- Theme changes must be isolated and complete. Switching from magical-girl to black mecha must clear previous magical variables and update root theme marker, sidebar subtitle, active navigation frame, swatch strip, and shared surface accessories together. +- Do not double-frame nested content. A `.state-view` inside an already framed shared parent such as a console panel, card, table wrapper, plugin group, or operation item is content only; the parent owns the visible border and accessory layer. +- Keep the primary menu as a compact admin sidebar with two states: expanded text menu and collapsed icon rail. Do not reintroduce the single-column / double-column menu toggle. The same route order should render as a restrained dark mecha operations rail in black mecha and as a semi-transparent pink frosted-glass rail with star-framed active states in magical-girl. +- Use `components/MagicalParticleLayer.tsx` for full-workspace theme-aware ultimate effects. Each theme should have a distinct low-cost “大招” scene, not a dense field of tiny rotating particles. Do not reintroduce hardcoded fixed decorative DOM/CSS elements such as page-local sparkles, hearts, moons, snowflakes, or sigils. +- Built-in backgrounds must stay original CSS/generated motif desktops. Do not bundle recognizable third-party character art. User-uploaded backgrounds are allowed and must render behind readable contrast overlays. +- Uploaded backgrounds take precedence over built-in desktop presets; the selected preset remains the fallback after the upload is removed. +- Non-dangerous commands may use theme-appropriate lucide icons. Destructive, failed, warning, and safety-critical operations must retain familiar warning/status iconography and text. +- Status, errors, operation results, logs, configuration diffs, and LLM review output must remain readable, traceable, and not color-only. +- Cards and framed repeated items should keep 8px-or-less radii unless a native control shape requires a pill or circle. + +See `theme/README.md` before changing theme tokens, shared CSS surfaces, page chrome, account/theme settings, or background behavior. + +## Verification Rules + +If a change touches UI pages or interactions, verify the key workflow in a browser before claiming acceptance. + +For theme, frame, or uploaded-background changes, the browser walkthrough must include both directions of theme switching and must explicitly check that nested empty/loading/error states do not render a second border or accessory. diff --git a/platform_web/Dockerfile b/platform_web/Dockerfile new file mode 100644 index 0000000..2ef2f23 --- /dev/null +++ b/platform_web/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 + +FROM node:22.17.0-alpine AS build +WORKDIR /src/platform_web +COPY platform_web/package.json platform_web/package-lock.json ./ +RUN npm ci +COPY platform_web/ ./ +ENV VITE_PLATFORM_API_BASE_URL=/api/v1 +RUN npm run build + +FROM nginx:1.27-alpine +COPY platform_web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/platform_web/dist /usr/share/nginx/html +EXPOSE 80 + diff --git a/platform_web/README.md b/platform_web/README.md new file mode 100644 index 0000000..6561a05 --- /dev/null +++ b/platform_web/README.md @@ -0,0 +1,91 @@ +# platform_web + +Management console frontend for the game server management platform. + +## Required Pages + +- 首页 +- 服务器管理 +- 插件市场 +- 用户管理 +- AI 提供商管理 + +## Required Directory Plan + +Implementation should use dedicated directories for: + +- `api/`: platform API clients and API DTO types. +- `routes/`: route definitions and guards. +- `pages/`: page-level views. +- `components/`: reusable UI components. +- `stores/`: state stores. +- `contracts/`: frontend page, plugin page bridge, and bridge contracts. +- `schemas/`: frontend validation schemas. +- `theme/`: design tokens and styling primitives. +- `utils/`: shared frontend helpers. + +Plugin page must be hosted by platform_web with safe platform context and without raw credentials. + +## Visual Direction + +The management console uses a unified game-operations visual system with two first-party themes. The default theme is black mecha: dark cockpit panels, cyan scanner light, angular clipped frames, tactical grid lines, and amber energy accents. The optional theme is magical-girl: pink jelly glass, gold star frames, ribbon glow, and visible magic-circle motifs. It must not drift into a generic opaque SaaS dashboard. + +Style guarantees for future changes: + +- Use `theme/tokens.ts` for palettes, built-in background presets, storage keys, and theme application helpers. +- Use `theme/base.css` shared classes for shell, navigation, cards, panels, tables, drawers, dialogs, command buttons, status pills, logs, diffs, plugin groups, and operation history. +- Major surfaces stay translucent so the selected desktop/background remains visible while text remains readable. +- Built-in desktops are original CSS/generated motif backgrounds. User-uploaded backgrounds are supported and take visual precedence over the selected preset. +- Global ultimate motion is owned by `components/MagicalParticleLayer.tsx`; each theme should render one visible low-cost effect, such as a mecha scanner/core or magical-girl magic circle, rather than many tiny rotating particles. Do not add page-local fixed decorative spans or backdrop CSS. +- Keep operational clarity: status is text/icon plus color, logs and diffs stay high contrast, and operation feedback remains traceable. +- Read `theme/README.md` before changing style tokens or adding a new shared surface pattern. + +## Development Baseline + +Tooling: + +- Node 22.17.0. +- npm 11.6.1. +- Vite 7, React 19, TypeScript 5. + +Commands: + +```bash +npm install +npm run dev +npm run typecheck +npm run test +npm run acceptance:browser +npm run build +npm run preview +``` + +Runtime configuration: + +- `VITE_PLATFORM_API_BASE_URL`: platform API base URL, default `/api/v1`. +- `PLATFORM_API_PROXY`: Vite dev-server proxy target for `/api/v1` and `/healthz`, default `http://127.0.0.1:8080`. +- `VITE_ENABLE_LOCAL_AUTH_FALLBACK`: enables local development auth fallback when set to `true`. + +For local direct debugging, copy `platform_web/.env.example` to `platform_web/.env`, edit the values, and run: + +```bash +npm run dev +``` + +For Docker, the web console is built with `VITE_PLATFORM_API_BASE_URL=/api/v1` and served by Nginx. Nginx proxies `/api/v1` and `/healthz` to the `platform` compose service, so browser code never needs a direct backend container address. + +Current UI behavior is a browser-verifiable console shell with the required first-party page routes. Data-backed workflows, plugin page hosting, and API integration belong to later OpenSpec changes. + +Browser walkthrough baseline: + +1. Start `npm run dev`. +2. Open the local Vite URL. +3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths. + +Automated browser acceptance uses the repository local debug stack: + +```bash +LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance ../scripts/browser-acceptance.sh +``` + +This command verifies the API-backed local debug console path, first-party route markers, plugin/server operation proof, fallback rejection, and forbidden-fragment scans. It writes evidence under `/browser-acceptance/`. diff --git a/platform_web/acceptance/browser-acceptance.mjs b/platform_web/acceptance/browser-acceptance.mjs new file mode 100644 index 0000000..8dee7bd --- /dev/null +++ b/platform_web/acceptance/browser-acceptance.mjs @@ -0,0 +1,870 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const platformUrl = process.env.LOCAL_DEBUG_PLATFORM_URL || `http://127.0.0.1:${process.env.LOCAL_DEBUG_PLATFORM_PORT || "18080"}`; +const webUrl = process.env.LOCAL_DEBUG_WEB_URL || `http://127.0.0.1:${process.env.LOCAL_DEBUG_WEB_PORT || "5173"}`; +const localDebugRoot = process.env.LOCAL_DEBUG_ROOT || path.resolve(".local-debug"); +const evidenceDir = process.env.BROWSER_ACCEPTANCE_EVIDENCE_DIR || path.join(localDebugRoot, "browser-acceptance"); +const apiUrl = `${platformUrl}/api/v1`; + +const forbiddenFragments = [ + { name: "host user path", pattern: /\/Users\// }, + { name: "private tmp path", pattern: /\/private\// }, + { name: "unix socket", pattern: /unix:\/\// }, + { name: "tcp socket", pattern: /tcp:\/\// }, + { name: "bearer token", pattern: /Bearer\s+/ }, + { name: "raw api key", pattern: /sk-[A-Za-z0-9_-]+/ }, + { name: "password query", pattern: /password=/ }, + { name: "raw api key ref field", pattern: /apiKeyRef/ }, + { name: "raw api key field", pattern: /rawApiKey/ }, + { name: "session token", pattern: /sessionToken|run session token/i }, + { name: "direct run URL", pattern: /direct run URL/i }, + { name: "plugin-owned transport", pattern: /plugin-owned transport/i } +]; + +const fallbackFragments = [ + { name: "English fallback", pattern: /fallback/i }, + { name: "demo-only", pattern: /demo-only/i }, + { name: "local fallback", pattern: /local fallback/i }, + { name: "Chinese fallback", pattern: /本地回退|本地演示数据|演示数据|模拟数据|本地视图/ } +]; + +async function main() { + await mkdir(evidenceDir, { recursive: true }); + + const session = await loginApi(); + const authHeaders = { Authorization: `Bearer ${session.sessionId}` }; + await ensureAiProvider(authHeaders); + + const [instances, endpoints, jobs, plugins, marketplace, users, providers, logStreams, artifacts, usage] = await Promise.all([ + getJson("/server-instances", authHeaders), + getJson("/run/endpoints?status=online", authHeaders), + getJson("/jobs?serverInstanceId=server-local-debug", authHeaders), + getJson("/game-plugins", authHeaders), + getJson("/plugin-marketplace/plugins", authHeaders), + getJson("/users", authHeaders), + getJson("/ai-providers", authHeaders), + getJson("/log-streams", authHeaders), + getJson("/artifacts", authHeaders), + getJson("/metrics/platform", authHeaders) + ]); + + const server = findRequired(instances.items, (item) => item.id === "server-local-debug", "server-local-debug instance"); + const runEndpoint = findRequired(endpoints.items, (item) => item.id === "run-local-debug", "run-local-debug endpoint"); + const plugin = findRequired(plugins.items, (item) => item.id === "game.example", "game.example plugin"); + const marketplacePlugin = findRequired(marketplace.items, (item) => item.id === "game.example", "game.example marketplace plugin"); + const operator = findRequired(users.items, (item) => item.email === "operator.local@example.test", "operator local user"); + const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyRef?.startsWith("secret://"), "redacted AI provider"); + + assertEqual(server.pluginId, "game.example", "server is backed by game.example"); + assertEqual(server.runEndpointId, "run-local-debug", "server is assigned to run-local-debug"); + assertIncludes(runEndpoint.capabilities, "process.install", "run endpoint exposes process.install"); + assertIncludes(runEndpoint.capabilities, "process.start", "run endpoint exposes process.start"); + assertIncludes(runEndpoint.capabilities, "process.stop", "run endpoint exposes process.stop"); + assertIncludes(plugin.requiredRunCapabilities, "process.start", "plugin requires process.start"); + assertIncludes(plugin.bridgeActions, "logs.query", "plugin exposes logs.query bridge action"); + assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action"); + assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability"); + assertSafeRedactedRef(aiProvider.apiKeyRef, "AI provider key reference"); + + const chrome = await startChrome(); + const evidence = { + checkedAt: new Date().toISOString(), + platformUrl, + webUrl, + localDebugRoot, + seedEvidenceDir: path.join(localDebugRoot, "smoke"), + session: { + userId: session.user.id, + displayName: session.user.displayName, + source: "api" + }, + environment: await verifyFrontendEnvironment(), + browser: { chromePath: chrome.chromePath }, + apiProof: { + server: pick(server, ["id", "name", "pluginId", "pluginVersion", "runEndpointId", "state", "configVersion"]), + runEndpoint: pick(runEndpoint, ["id", "displayName", "status", "capabilities"]), + plugin: pick(plugin, ["id", "name", "version", "status", "manifestRef", "requiredRunCapabilities", "bridgeActions", "declaredPermissions"]), + marketplacePlugin: pick(marketplacePlugin, ["id", "name", "version", "status", "manifestRef", "capabilities", "bridgeActions", "declaredPermissions"]), + operator: pick(operator, ["id", "displayName", "email", "status", "roles"]), + aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "apiKeyRef"]), + jobs: jobs.items.map((job) => pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"])), + logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])), + artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])), + usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"]) + }, + routes: [], + safety: { + forbiddenFragments: forbiddenFragments.map((item) => item.name), + fallbackFragments: fallbackFragments.map((item) => item.name) + } + }; + + try { + await chrome.navigate(webUrl); + const loginState = await loginInBrowser(chrome); + evidence.login = loginState; + + const routeChecks = [ + { + name: "首页", + hash: "#/home", + markers: ["平台概览", "数据已加载", "game.example", "运行节点", "CPU"] + }, + { + name: "服务器管理", + hash: "#/servers", + markers: ["服务器管理", server.name, server.id, "创建服务器", "全部", "离线"] + }, + { + name: "插件市场", + hash: "#/plugins", + markers: [ + "插件市场", + "平台 API", + marketplacePlugin.id, + marketplacePlugin.manifestRef, + "process.install", + "process.start", + "process.stop", + "server.instances.read", + "jobs.dispatch", + "logs.query", + "artifacts.open" + ] + }, + { + name: "用户管理", + hash: "#/users", + markers: ["用户管理", "账号 API 已连接", operator.displayName, operator.email, "平台数据"] + }, + { + name: "AI 提供商管理", + hash: "#/aiProviders", + markers: ["AI 提供商管理", "已连接", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"] + }, + { + name: "服务器详情", + hash: "#/servers/server-local-debug", + markers: [ + server.name, + `${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`, + "启动", + "停止", + "日志", + "配置", + "插件控制", + "AI 助手", + "操作历史" + ] + } + ]; + + for (const route of routeChecks) { + const state = await verifyBrowserRoute(chrome, route.hash, route.markers, route.name); + evidence.routes.push(state); + } + + const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]); + evidence.routes.push({ name: "服务器详情 / 插件控制", url: await chrome.url(), ...pluginControls }); + + evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server); + + const operationProof = await verifyLifecycleOperation(authHeaders, server, chrome); + evidence.operationProof = operationProof; + } finally { + await chrome.close(); + } + + const evidencePath = path.join(evidenceDir, "browser-acceptance-evidence.json"); + await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); + console.log("browser acceptance passed"); + console.log(`evidence directory: ${evidenceDir}`); + console.log(`evidence file: ${evidencePath}`); +} + +async function loginApi() { + const response = await postJson("/auth/login", { + account: "operator.local@example.test", + password: "operator-local" + }); + if (!response.sessionId || response.status !== "authenticated") { + throw new Error("local debug API login did not return an active session"); + } + return response; +} + +async function loginInBrowser(chrome) { + await chrome.waitForText(["账号 / 邮箱", "密码", "登录"], "login form"); + await chrome.evaluate(() => { + const inputs = Array.from(document.querySelectorAll("input")); + const account = inputs.find((input) => input.type !== "password"); + const password = inputs.find((input) => input.type === "password"); + const submit = document.querySelector('button[type="submit"]'); + if (!(account instanceof HTMLInputElement) || !(password instanceof HTMLInputElement) || !(submit instanceof HTMLButtonElement)) { + throw new Error("login controls not found"); + } + const setInputValue = (input, value) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }; + setInputValue(account, "operator.local@example.test"); + setInputValue(password, "operator-local"); + submit.click(); + }); + await chrome.waitForText(["平台概览", "数据已加载"], "post-login home"); + const visibleText = await chrome.visibleText(); + scanText(visibleText, "登录后首页"); + return { + url: await chrome.url(), + requiredMarkers: ["平台概览", "数据已加载"], + fallbackScan: "passed", + forbiddenFragmentScan: "passed" + }; +} + +async function verifyBrowserRoute(chrome, hash, markers, name) { + await chrome.evaluate((nextHash) => { + window.location.hash = nextHash; + }, hash); + await chrome.waitForText(markers, name); + const visibleText = await chrome.visibleText(); + assertMarkers(visibleText, markers, name); + scanText(visibleText, name); + return { + name, + url: await chrome.url(), + requiredMarkers: markers, + fallbackScan: "passed", + forbiddenFragmentScan: "passed", + textSample: visibleText.slice(0, 1200) + }; +} + +async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) { + const walkthroughs = []; + const scenarios = [ + { + name: "desktop / black mecha", + viewport: { width: 1440, height: 960, mobile: false }, + paletteId: "mecha-black", + backgroundId: "mecha-grid", + themeMarkers: ["Mecha Game Console", "黑色机甲 / OPS"] + }, + { + name: "mobile / black mecha", + viewport: { width: 390, height: 844, mobile: true }, + paletteId: "mecha-black", + backgroundId: "mecha-grid", + themeMarkers: ["Mecha Game Console", "黑色机甲 / OPS"] + }, + { + name: "desktop / magical-girl", + viewport: { width: 1440, height: 960, mobile: false }, + paletteId: "magical-girl", + backgroundId: "magic-stage", + themeMarkers: ["Mecha Game Console", "魔法少女 / OPS"] + }, + { + name: "mobile / magical-girl", + viewport: { width: 390, height: 844, mobile: true }, + paletteId: "magical-girl", + backgroundId: "magic-stage", + themeMarkers: ["Mecha Game Console", "魔法少女 / OPS"] + } + ]; + + for (const scenario of scenarios) { + await chrome.setViewport(scenario.viewport); + await applyBrowserTheme(chrome, scenario.paletteId, scenario.backgroundId); + const routeEvidence = []; + for (const route of routeChecks) { + const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...scenario.themeMarkers], `${scenario.name} / ${route.name}`); + const layout = await chrome.layoutSnapshot(); + assertNoVisibleLayoutIssues(layout, `${scenario.name} / ${route.name}`); + routeEvidence.push({ + name: route.name, + url: state.url, + requiredMarkers: state.requiredMarkers, + fallbackScan: state.fallbackScan, + forbiddenFragmentScan: state.forbiddenFragmentScan, + layout, + textSample: state.textSample + }); + } + + await chrome.evaluate(() => { + window.location.hash = "#/servers/server-local-debug"; + }); + await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`); + const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]); + const pluginLayout = await chrome.layoutSnapshot(); + assertNoVisibleLayoutIssues(pluginLayout, `${scenario.name} / plugin controls`); + routeEvidence.push({ + name: "服务器详情 / 插件控制", + url: await chrome.url(), + requiredMarkers: pluginControls.requiredMarkers, + fallbackScan: pluginControls.fallbackScan, + forbiddenFragmentScan: pluginControls.forbiddenFragmentScan, + layout: pluginLayout, + textSample: pluginControls.textSample + }); + + walkthroughs.push({ + name: scenario.name, + viewport: scenario.viewport, + paletteId: scenario.paletteId, + backgroundId: scenario.backgroundId, + routeCount: routeEvidence.length, + routes: routeEvidence + }); + } + return walkthroughs; +} + +async function applyBrowserTheme(chrome, paletteId, backgroundId) { + await chrome.evaluate( + ({ paletteId: nextPaletteId, backgroundId: nextBackgroundId }) => { + window.localStorage.setItem("platform-web.theme.palette", nextPaletteId); + window.localStorage.setItem("platform-web.theme.backgroundPreset", nextBackgroundId); + window.localStorage.removeItem("platform-web.theme.background"); + window.location.reload(); + }, + { paletteId, backgroundId } + ); + await delay(500); +} + +function assertNoVisibleLayoutIssues(layout, label) { + if (layout.horizontalOverflow > 1) { + throw new Error(`${label} has horizontal overflow: scrollWidth ${layout.scrollWidth} > viewport ${layout.innerWidth}`); + } + const overlapping = layout.overlappingControls.filter((item) => item.area > 24); + if (overlapping.length > 0) { + throw new Error(`${label} has overlapping controls: ${JSON.stringify(overlapping.slice(0, 3))}`); + } + if (layout.visibleTinyTextBoxes > 0) { + throw new Error(`${label} has ${layout.visibleTinyTextBoxes} visible clipped/tiny text boxes`); + } +} + +async function clickAndVerify(chrome, buttonText, markers) { + await chrome.evaluate((label) => { + const button = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes(label)); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`button not found: ${label}`); + } + button.click(); + }, buttonText); + await chrome.waitForText(markers, buttonText); + const visibleText = await chrome.visibleText(); + assertMarkers(visibleText, markers, buttonText); + scanText(visibleText, buttonText); + return { + requiredMarkers: markers, + fallbackScan: "passed", + forbiddenFragmentScan: "passed", + textSample: visibleText.slice(0, 1200) + }; +} + +async function ensureAiProvider(headers) { + const providers = await getJson("/ai-providers", headers); + if (providers.items.some((item) => item.id === "ai.openai")) { + return; + } + await postJson( + "/ai-providers", + { + id: "ai.openai", + name: "OpenAI Relay", + kind: "openai-compatible", + baseUrl: "https://relay.example.test/v1", + apiKeyRef: "secret://providers/openai", + models: ["gpt-4.1", "gpt-4.1-mini"], + defaultModel: "gpt-4.1-mini", + relayMode: "relay", + timeoutMs: 30000, + redactionPolicy: "default" + }, + headers + ); +} + +async function verifyFrontendEnvironment() { + const [html, packageJsonText, viteConfigText] = await Promise.all([ + fetchText(webUrl), + readFile(new URL("../package.json", import.meta.url), "utf8"), + readFile(new URL("../vite.config.ts", import.meta.url), "utf8") + ]); + const packageJson = JSON.parse(packageJsonText); + const checks = [ + { name: "serves app root", passed: html.includes('
') }, + { name: "serves module app entry", passed: html.includes('type="module"') }, + { name: "Vite proxy includes /api/v1", passed: viteConfigText.includes('"/api/v1"') }, + { name: "Vite proxy includes /healthz", passed: viteConfigText.includes('"/healthz"') }, + { name: "Vite env uses platform proxy", passed: viteConfigText.includes("PLATFORM_API_PROXY") }, + { name: "package has browser acceptance script", passed: Boolean(packageJson.scripts?.["acceptance:browser"]) } + ]; + const failed = checks.filter((check) => !check.passed); + if (failed.length > 0) { + throw new Error(`frontend environment checks failed: ${failed.map((item) => item.name).join(", ")}`); + } + return { webUrl, checks }; +} + +async function verifyLifecycleOperation(headers, server, chrome) { + const request = { expectedConfigVersion: server.configVersion, idempotencyKey: `browser-acceptance-start-${Date.now()}` }; + const result = await postJson(`/server-instances/${encodeURIComponent(server.id)}/start`, request, headers); + if (!result.accepted || !result.job?.id) { + throw new Error("lifecycle start operation did not return accepted job evidence"); + } + if (result.job.capability !== "process.start") { + throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`); + } + if (result.job.runEndpointId !== "run-local-debug") { + throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`); + } + + const job = await waitForJob(headers, server.id, result.job.id); + + await chrome.navigate(`${webUrl}/#/servers/server-local-debug`); + await chrome.waitForText([server.name, "操作历史"], "server detail after lifecycle operation"); + const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]); + + return { + action: "start", + accepted: result.accepted, + request: { + expectedConfigVersion: request.expectedConfigVersion, + idempotencyKey: request.idempotencyKey + }, + acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]), + job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]), + proof: "platform API accepted process.start and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access", + browserEvidence: { + ...historyState, + proofMode: "operation-history-entry-point" + } + }; +} + +async function waitForJob(headers, serverId, jobId) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const jobs = await getJson(`/jobs?serverInstanceId=${encodeURIComponent(serverId)}`, headers); + const job = jobs.items.find((item) => item.id === jobId); + if (job) { + return job; + } + await delay(250); + } + throw new Error(`platform jobs endpoint did not return lifecycle operation job ${jobId}`); +} + +async function startChrome() { + const chromePath = findChromePath(); + const userDataDir = path.join(evidenceDir, "chrome-profile"); + await rm(userDataDir, { recursive: true, force: true }); + await mkdir(userDataDir, { recursive: true }); + const chrome = spawn(chromePath, [ + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + `--user-data-dir=${userDataDir}`, + "--remote-debugging-port=0", + "about:blank" + ], { + stdio: ["ignore", "ignore", "pipe"] + }); + let stderr = ""; + chrome.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const activePortFile = path.join(userDataDir, "DevToolsActivePort"); + let port = ""; + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + const activePort = await readFile(activePortFile, "utf8"); + port = activePort.split(/\r?\n/)[0]?.trim(); + if (port) { + break; + } + } catch { + // keep waiting for Chrome to write DevToolsActivePort + } + await delay(250); + } + if (!port) { + chrome.kill("SIGTERM"); + throw new Error(`Chrome did not expose a DevTools port. ${stderr.slice(0, 500)}`); + } + + const targetResponse = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" }); + if (!targetResponse.ok) { + chrome.kill("SIGTERM"); + throw new Error(`failed to create Chrome target: HTTP ${targetResponse.status}`); + } + const target = await targetResponse.json(); + const client = await CdpClient.connect(target.webSocketDebuggerUrl); + await client.send("Page.enable"); + await client.send("Runtime.enable"); + return { + chromePath, + async setViewport({ width, height, mobile }) { + await client.send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: mobile ? 2 : 1, + mobile + }); + }, + async navigate(url) { + const loaded = client.waitFor("Page.loadEventFired", 15000); + await client.send("Page.navigate", { url }); + await loaded.catch(() => undefined); + }, + async evaluate(pageFunction, arg) { + const expression = `(${pageFunction.toString()})(${arg === undefined ? "" : JSON.stringify(arg)})`; + const result = await client.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text || "browser evaluation failed"); + } + return result.result?.value; + }, + async visibleText() { + return this.evaluate(() => document.body?.innerText || ""); + }, + async layoutSnapshot() { + return this.evaluate(() => { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const scrollWidth = document.documentElement.scrollWidth; + const viewportClip = { left: 0, top: 0, right: viewportWidth, bottom: viewportHeight }; + const rectFromDomRect = (rect) => ({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom + }); + const intersection = (first, second) => ({ + left: Math.max(first.left, second.left), + top: Math.max(first.top, second.top), + right: Math.min(first.right, second.right), + bottom: Math.min(first.bottom, second.bottom) + }); + const clipForElement = (element) => { + let clip = viewportClip; + let parent = element.parentElement; + while (parent) { + const style = window.getComputedStyle(parent); + const clipsX = ["auto", "scroll", "hidden", "clip"].includes(style.overflowX); + const clipsY = ["auto", "scroll", "hidden", "clip"].includes(style.overflowY); + if (clipsX || clipsY) { + const parentRect = rectFromDomRect(parent.getBoundingClientRect()); + clip = intersection(clip, { + left: clipsX ? parentRect.left : clip.left, + top: clipsY ? parentRect.top : clip.top, + right: clipsX ? parentRect.right : clip.right, + bottom: clipsY ? parentRect.bottom : clip.bottom + }); + } + parent = parent.parentElement; + } + return clip; + }; + const controls = Array.from(document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='tab']")) + .filter((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }) + .map((element) => { + const rect = element.getBoundingClientRect(); + const clipped = intersection(rectFromDomRect(rect), clipForElement(element)); + const visibleWidth = Math.max(0, clipped.right - clipped.left); + const visibleHeight = Math.max(0, clipped.bottom - clipped.top); + return { + tag: element.tagName.toLowerCase(), + text: (element.textContent || element.getAttribute("aria-label") || "").trim().slice(0, 60), + left: Math.round(clipped.left), + top: Math.round(clipped.top), + right: Math.round(clipped.right), + bottom: Math.round(clipped.bottom), + width: Math.round(visibleWidth), + height: Math.round(visibleHeight) + }; + }) + .filter((control) => control.width > 0 && control.height > 0); + + const overlappingControls = []; + for (let index = 0; index < controls.length; index += 1) { + for (let otherIndex = index + 1; otherIndex < controls.length; otherIndex += 1) { + const left = Math.max(controls[index].left, controls[otherIndex].left); + const top = Math.max(controls[index].top, controls[otherIndex].top); + const right = Math.min(controls[index].right, controls[otherIndex].right); + const bottom = Math.min(controls[index].bottom, controls[otherIndex].bottom); + const width = right - left; + const height = bottom - top; + if (width > 0 && height > 0) { + overlappingControls.push({ + first: controls[index].text || controls[index].tag, + second: controls[otherIndex].text || controls[otherIndex].tag, + area: Math.round(width * height) + }); + } + } + } + + const visibleTinyTextBoxes = Array.from(document.querySelectorAll("button, a, label, h1, h2, h3, p, span, strong, td, th")) + .filter((element) => { + const text = (element.textContent || "").trim(); + if (!text) { + return false; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none" && rect.width < 4; + }).length; + + return { + innerWidth: viewportWidth, + innerHeight: viewportHeight, + scrollWidth, + horizontalOverflow: Math.max(0, scrollWidth - viewportWidth), + controlCount: controls.length, + overlappingControls: overlappingControls.slice(0, 10), + visibleTinyTextBoxes + }; + }); + }, + async url() { + return this.evaluate(() => window.location.href); + }, + async waitForText(markers, label) { + const required = Array.isArray(markers) ? markers : [markers]; + for (let attempt = 0; attempt < 80; attempt += 1) { + const text = await this.visibleText(); + if (required.every((marker) => text.includes(marker))) { + return; + } + await delay(250); + } + const text = await this.visibleText(); + const missing = required.filter((marker) => !text.includes(marker)); + throw new Error(`${label} missing browser markers: ${missing.join(", ")}. Visible sample: ${text.slice(0, 500)}`); + }, + async close() { + await client.close(); + await stopChrome(chrome); + await rm(userDataDir, { recursive: true, force: true }); + } + }; +} + +async function stopChrome(chrome) { + if (chrome.exitCode !== null || chrome.signalCode !== null) { + return; + } + + const exited = new Promise((resolve) => { + chrome.once("exit", resolve); + }); + chrome.kill("SIGTERM"); + const stopped = await Promise.race([ + exited.then(() => true), + delay(3000).then(() => false) + ]); + if (!stopped && chrome.exitCode === null && chrome.signalCode === null) { + chrome.kill("SIGKILL"); + await Promise.race([ + exited, + delay(3000) + ]); + } +} + +class CdpClient { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + this.waiters = new Map(); + socket.addEventListener("message", (event) => this.handleMessage(event)); + } + + static async connect(url) { + const socket = new WebSocket(url); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + return new CdpClient(socket); + } + + send(method, params = {}) { + const id = this.nextId; + this.nextId += 1; + const promise = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + this.socket.send(JSON.stringify({ id, method, params })); + return promise; + } + + waitFor(method, timeoutMs) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const waiters = this.waiters.get(method) || []; + this.waiters.set(method, waiters.filter((item) => item.resolve !== resolve)); + reject(new Error(`timed out waiting for ${method}`)); + }, timeoutMs); + const waiters = this.waiters.get(method) || []; + waiters.push({ + resolve: (params) => { + clearTimeout(timeout); + resolve(params); + } + }); + this.waiters.set(method, waiters); + }); + } + + handleMessage(event) { + const message = JSON.parse(event.data); + if (message.id) { + const pending = this.pending.get(message.id); + if (pending) { + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message)); + } else { + pending.resolve(message.result); + } + } + return; + } + if (message.method && this.waiters.has(message.method)) { + const waiters = this.waiters.get(message.method); + const waiter = waiters.shift(); + if (waiters.length === 0) { + this.waiters.delete(message.method); + } + waiter?.resolve(message.params); + } + } + + async close() { + this.socket.close(); + } +} + +async function getJson(pathname, headers = {}) { + return requestJson("GET", pathname, undefined, headers); +} + +async function postJson(pathname, body, headers = {}) { + return requestJson("POST", pathname, body, headers); +} + +async function requestJson(method, pathname, body, headers = {}) { + const response = await fetch(`${apiUrl}${pathname}`, { + method, + headers: { + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + ...headers + }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`${method} ${pathname} failed with HTTP ${response.status}: ${text.slice(0, 300)}`); + } + return response.json(); +} + +async function fetchText(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`GET ${url} failed with HTTP ${response.status}`); + } + return response.text(); +} + +function findChromePath() { + const candidates = [ + process.env.CHROME_BIN, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "google-chrome", + "chromium", + "chromium-browser" + ].filter(Boolean); + return candidates.find((candidate) => { + if (candidate.startsWith("/")) { + return true; + } + return false; + }) || candidates[0]; +} + +function findRequired(items, predicate, label) { + const item = items.find(predicate); + if (!item) { + throw new Error(`missing required ${label}`); + } + return item; +} + +function assertMarkers(text, markers, routeName) { + const missing = markers.filter((marker) => !text.includes(marker)); + if (missing.length > 0) { + throw new Error(`${routeName} missing required markers: ${missing.join(", ")}`); + } +} + +function scanText(text, routeName) { + const fallback = fallbackFragments.find((item) => item.pattern.test(text)); + if (fallback) { + throw new Error(`${routeName} contains fallback/demo marker: ${fallback.name}`); + } + const forbidden = forbiddenFragments.find((item) => item.pattern.test(text)); + if (forbidden) { + throw new Error(`${routeName} contains forbidden visible fragment: ${forbidden.name}`); + } +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`); + } +} + +function assertIncludes(values, expected, label) { + if (!Array.isArray(values) || !values.includes(expected)) { + throw new Error(`${label}: missing ${expected}`); + } +} + +function assertSafeRedactedRef(value, label) { + if (typeof value !== "string" || (!value.startsWith("secret://") && !value.startsWith("env://"))) { + throw new Error(`${label} must be a safe secret/env reference`); + } +} + +function pick(value, keys) { + return Object.fromEntries(keys.map((key) => [key, value?.[key]])); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/platform_web/api/client.env.test.ts b/platform_web/api/client.env.test.ts new file mode 100644 index 0000000..f3a9023 --- /dev/null +++ b/platform_web/api/client.env.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("platformApiClient runtime environment", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("uses VITE_PLATFORM_API_BASE_URL for the shared client", async () => { + vi.stubEnv("VITE_PLATFORM_API_BASE_URL", "http://127.0.0.1:18080/api/v1"); + vi.resetModules(); + + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ items: [], count: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const { platformApiClient } = await import("./client"); + await expect(platformApiClient.listUsers()).resolves.toMatchObject({ count: 0 }); + + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:18080/api/v1/users", expect.any(Object)); + }); +}); diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts new file mode 100644 index 0000000..179d887 --- /dev/null +++ b/platform_web/api/client.test.ts @@ -0,0 +1,506 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PlatformApiClient, setPlatformApiSessionToken } from "./client"; +import type { AiProviderResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types"; + +const provider: AiProviderResponse = { + id: "ai.openai", + name: "OpenAI", + kind: "openai", + baseUrl: "https://api.openai.com/v1", + apiKeyRef: "secret://providers/openai", + models: ["gpt-4.1"], + defaultModel: "gpt-4.1", + relayMode: "direct", + timeoutMs: 30000, + status: "active", + redactionPolicy: "default" +}; + +const plugin: GamePluginResponse = { + id: "game.example", + name: "Example Server", + version: "0.1.0", + serverType: "example", + serverDisplayName: "Example Server", + manifestRef: "artifact://manifests/game.example/0.1.0", + createFormSchemaRef: "schemas/create-form.schema.json", + requiredRunCapabilities: ["process.start", "logs.read"], + declaredPermissions: ["server.read", "server.logs.read", "ai.invoke"], + permissions: { ai: true, logs: true, files: false, jobs: false, artifacts: false }, + lifecycleActions: { start: "actions/start.json" }, + bridgeActions: ["server.instances.read", "logs.query", "ai.invoke"], + pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }], + tags: ["example"], + aiPurposes: ["logs.diagnose"], + status: "installed" +}; + +const marketplacePlugin: MarketplacePluginResponse = { + id: "game.example", + name: "Example Server", + description: "Development plugin", + version: "0.1.0", + serverType: "example", + serverDisplayName: "Example Server", + supportedOs: ["linux", "darwin"], + manifestRef: "artifact://manifests/game.example/0.1.0", + createFormSchemaRef: "schemas/create-form.schema.json", + capabilities: ["process.install", "process.start", "logs.read"], + declaredPermissions: ["server.read", "server.logs.read", "ai.invoke"], + permissions: { ai: true, logs: true, files: false, jobs: false, artifacts: false }, + lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" }, + bridgeActions: ["server.instances.read", "logs.query", "ai.invoke"], + pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }], + tags: ["example"], + aiPurposes: ["logs.diagnose"], + status: "installed", + source: "platform-registry" +}; + +const server: ServerInstanceResponse = { + id: "server-1", + pluginId: "game.example", + pluginVersion: "0.1.0", + runEndpointId: "run-local", + name: "Example Survival #1", + ownerUserId: "user-owner", + adminUserIds: ["user-admin-1"], + state: "running", + configVersion: 1, + createdAt: "2026-07-03T00:00:00Z", + updatedAt: "2026-07-03T00:00:00Z" +}; + +const endpoint: RunEndpointResponse = { + id: "run-local", + displayName: "Local Run", + version: "0.1.0", + status: "online", + capabilities: ["process.install", "process.start", "process.stop"], + capacity: { maxJobs: 4, runningJobs: 0, queuedJobs: 1 }, + lastHeartbeatAt: "2026-07-03T00:00:00Z" +}; + +const job: JobResponse = { + id: "job-1", + serverInstanceId: server.id, + runEndpointId: endpoint.id, + capability: "process.start", + idempotencyKey: "idem-start", + state: "queued", + progress: { percent: 0, message: "queued" }, + createdAt: "2026-07-03T00:00:00Z", + updatedAt: "2026-07-03T00:00:00Z" +}; + +const artifact = { + id: "artifact-1", + ownerKind: "job", + ownerId: job.id, + sizeBytes: 18, + checksum: "sha256:artifactchecksum", + state: "available", + createdAt: "2026-07-03T00:00:00Z", + updatedAt: "2026-07-03T00:00:00Z" +}; + +describe("PlatformApiClient AI providers", () => { + afterEach(() => { + setPlatformApiSessionToken(null); + vi.restoreAllMocks(); + }); + + it("calls AI provider management endpoints with named contracts", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/v1/ai-providers") && (!init?.method || init.method === "GET")) { + return jsonResponse({ items: [provider], count: 1 }); + } + if (url.endsWith("/api/v1/ai-providers") && init?.method === "POST") { + return jsonResponse(provider); + } + if (url.endsWith("/api/v1/ai-providers/ai.openai") && init?.method === "PUT") { + return jsonResponse({ ...provider, name: "OpenAI Relay" }); + } + if (url.endsWith("/api/v1/ai-providers/ai.openai/status") && init?.method === "POST") { + return jsonResponse({ ...provider, status: "disabled" }); + } + if (url.endsWith("/api/v1/ai-providers/ai.openai/test") && init?.method === "POST") { + return jsonResponse({ providerId: provider.id, mode: "metadata", success: true, message: "metadata validation passed" }); + } + if (url.endsWith("/api/v1/ai-providers/ai.openai/models")) { + return jsonResponse({ providerId: provider.id, defaultModel: provider.defaultModel, models: provider.models }); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new PlatformApiClient(); + + await expect(client.listAiProviders()).resolves.toMatchObject({ count: 1 }); + await expect(client.createAiProvider(provider)).resolves.toMatchObject({ id: provider.id }); + await expect(client.updateAiProvider(provider.id, { ...provider, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" }); + await expect(client.setAiProviderStatus(provider.id, { status: "disabled" })).resolves.toMatchObject({ status: "disabled" }); + await expect(client.testAiProvider(provider.id)).resolves.toMatchObject({ success: true, mode: "metadata" }); + await expect(client.listAiProviderModels(provider.id)).resolves.toMatchObject({ models: ["gpt-4.1"] }); + + expect(fetchMock).toHaveBeenCalledTimes(6); + }); + + it("calls console shell resource endpoints with named contracts", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/healthz")) { + return jsonResponse({ service: "platform", status: "ok", version: "0.1.0", time: "2026-07-03T00:00:00Z" }); + } + if (url.endsWith("/api/v1/game-plugins")) { + return jsonResponse({ items: [plugin], count: 1 }); + } + if (url.endsWith("/api/v1/server-instances") && (!init?.method || init.method === "GET")) { + return jsonResponse({ items: [server], count: 1 }); + } + if (url.endsWith("/api/v1/metrics/platform")) { + return jsonResponse({ cpuPercent: 28, memoryPercent: 42, diskPercent: 19, source: "platform-derived", collectedAt: "2026-07-03T00:00:00Z" }); + } + if (url.endsWith("/api/v1/metrics/server-instances")) { + return jsonResponse({ + items: [ + { + serverInstanceId: server.id, + online: true, + playerCount: 5, + maxPlayers: 20, + tps: 19.8, + latencyMs: 42, + cpuPercent: 31, + memoryPercent: 44, + diskPercent: 22, + source: "platform-derived", + collectedAt: "2026-07-03T00:00:00Z" + } + ], + count: 1 + }); + } + if (url.endsWith("/api/v1/server-instances/server-1/config")) { + return jsonResponse({ + serverInstanceId: server.id, + configVersion: 1, + format: "properties", + key: "server.properties", + content: "server.name=Example Survival #1\n", + source: "platform-derived", + updatedAt: "2026-07-03T00:00:00Z" + }); + } + if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ + expectedConfigVersion: 1, + key: "server.properties", + proposedContent: "server.name=Example Survival #2\n" + }); + return jsonResponse({ + serverInstanceId: server.id, + configVersion: 1, + key: "server.properties", + currentContent: "server.name=Example Survival #1\n", + proposedContent: "server.name=Example Survival #2\n", + diff: [ + { kind: "removed", oldNumber: 1, content: "server.name=Example Survival #1" }, + { kind: "added", newNumber: 1, content: "server.name=Example Survival #2" } + ], + hasChanges: true, + source: "platform-review", + reviewedAt: "2026-07-03T00:00:00Z" + }); + } + if (url.endsWith("/api/v1/server-instances/server-1/config/approve") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ + expectedConfigVersion: 1, + key: "server.properties", + proposedContent: "server.name=Example Survival #2\n", + idempotencyKey: "idem-config" + }); + return jsonResponse({ + status: "queued", + preview: { + serverInstanceId: server.id, + configVersion: 1, + key: "server.properties", + currentContent: "server.name=Example Survival #1\n", + proposedContent: "server.name=Example Survival #2\n", + diff: [{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }], + hasChanges: true, + source: "platform-review", + reviewedAt: "2026-07-03T00:00:00Z" + }, + job: { ...job, id: "job-config-write", capability: "config.write", targetKey: "server.properties", inputRef: "input://server-config/server-1/server.properties/v1" } + }); + } + if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ + serverInstanceId: server.id, + operation: "read", + key: "logs/latest.log", + idempotencyKey: "idem-file" + }); + return jsonResponse({ + status: "queued", + serverInstanceId: server.id, + operation: "read", + key: "logs/latest.log", + job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" } + }); + } + if (url.endsWith("/api/v1/run/endpoints")) { + return jsonResponse({ items: [endpoint], count: 1 }); + } + if (url.endsWith("/api/v1/jobs")) { + return jsonResponse({ items: [job], count: 1 }); + } + if (url.endsWith("/api/v1/jobs?serverInstanceId=server-1")) { + return jsonResponse({ items: [job], count: 1 }); + } + if (url.endsWith("/api/v1/artifacts?ownerKind=job&ownerId=job-1&state=available")) { + return jsonResponse({ items: [artifact], count: 1 }); + } + if (url.endsWith("/api/v1/artifacts/artifact-1/download") && init?.method === "POST") { + return jsonResponse({ + artifactId: artifact.id, + ownerKind: artifact.ownerKind, + ownerId: artifact.ownerId, + filename: "artifact-1.bin", + contentType: "application/octet-stream", + sizeBytes: artifact.sizeBytes, + checksum: artifact.checksum, + state: artifact.state, + downloadUrl: "/api/v1/artifacts/artifact-1/content", + expiresAt: "2026-07-03T00:15:00Z", + rangeSupported: true, + chunkSizeBytes: 1048576, + storageBehavior: "platform-memory-transfer-session" + }); + } + if (url.endsWith("/api/v1/artifacts/artifact-1/content?offset=0&limit=8")) { + return new Response(new TextEncoder().encode("artifact").buffer, { + status: 206, + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": "8", + "Content-Range": "bytes 0-7/18", + "X-Artifact-Id": artifact.id, + "X-Artifact-Checksum": artifact.checksum, + "X-Artifact-Content-Checksum": "sha256:chunkchecksum", + "X-Artifact-Storage": "platform-memory-transfer-session" + } + }); + } + if (url.endsWith("/api/v1/server-instances/workflows/create") && init?.method === "POST") { + return jsonResponse({ accepted: true, action: "create", instance: { ...server, state: "installing" }, job: { ...job, capability: "process.install" } }); + } + if (url.endsWith("/api/v1/server-instances/server-1/start") && init?.method === "POST") { + return jsonResponse({ accepted: true, action: "start", instance: server, job }); + } + if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") { + return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } }); + } + if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) { + return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 }); + } + if (url.endsWith("/api/v1/server-instances/server-1/administrators") && init?.method === "POST") { + return jsonResponse({ ...server, adminUserIds: [...server.adminUserIds, "user-2"] }); + } + if (url.endsWith("/api/v1/server-instances/server-1/administrators/user-2") && init?.method === "DELETE") { + return jsonResponse({ ...server, adminUserIds: [] }); + } + if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") { + return jsonResponse({ + pluginId: plugin.id, + routeKey: "logs", + action: "logs.query", + allowed: true, + requiredPermissions: ["server.logs.read"], + effectivePermissions: ["server.logs.read"] + }); + } + if (url.endsWith("/api/v1/plugin-bridge/execute") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ + requestId: "req-bridge", + pluginId: plugin.id, + routeKey: "logs", + serverInstanceId: server.id, + action: "logs.query", + payload: { logStreamId: "log-1" } + }); + return jsonResponse({ + requestId: "req-bridge", + pluginId: plugin.id, + routeKey: "logs", + serverInstanceId: server.id, + action: "logs.query", + status: "ok", + result: { entryCount: "0" } + }); + } + if (url.endsWith("/api/v1/ai/invocations") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ + requestId: "ai-1", + serverInstanceId: server.id, + purpose: "config.suggest", + prompt: "Tune PVP safely", + currentConfig: "server.name=Example Survival #1\n" + }); + return jsonResponse({ + requestId: "ai-1", + purpose: "config.suggest", + providerId: "ai.openai", + model: "gpt-4.1", + status: "ok", + recommendation: "Review before applying.", + configRecommendation: { key: "server.properties", suggestedConfig: "server.name=Example Survival #1\npvp=false\n", diffSummary: "review required" }, + usage: { providerId: "ai.openai", model: "gpt-4.1", inputTokens: 20, outputTokens: 12, mocked: true } + }); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new PlatformApiClient(); + + await expect(client.health()).resolves.toMatchObject({ status: "ok" }); + await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 }); + await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 }); + await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 }); + await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] }); + await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" }); + await expect( + client.previewServerConfigDiff(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n" }) + ).resolves.toMatchObject({ hasChanges: true, source: "platform-review" }); + await expect( + client.approveServerConfigWrite(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n", idempotencyKey: "idem-config" }) + ).resolves.toMatchObject({ status: "queued", job: { capability: "config.write", targetKey: "server.properties" } }); + await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({ + status: "queued", + job: { capability: "files.read", targetKey: "logs/latest.log" } + }); + await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 }); + await expect(client.listJobs()).resolves.toMatchObject({ count: 1 }); + await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 }); + await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] }); + await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true }); + await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum }); + await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({ + action: "create" + }); + await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" }); + await expect(client.stopServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-stop" })).resolves.toMatchObject({ action: "stop" }); + await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 }); + await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] }); + await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] }); + await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({ + allowed: true + }); + await expect( + client.executePluginBridge({ requestId: "req-bridge", pluginId: plugin.id, routeKey: "logs", serverInstanceId: server.id, action: "logs.query", payload: { logStreamId: "log-1" } }) + ).resolves.toMatchObject({ status: "ok", result: { entryCount: "0" } }); + await expect( + client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) + ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } }); + + expect(fetchMock).toHaveBeenCalledTimes(24); + }); + + it("calls plugin marketplace endpoints with filter and state contracts", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/v1/plugin-marketplace/plugins?status=installed&serverType=example&capability=logs.read&keyword=example")) { + return jsonResponse({ items: [marketplacePlugin], count: 1 }); + } + if (url.endsWith("/api/v1/plugin-marketplace/plugins/game.example") && (!init?.method || init.method === "GET")) { + return jsonResponse(marketplacePlugin); + } + if (url.endsWith("/api/v1/plugin-marketplace/plugins/game.example/state") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ action: "disable" }); + return jsonResponse({ ...marketplacePlugin, status: "disabled" }); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new PlatformApiClient(); + + await expect(client.listMarketplacePlugins({ status: "installed", serverType: "example", capability: "logs.read", keyword: "example" })).resolves.toMatchObject({ count: 1 }); + await expect(client.getMarketplacePlugin(marketplacePlugin.id)).resolves.toMatchObject({ source: "platform-registry" }); + await expect(client.setMarketplacePluginState(marketplacePlugin.id, { action: "disable" })).resolves.toMatchObject({ status: "disabled" }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("surfaces config diff preview failures from the platform", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") { + return new Response(JSON.stringify({ code: "validation", message: "expectedConfigVersion must match server instance" }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new PlatformApiClient(); + + await expect(client.previewServerConfigDiff(server.id, { expectedConfigVersion: 0, key: "server.properties", proposedContent: "changed=true\n" })).rejects.toThrow( + "expectedConfigVersion must match server instance" + ); + }); + + it("keeps raw key fields out of provider responses", () => { + expect("apiKey" in provider).toBe(false); + expect("rawApiKey" in provider).toBe(false); + expect(provider.apiKeyRef).toBe("secret://providers/openai"); + }); + + it("calls auth endpoints and attaches bearer sessions", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/v1/auth/login") && init?.method === "POST") { + return jsonResponse({ + user: { id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] }, + sessionId: "session-token", + status: "authenticated", + message: "登录成功" + }); + } + if (url.endsWith("/api/v1/users/current")) { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token"); + return jsonResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] }); + } + if (url.endsWith("/api/v1/auth/logout") && init?.method === "POST") { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token"); + return new Response(null, { status: 204 }); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new PlatformApiClient(); + const login = await client.login({ account: "operator.local@example.test", password: "operator-local" }); + expect(login.sessionId).toBe("session-token"); + + setPlatformApiSessionToken(login.sessionId ?? null); + await expect(client.getCurrentUser()).resolves.toMatchObject({ id: "user-admin" }); + await expect(client.logout()).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" } + }); +} diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts new file mode 100644 index 0000000..44670a8 --- /dev/null +++ b/platform_web/api/client.ts @@ -0,0 +1,414 @@ +import type { + AiProviderListResponse, + AiProviderModelsResponse, + AiProviderRequest, + AiProviderResponse, + AiProviderStatusRequest, + AiProviderTestResponse, + AiProviderUpdateRequest, + AIInvocationRequest, + AIInvocationResponse, + ApiErrorResponse, + ArtifactContentChunk, + ArtifactDownloadReferenceResponse, + ArtifactFilterRequest, + ArtifactListResponse, + AuthSessionResponse, + AuditEventListResponse, + CurrentUserResponse, + FileOperationDispatchRequest, + FileOperationDispatchResponse, + GamePluginListResponse, + HealthResponse, + JobCreateRequest, + JobListResponse, + JobResponse, + LlmConfigSuggestionRequest, + LlmConfigSuggestionResponse, + LogStreamCursorRequest, + LogStreamCursorResponse, + LogStreamListResponse, + LoginRequest, + MarketplacePluginFilterRequest, + MarketplacePluginListResponse, + MarketplacePluginResponse, + MarketplacePluginStateRequest, + PlatformResourceUsageResponse, + PluginBridgeAuthorizeRequest, + PluginBridgeAuthorizeResponse, + PluginBridgeExecuteRequest, + PluginBridgeExecuteResponse, + RegisterRequest, + RunEndpointListResponse, + ServerConfigResponse, + ServerConfigDiffPreviewRequest, + ServerConfigDiffPreviewResponse, + ServerLifecycleCommandRequest, + ServerLifecycleCreateRequest, + ServerLifecycleResponse, + ServerConfigWriteApprovalRequest, + ServerConfigWriteDispatchResponse, + ServerInstanceListResponse, + ServerInstanceResponse, + ServerMemberListResponse, + ServerMemberRequest, + ServerMetricsListResponse, + UserCreateRequest, + UserListResponse, + UserProfileUpdateRequest, + UserResponse, + UserThemePreferenceRequest, + UserThemePreferenceResponse, + UserUpdateRequest +} from "./types"; +import { readWebRuntimeEnv } from "../schemas/env"; + +let platformApiSessionToken: string | null = null; + +export function setPlatformApiSessionToken(token: string | null) { + platformApiSessionToken = token; +} + +export class PlatformApiClient { + constructor(private readonly baseUrl = "/api/v1", private readonly sessionTokenProvider: () => string | null = () => platformApiSessionToken) {} + + async health(): Promise { + return this.request("/healthz", { absolute: true }); + } + + async listGamePlugins(): Promise { + return this.request("/game-plugins"); + } + + async listMarketplacePlugins(filter: MarketplacePluginFilterRequest = {}): Promise { + return this.request(`/plugin-marketplace/plugins${marketplaceQuery(filter)}`); + } + + async getMarketplacePlugin(id: string): Promise { + return this.request(`/plugin-marketplace/plugins/${encodeURIComponent(id)}`); + } + + async setMarketplacePluginState(id: string, request: MarketplacePluginStateRequest): Promise { + return this.request(`/plugin-marketplace/plugins/${encodeURIComponent(id)}/state`, { + method: "POST", + body: request + }); + } + + async listServerInstances(): Promise { + return this.request("/server-instances"); + } + + async createServerWorkflow(request: ServerLifecycleCreateRequest): Promise { + return this.request("/server-instances/workflows/create", { + method: "POST", + body: request + }); + } + + async startServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/start`, { + method: "POST", + body: request + }); + } + + async stopServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/stop`, { + method: "POST", + body: request + }); + } + + async listServerAdministratorCandidates(id: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/administrators/candidates`); + } + + async addServerAdministrator(id: string, request: ServerMemberRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/administrators`, { + method: "POST", + body: request + }); + } + + async removeServerAdministrator(id: string, userId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/administrators/${encodeURIComponent(userId)}`, { + method: "DELETE" + }); + } + + async listRunEndpoints(): Promise { + return this.request("/run/endpoints"); + } + + async listJobs(serverInstanceId?: string): Promise { + const params = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : ""; + return this.request(`/jobs${params}`); + } + + async listArtifacts(filter: ArtifactFilterRequest = {}): Promise { + return this.request(`/artifacts${artifactQuery(filter)}`); + } + + async openArtifactDownload(id: string): Promise { + return this.request(`/artifacts/${encodeURIComponent(id)}/download`, { method: "POST", body: {} }); + } + + async readArtifactContent(id: string, offset = 0, limit?: number): Promise { + const params = new URLSearchParams({ offset: String(offset) }); + if (limit !== undefined) { + params.set("limit", String(limit)); + } + const headers = new Headers(); + const sessionToken = this.sessionTokenProvider(); + if (sessionToken) { + headers.set("Authorization", `Bearer ${sessionToken}`); + } + const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content?${params.toString()}`, { headers }); + if (!response.ok) { + const apiError = await safeReadError(response); + throw new Error(apiError?.message ?? `request failed: ${response.status}`); + } + const payload = await response.arrayBuffer(); + return { + artifactId: response.headers.get("X-Artifact-Id") ?? undefined, + payload, + contentType: response.headers.get("Content-Type") ?? "application/octet-stream", + contentLength: Number(response.headers.get("Content-Length") ?? payload.byteLength), + contentRange: response.headers.get("Content-Range") ?? undefined, + checksum: response.headers.get("X-Artifact-Checksum") ?? undefined, + contentChecksum: response.headers.get("X-Artifact-Content-Checksum") ?? undefined, + storageBehavior: response.headers.get("X-Artifact-Storage") ?? undefined + }; + } + + async getJob(id: string): Promise { + return this.request(`/jobs/${encodeURIComponent(id)}`); + } + + async createJob(request: JobCreateRequest): Promise { + return this.request("/jobs", { method: "POST", body: request }); + } + + async register(request: RegisterRequest): Promise { + return this.request("/auth/register", { method: "POST", body: request }); + } + + async login(request: LoginRequest): Promise { + return this.request("/auth/login", { method: "POST", body: request }); + } + + async logout(): Promise { + await this.request("/auth/logout", { method: "POST", parseJson: false }); + } + + async getCurrentUser(): Promise { + return this.request("/users/current"); + } + + async listUsers(): Promise { + return this.request("/users"); + } + + async createUser(request: UserCreateRequest): Promise { + return this.request("/users", { method: "POST", body: request }); + } + + async updateUser(id: string, request: UserUpdateRequest): Promise { + return this.request(`/users/${encodeURIComponent(id)}`, { method: "PUT", body: request }); + } + + async updateCurrentUserProfile(request: UserProfileUpdateRequest): Promise { + return this.request("/users/current/profile", { method: "PUT", body: request }); + } + + async updateCurrentUserTheme(request: UserThemePreferenceRequest): Promise { + return this.request("/users/current/theme", { method: "PUT", body: request }); + } + + async getServerInstance(id: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}`); + } + + async getPlatformResourceUsage(): Promise { + return this.request("/metrics/platform"); + } + + async listServerMetrics(): Promise { + return this.request("/metrics/server-instances"); + } + + async getServerConfig(id: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/config`); + } + + async previewServerConfigDiff(id: string, request: ServerConfigDiffPreviewRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/config/diff`, { + method: "POST", + body: request + }); + } + + async approveServerConfigWrite(id: string, request: ServerConfigWriteApprovalRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/config/approve`, { + method: "POST", + body: request + }); + } + + async dispatchFileOperation(request: FileOperationDispatchRequest): Promise { + return this.request("/file-operations/dispatch", { + method: "POST", + body: request + }); + } + + async listLogStreams(): Promise { + return this.request("/log-streams"); + } + + async queryLogStream(request: LogStreamCursorRequest): Promise { + return this.request("/log-streams/query", { method: "POST", body: request }); + } + + async listAuditEvents(): Promise { + return this.request("/audit-events"); + } + + async suggestServerConfig(request: LlmConfigSuggestionRequest): Promise { + return this.request("/ai/config-suggestions", { method: "POST", body: request }); + } + + async invokeAI(request: AIInvocationRequest): Promise { + return this.request("/ai/invocations", { method: "POST", body: request }); + } + + async authorizePluginBridge(request: PluginBridgeAuthorizeRequest): Promise { + return this.request("/plugin-bridge/authorize", { + method: "POST", + body: request + }); + } + + async executePluginBridge(request: PluginBridgeExecuteRequest): Promise { + return this.request("/plugin-bridge/execute", { + method: "POST", + body: request + }); + } + + async listAiProviders(): Promise { + return this.request("/ai-providers"); + } + + async createAiProvider(request: AiProviderRequest): Promise { + return this.request("/ai-providers", { + method: "POST", + body: request + }); + } + + async updateAiProvider(id: string, request: AiProviderUpdateRequest): Promise { + return this.request(`/ai-providers/${encodeURIComponent(id)}`, { + method: "PUT", + body: request + }); + } + + async setAiProviderStatus(id: string, request: AiProviderStatusRequest): Promise { + return this.request(`/ai-providers/${encodeURIComponent(id)}/status`, { + method: "POST", + body: request + }); + } + + async testAiProvider(id: string): Promise { + return this.request(`/ai-providers/${encodeURIComponent(id)}/test`, { + method: "POST" + }); + } + + async listAiProviderModels(id: string): Promise { + return this.request(`/ai-providers/${encodeURIComponent(id)}/models`); + } + + private async request(path: string, options: ApiRequestOptions = {}): Promise { + const headers = new Headers(options.init?.headers); + if (options.body !== undefined) { + headers.set("Content-Type", "application/json"); + } + const sessionToken = this.sessionTokenProvider(); + if (sessionToken && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${sessionToken}`); + } + + const response = await fetch(options.absolute ? path : `${this.baseUrl}${path}`, { + ...options.init, + method: options.method ?? options.init?.method ?? "GET", + headers, + body: options.body === undefined ? options.init?.body : JSON.stringify(options.body) + }); + + if (!response.ok) { + const apiError = await safeReadError(response); + throw new Error(apiError?.message ?? `request failed: ${response.status}`); + } + + if (options.parseJson === false || response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; + } +} + +interface ApiRequestOptions { + absolute?: boolean; + method?: string; + body?: unknown; + init?: RequestInit; + parseJson?: boolean; +} + +async function safeReadError(response: Response): Promise { + try { + return (await response.json()) as ApiErrorResponse; + } catch { + return null; + } +} + +function marketplaceQuery(filter: MarketplacePluginFilterRequest): string { + const params = new URLSearchParams(); + if (filter.status && filter.status !== "all") { + params.set("status", filter.status); + } + if (filter.serverType) { + params.set("serverType", filter.serverType); + } + if (filter.capability) { + params.set("capability", filter.capability); + } + if (filter.keyword) { + params.set("keyword", filter.keyword); + } + const query = params.toString(); + return query ? `?${query}` : ""; +} + +function artifactQuery(filter: ArtifactFilterRequest): string { + const params = new URLSearchParams(); + if (filter.ownerKind) { + params.set("ownerKind", filter.ownerKind); + } + if (filter.ownerId) { + params.set("ownerId", filter.ownerId); + } + if (filter.state) { + params.set("state", filter.state); + } + const query = params.toString(); + return query ? `?${query}` : ""; +} + +export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl); diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md new file mode 100644 index 0000000..73a7162 --- /dev/null +++ b/platform_web/api/contracts.md @@ -0,0 +1,50 @@ +# Frontend API Contracts + +API clients and DTO types live here, not inside page components. + +## Client Groups + +- `users`: user and role APIs. +- `serverPlugins`: plugin marketplace and installed plugin APIs. +- `serverInstances`: create server, lifecycle, config read, config diff/approval, scoped files, logs, and detail APIs. +- `aiProviders`: provider CRUD, test, and model APIs. +- `jobs`: job status and operation APIs. +- `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs. +- `artifacts`: artifact upload/download APIs. +- `logs`: historical query and tail APIs. +- `pluginPageBridge`: safe bridge APIs for hosted plugin page. + +Every API client must use named request and response types. + +## Server Management Workflows + +- `createServerWorkflow` posts `ServerLifecycleCreateRequest` to `/server-instances/workflows/create` and receives the accepted instance plus install job. +- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. +- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. +- `getServerConfig`, `previewServerConfigDiff`, and `approveServerConfigWrite` call platform-mediated config routes. ServerDetailPage must preview the platform diff first, keep the explicit confirmation step, and dispatch writes only through the approval API. +- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths. +- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior. +- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions. +- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential. +- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details. +- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and pending lifecycle status. +- Server management DTOs may include bounded `ownerUserId` and `adminUserIds` metadata, but must not include raw run credentials, host paths, direct socket details, user password hashes, or AI provider keys. + +## Redesign Contract Gaps (redesign-platform-web-interactions) + +Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, audit events, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently: + +- `POST /api/v1/auth/register` (`RegisterRequest`/`AuthSessionResponse`): visitor registration. Implemented: the first registered user becomes an active platform administrator; later self-registered users become pending server-scoped users and do not receive platform administrator privileges. +- `POST /api/v1/auth/login` (`LoginRequest`/`AuthSessionResponse`) and `POST /api/v1/auth/logout`: implemented bearer session lifecycle for authenticated workspace entry. +- `GET /api/v1/users/current` (`CurrentUserResponse`): implemented current session identity, roles, profile summary, and theme preference reference for role-aware navigation and default landing. +- `PUT /api/v1/users/current/profile` (`UserProfileUpdateRequest`/`CurrentUserResponse`): implemented current-user profile updates such as display name, avatar reference, phone, QQ, and bounded contact fields. +- `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference. +- `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen. +- `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards and the server detail header. +- `GET /api/v1/server-instances/{id}/config` (`ServerConfigResponse`): implemented readable configuration content for diff-based editing. +- `POST /api/v1/server-instances/{id}/config/diff` (`ServerConfigDiffPreviewRequest`/`ServerConfigDiffPreviewResponse`) and `POST /api/v1/server-instances/{id}/config/approve` (`ServerConfigWriteApprovalRequest`/`ServerConfigWriteDispatchResponse`): implemented platform-mediated config write review and approval. Manual config edits and AI suggestion applies must not create generic `config.write` jobs through `POST /api/v1/jobs`. +- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only. +- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets. +- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals. +- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent. +- Log filtering by level/keyword/time/source is applied client-side over `POST /api/v1/log-streams/query` (`LogStreamCursorRequest`) results until the platform exposes server-side filters. diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts new file mode 100644 index 0000000..dd19db3 --- /dev/null +++ b/platform_web/api/types.ts @@ -0,0 +1,679 @@ +export interface HealthResponse { + service: string; + status: "ok" | "degraded"; + version: string; + time: string; +} + +export type GamePluginStatus = "installed" | "disabled" | "invalid" | "updating"; +export type ServerInstanceState = "draft" | "installing" | "ready" | "running" | "stopped" | "failed" | "deleted"; +export type RunEndpointStatus = "online" | "offline" | "degraded" | "disabled"; +export type JobState = "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled"; +export type ServerLifecycleAction = "create" | "start" | "stop"; + +export interface PluginPermissionsResponse { + ai: boolean; + logs: boolean; + files: boolean; + jobs: boolean; + artifacts: boolean; +} + +export interface GamePluginPageResponse { + key: string; + title: string; + path: string; + permissions: string[]; + bridgeActions?: string[]; +} + +export interface GamePluginResponse { + id: string; + name: string; + description?: string; + version: string; + serverType: string; + serverDisplayName?: string; + supportedOs?: string[]; + manifestRef: string; + createFormSchemaRef: string; + requiredRunCapabilities: string[]; + declaredPermissions: string[]; + permissions: PluginPermissionsResponse; + lifecycleActions: Record; + bridgeActions: string[]; + pages: GamePluginPageResponse[]; + tags: string[]; + aiPurposes: string[]; + validationViolations?: string[]; + status: GamePluginStatus; +} + +export interface GamePluginListResponse { + items: GamePluginResponse[]; + count: number; +} + +export type MarketplacePluginStateAction = "install" | "enable" | "disable"; + +export interface MarketplacePluginResponse { + id: string; + name: string; + description?: string; + version: string; + serverType: string; + serverDisplayName?: string; + supportedOs?: string[]; + manifestRef: string; + createFormSchemaRef: string; + capabilities: string[]; + declaredPermissions: string[]; + permissions: PluginPermissionsResponse; + lifecycleActions: Record; + bridgeActions: string[]; + pages: GamePluginPageResponse[]; + tags: string[]; + aiPurposes: string[]; + validationViolations?: string[]; + status: GamePluginStatus; + source: string; +} + +export interface MarketplacePluginListResponse { + items: MarketplacePluginResponse[]; + count: number; +} + +export interface MarketplacePluginFilterRequest { + status?: GamePluginStatus | "all"; + serverType?: string; + capability?: string; + keyword?: string; +} + +export interface MarketplacePluginStateRequest { + action: MarketplacePluginStateAction; +} + +export interface ServerInstanceResponse { + id: string; + pluginId: string; + pluginVersion: string; + runEndpointId: string; + name: string; + ownerUserId?: string; + adminUserIds: string[]; + state: ServerInstanceState; + configVersion: number; + createdAt: string; + updatedAt: string; +} + +export interface ServerInstanceListResponse { + items: ServerInstanceResponse[]; + count: number; +} + +export interface ServerLifecycleCreateRequest { + id: string; + pluginId: string; + runEndpointId: string; + name: string; + idempotencyKey: string; +} + +export interface ServerLifecycleCommandRequest { + expectedConfigVersion: number; + idempotencyKey: string; +} + +export interface ServerLifecycleResponse { + accepted: boolean; + action: ServerLifecycleAction; + instance: ServerInstanceResponse; + job: JobResponse; +} + +export interface RunCapacityResponse { + maxJobs: number; + runningJobs: number; + queuedJobs: number; + summary?: string; +} + +export interface RunEndpointResponse { + id: string; + displayName: string; + version: string; + status: RunEndpointStatus; + capabilities: string[]; + capacity: RunCapacityResponse; + lastHeartbeatAt: string; +} + +export interface RunEndpointListResponse { + items: RunEndpointResponse[]; + count: number; +} + +export interface JobProgressBody { + percent: number; + message?: string; +} + +export interface JobResponse { + id: string; + serverInstanceId?: string; + runEndpointId: string; + capability: string; + targetKey?: string; + inputRef?: string; + idempotencyKey: string; + state: JobState; + progress: JobProgressBody; + resultRef?: string; + createdAt: string; + updatedAt: string; +} + +export interface JobListResponse { + items: JobResponse[]; + count: number; +} + +export type ArtifactOwnerKind = "platform" | "plugin" | "server-instance" | "job"; +export type ArtifactState = "uploading" | "available" | "expired" | "failed"; + +export interface ArtifactResponse { + id: string; + ownerKind: ArtifactOwnerKind; + ownerId: string; + sizeBytes: number; + checksum: string; + state: ArtifactState; + createdAt: string; + updatedAt: string; +} + +export interface ArtifactListResponse { + items: ArtifactResponse[]; + count: number; +} + +export interface ArtifactFilterRequest { + ownerKind?: ArtifactOwnerKind; + ownerId?: string; + state?: ArtifactState; +} + +export interface ArtifactDownloadReferenceResponse { + artifactId: string; + ownerKind: ArtifactOwnerKind; + ownerId: string; + filename: string; + contentType: string; + sizeBytes: number; + checksum: string; + state: ArtifactState; + downloadUrl: string; + expiresAt: string; + rangeSupported: boolean; + chunkSizeBytes: number; + storageBehavior: string; +} + +export interface ArtifactContentChunk { + artifactId?: string; + payload: ArrayBuffer; + contentType: string; + contentLength: number; + contentRange?: string; + checksum?: string; + contentChecksum?: string; + storageBehavior?: string; +} + +export interface PluginBridgeAuthorizeRequest { + pluginId: string; + routeKey: string; + serverInstanceId?: string; + action: string; + aiPurpose?: string; +} + +export interface PluginBridgeAuthorizeResponse { + pluginId: string; + routeKey: string; + serverInstanceId?: string; + action: string; + allowed: boolean; + requiredPermissions: string[]; + effectivePermissions: string[]; + reason?: string; +} + +export interface PluginBridgeExecuteRequest { + requestId: string; + pluginId: string; + routeKey: string; + serverInstanceId?: string; + action: string; + aiPurpose?: string; + payload?: Record; +} + +export interface PluginBridgeSafeErrorResponse { + code: string; + message: string; + details?: string[]; +} + +export interface PluginBridgeExecuteResponse { + requestId: string; + pluginId: string; + routeKey: string; + serverInstanceId?: string; + action: string; + status: "ok" | "queued" | "denied" | "unsupported" | "cancelled" | "error" | string; + result?: Record; + error?: PluginBridgeSafeErrorResponse; +} + +export type AiProviderKind = "openai-compatible" | "openai" | "claude" | "gemini" | "ollama" | "custom"; +export type AiRelayMode = "direct" | "relay" | "local"; +export type AiProviderStatus = "active" | "disabled" | "error"; + +export interface AiProviderRequest { + id: string; + name: string; + kind: AiProviderKind; + baseUrl: string; + apiKeyRef: string; + models: string[]; + defaultModel?: string; + relayMode: AiRelayMode; + timeoutMs: number; + redactionPolicy: string; +} + +export type AiProviderUpdateRequest = Omit; + +export interface AiProviderStatusRequest { + status: Extract; +} + +export interface AiProviderResponse { + id: string; + name: string; + kind: AiProviderKind; + baseUrl: string; + apiKeyRef: string; + models: string[]; + defaultModel?: string; + relayMode: AiRelayMode; + timeoutMs: number; + status: AiProviderStatus; + redactionPolicy: string; +} + +export interface AiProviderListResponse { + items: AiProviderResponse[]; + count: number; +} + +export interface AiProviderTestResponse { + providerId: string; + mode: "metadata"; + success: boolean; + message: string; + violations?: string[]; +} + +export interface AiProviderModelsResponse { + providerId: string; + defaultModel?: string; + models: string[]; +} + +export interface ApiErrorResponse { + code: string; + message: string; + details?: string[]; +} + +export type UserStatus = "active" | "disabled" | "pending"; +export type UserThemePersistence = "api" | "local"; + +export interface UserContactProfile { + avatarUrl?: string; + phone?: string; + qq?: string; + contactNote?: string; +} + +export interface UserThemePreferenceRequest { + paletteId: string; + backgroundPresetId: string; + backgroundImage?: string | null; +} + +export interface UserThemePreferenceResponse extends UserThemePreferenceRequest { + userId: string; + persistence: UserThemePersistence; + updatedAt: string; +} + +export interface UserResponse { + id: string; + displayName: string; + email?: string; + status: UserStatus; + roles: string[]; + profile?: UserContactProfile; + themePreference?: UserThemePreferenceResponse; + createdAt: string; + updatedAt: string; +} + +export interface ServerMemberResponse { + id: string; + displayName: string; + email?: string; + status: UserStatus; + roles: string[]; + profile?: UserContactProfile; +} + +export interface ServerMemberListResponse { + items: ServerMemberResponse[]; + count: number; +} + +export interface ServerMemberRequest { + userId: string; +} + +export interface UserListResponse { + items: UserResponse[]; + count: number; +} + +export interface CurrentUserResponse { + id: string; + displayName: string; + email?: string; + status?: UserStatus; + roles: string[]; + capabilities?: string[]; + profile?: UserContactProfile; + themePreference?: UserThemePreferenceResponse; +} + +export interface LoginRequest { + account: string; + password: string; +} + +export interface RegisterRequest { + displayName: string; + email: string; + password: string; + phone?: string; + qq?: string; +} + +export interface AuthSessionResponse { + user: CurrentUserResponse; + sessionId?: string; + status: "authenticated" | "pending"; + message?: string; +} + +export interface UserProfileUpdateRequest { + displayName: string; + avatarUrl?: string; + phone?: string; + qq?: string; + contactNote?: string; +} + +export interface UserCreateRequest { + displayName: string; + email?: string; + roles: string[]; + status: UserStatus; + profile?: UserContactProfile; +} + +export interface UserUpdateRequest { + displayName?: string; + email?: string; + roles?: string[]; + status?: UserStatus; + profile?: UserContactProfile; +} + +export interface PlatformResourceUsageResponse { + cpuPercent: number; + memoryPercent: number; + diskPercent: number; + source?: string; + collectedAt: string; +} + +export interface ServerMetricsResponse { + serverInstanceId: string; + online: boolean; + playerCount?: number; + maxPlayers?: number; + tps?: number; + latencyMs?: number; + cpuPercent?: number; + memoryPercent?: number; + diskPercent?: number; + source?: string; + collectedAt: string; +} + +export interface ServerMetricsListResponse { + items: ServerMetricsResponse[]; + count: number; +} + +export interface ServerConfigResponse { + serverInstanceId: string; + configVersion: number; + format: string; + key?: string; + content: string; + source?: string; + updatedAt: string; +} + +export type ConfigDiffLineKind = "context" | "added" | "removed"; + +export interface ConfigDiffLineResponse { + kind: ConfigDiffLineKind; + oldNumber?: number; + newNumber?: number; + content: string; +} + +export interface ServerConfigDiffPreviewRequest { + expectedConfigVersion: number; + key: string; + proposedContent?: string; + proposedContentInputRef?: string; +} + +export interface ServerConfigDiffPreviewResponse { + serverInstanceId: string; + configVersion: number; + key: string; + currentContent: string; + proposedContent?: string; + proposedContentInputRef?: string; + diff: ConfigDiffLineResponse[]; + hasChanges: boolean; + source: string; + reviewedAt: string; +} + +export interface ServerConfigWriteApprovalRequest { + expectedConfigVersion: number; + key: string; + proposedContent?: string; + proposedContentInputRef?: string; + idempotencyKey: string; +} + +export interface ServerConfigWriteDispatchResponse { + status: string; + preview: ServerConfigDiffPreviewResponse; + job: JobResponse; +} + +export type FileOperationKind = "read" | "write"; + +export interface FileOperationDispatchRequest { + serverInstanceId: string; + pluginId?: string; + operation: FileOperationKind; + key: string; + inputRef?: string; + expectedConfigVersion?: number; + idempotencyKey: string; +} + +export interface FileOperationDispatchResponse { + status: string; + serverInstanceId: string; + pluginId?: string; + operation: FileOperationKind; + key: string; + inputRef?: string; + job: JobResponse; +} + +export interface LogStreamResponse { + id: string; + serverInstanceId: string; + source: string; + streamKey: string; + latestSeq: number; + storageBackend: string; + retentionPolicy: string; + createdAt: string; + updatedAt: string; +} + +export interface LogStreamListResponse { + items: LogStreamResponse[]; + count: number; +} + +export interface LogEntryBody { + seq: number; + timestamp: string; + level?: string; + line: string; + fields?: Record; + redacted: boolean; +} + +export interface LogStreamCursorRequest { + logStreamId: string; + afterSeq: number; + limit: number; +} + +export interface LogStreamCursorResponse { + logStreamId: string; + entries: LogEntryBody[]; + nextSeq: number; + latestSeq: number; +} + +export interface AuditEventResponse { + id: string; + actorId: string; + action: string; + resourceKind: string; + resourceId: string; + result: string; + summary: string; + createdAt: string; +} + +export interface AuditEventListResponse { + items: AuditEventResponse[]; + count: number; +} + +export interface JobCreateRequest { + id: string; + serverInstanceId?: string; + runEndpointId: string; + capability: string; + targetKey?: string; + inputRef?: string; + idempotencyKey: string; + progress?: JobProgressBody; +} + +export interface LlmConfigSuggestionRequest { + serverInstanceId: string; + prompt: string; + currentConfig: string; +} + +export interface LlmConfigSuggestionResponse { + serverInstanceId: string; + recommendation: string; + suggestedConfig?: string; +} + +export interface AIInvocationRequest { + requestId: string; + pluginId?: string; + routeKey?: string; + serverInstanceId?: string; + purpose: string; + providerId?: string; + model?: string; + prompt: string; + currentConfig?: string; + contextRefs?: Record; +} + +export interface AIInvocationUsageResponse { + providerId: string; + model: string; + inputTokens: number; + outputTokens: number; + mocked: boolean; +} + +export interface AIConfigRecommendationResponse { + key: string; + suggestedConfig?: string; + diffSummary: string; +} + +export interface AIInvocationSafeErrorResponse { + code: string; + message: string; + details?: string[]; +} + +export interface AIInvocationResponse { + requestId: string; + purpose: string; + providerId?: string; + model?: string; + status: "ok" | "denied" | "error" | string; + recommendation?: string; + configRecommendation?: AIConfigRecommendationResponse; + usage: AIInvocationUsageResponse; + error?: AIInvocationSafeErrorResponse; +} diff --git a/platform_web/app/App.test.tsx b/platform_web/app/App.test.tsx new file mode 100644 index 0000000..3a39e86 --- /dev/null +++ b/platform_web/app/App.test.tsx @@ -0,0 +1,14 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { App } from "./App"; + +describe("App", () => { + it("renders a visible authentication loading state before the session resolves", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("正在加载身份状态"); + expect(html).toContain("auth-shell"); + expect(html).not.toContain("blank"); + }); +}); diff --git a/platform_web/app/App.tsx b/platform_web/app/App.tsx new file mode 100644 index 0000000..6268599 --- /dev/null +++ b/platform_web/app/App.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; + +import { AuthView } from "../components/AuthView"; +import { AppShell } from "../components/AppShell"; +import { EmptyState, LoadingState } from "../components/StateViews"; +import type { PageId, PageParams } from "../contracts/page"; +import { pageRegistry } from "../pages/pageRegistry"; +import { canAccessRoute, defaultPageForUser, hashForPage, navigationRoutesForUser } from "../routes/routes"; +import { initialNavigationState, type NavigationState } from "../stores/navigation"; +import { useOperationTracker } from "../stores/operations"; +import { useSession } from "../stores/session"; + +export function App() { + const session = useSession(); + const user = session.user; + const [navigation, setNavigation] = useState(); + const operations = useOperationTracker(); + + useEffect(() => { + if (session.loaded && user) { + const nextNavigation = initialNavigationState(user); + setNavigation(nextNavigation); + if (typeof window !== "undefined") { + const nextHash = hashForPage(nextNavigation.pageId, nextNavigation.params); + if (window.location.hash !== nextHash) { + window.history.replaceState(null, "", nextHash); + } + } + } + }, [session.loaded, user]); + + useEffect(() => { + function syncPageFromHash() { + if (user) { + setNavigation(initialNavigationState(user)); + } + } + + window.addEventListener("hashchange", syncPageFromHash); + window.addEventListener("popstate", syncPageFromHash); + return () => { + window.removeEventListener("hashchange", syncPageFromHash); + window.removeEventListener("popstate", syncPageFromHash); + }; + }, [user]); + + function handleNavigate(pageId: PageId, params: PageParams = {}) { + if (!user) { + return; + } + const target: NavigationState = canAccessRoute(user, pageId) ? { pageId, params } : { pageId: defaultPageForUser(user), params: {} }; + setNavigation(target); + if (typeof window !== "undefined") { + window.history.replaceState(null, "", hashForPage(target.pageId, target.params)); + } + } + + if (!session.loaded) { + return ( +
+ +
+ ); + } + + if (!user || !navigation) { + return ; + } + + const navRoutes = navigationRoutesForUser(user); + const ActivePage = pageRegistry[navigation.pageId]; + const allowed = canAccessRoute(user, navigation.pageId); + + return ( + + {allowed ? ( + + ) : ( + handleNavigate(defaultPageForUser(user))} + /> + )} + + ); +} diff --git a/platform_web/app/main.tsx b/platform_web/app/main.tsx new file mode 100644 index 0000000..8ed0972 --- /dev/null +++ b/platform_web/app/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; +import "../theme/base.css"; + +createRoot(document.getElementById("root") as HTMLElement).render( + + + +); diff --git a/platform_web/components/AppShell.tsx b/platform_web/components/AppShell.tsx new file mode 100644 index 0000000..982faf4 --- /dev/null +++ b/platform_web/components/AppShell.tsx @@ -0,0 +1,164 @@ +import { + Bot, + Heart, + LayoutDashboard, + PackageSearch, + PanelLeft, + PanelRight, + ServerCog, + ShieldCheck, + UserRoundPen, + WandSparkles, + Wrench +} from "lucide-react"; +import { type ComponentType, type ReactNode, useEffect, useState } from "react"; + +import type { PageId, PageParams, PageRoute } from "../contracts/page"; +import type { CurrentUserView } from "../contracts/workspace"; +import { MagicalParticleLayer } from "./MagicalParticleLayer"; +import { + applyBackgroundImage, + applyThemeBackgroundPreset, + applyThemePalette, + loadThemeState, + themePaletteChangeEvent, + themePalettes, + themeTokens, + type ThemePaletteChangeDetail, + type WorkspaceThemeState +} from "../theme/tokens"; +import { cx } from "../utils/classes"; + +interface AppShellProps { + routes: PageRoute[]; + currentPage: PageId; + session: CurrentUserView; + onNavigate: (pageId: PageId, params?: PageParams) => void; + children: ReactNode; +} + +interface MenuGroup { + id: string; + label: string; + routeIds: PageId[]; + icon: ComponentType<{ size?: number; className?: string }>; +} + +const menuGroups: MenuGroup[] = [ + { id: "overview", label: "平台概览", routeIds: ["home"], icon: LayoutDashboard }, + { id: "servers", label: "服务器管理", routeIds: ["servers"], icon: ServerCog }, + { id: "plugins", label: "插件市场", routeIds: ["plugins"], icon: PackageSearch }, + { id: "users", label: "用户管理", routeIds: ["users"], icon: ShieldCheck }, + { id: "ai", label: "AI 提供商管理", routeIds: ["aiProviders"], icon: Bot }, + { id: "tools", label: "系统工具", routeIds: ["maintenance"], icon: Wrench } +]; + +const roleLabels: Record = { + platformAdmin: "平台管理员", + serverOwner: "服主", + serverAdmin: "服务器管理员" +}; + +export function AppShell({ routes, currentPage, session, onNavigate, children }: AppShellProps) { + const [themeState, setThemeState] = useState(() => loadThemeState()); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + + useEffect(() => { + function handleThemePaletteChange(event: Event) { + const paletteId = (event as CustomEvent).detail?.paletteId; + if (!paletteId) { + return; + } + setThemeState((current) => (current.paletteId === paletteId ? current : { ...current, paletteId })); + } + + window.addEventListener(themePaletteChangeEvent, handleThemePaletteChange); + const stored = loadThemeState(); + applyThemePalette(stored.paletteId); + applyThemeBackgroundPreset(stored.backgroundPresetId); + applyBackgroundImage(stored.backgroundImage); + setThemeState(stored); + return () => window.removeEventListener(themePaletteChangeEvent, handleThemePaletteChange); + }, []); + + const activePalette = themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0]; + const routesById = new Map(routes.map((route) => [route.id, route])); + const visibleGroups = menuGroups + .map((group) => ({ + ...group, + routes: group.routeIds.map((routeId) => routesById.get(routeId)).filter((route): route is PageRoute => Boolean(route)) + })) + .filter((group) => group.routes.length > 0); + + function activateGroup(group: (typeof visibleGroups)[number]) { + if (group.routes[0].id !== currentPage) { + onNavigate(group.routes[0].id); + } + } + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/platform_web/components/AuthView.tsx b/platform_web/components/AuthView.tsx new file mode 100644 index 0000000..8bec16c --- /dev/null +++ b/platform_web/components/AuthView.tsx @@ -0,0 +1,132 @@ +import { HeartHandshake, Loader2, MoonStar, Sparkles, WandSparkles } from "lucide-react"; +import { type FormEvent, useState } from "react"; + +import type { SessionState } from "../stores/session"; +import { cx } from "../utils/classes"; + +interface AuthViewProps { + session: SessionState; +} + +export function AuthView({ session }: AuthViewProps) { + const isRegister = session.auth.mode === "register"; + const [draft, setDraft] = useState({ + account: "", + displayName: "", + email: "", + password: "", + phone: "", + qq: "" + }); + + function updateDraft(key: keyof typeof draft, value: string) { + setDraft((current) => ({ ...current, [key]: value })); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (isRegister) { + await session.register({ + displayName: draft.displayName.trim() || draft.account.trim() || "待审核玩家", + email: draft.email.trim(), + password: draft.password, + phone: draft.phone.trim(), + qq: draft.qq.trim() + }); + return; + } + await session.login({ account: draft.account.trim(), password: draft.password }); + } + + return ( +
+
+
+ + + +
+

Mecha Ops Console

+

{isRegister ? "申请进入服务器工作台" : "登录机甲运维工作台"}

+
+
+

+ {isRegister + ? "新账号默认进入待审核或服务器范围,不会获得平台管理员权限。" + : "登录后会按你的角色进入默认工作区:平台管理员看概览,服务器用户看服务器列表。"} +

+ +
+ + +
+ +
+ {isRegister ? ( + <> + + + + + + ) : ( + + )} + + {session.auth.error && ( +
+ 操作失败 + {session.auth.error} +
+ )} + {session.auth.success && ( +
+ 已记录 + {session.auth.success} +
+ )} + {session.authUnavailable && session.localFallbackAvailable && ( +
+ 本地回退可用 + 当前身份 API 不可用,可以进入服务器范围的本地演示工作台进行 UI 验收。 +
+ )} + + {session.authUnavailable && session.localFallbackAvailable && ( + + )} +
+
+
+ ); +} diff --git a/platform_web/components/MagicalParticleLayer.tsx b/platform_web/components/MagicalParticleLayer.tsx new file mode 100644 index 0000000..9776235 --- /dev/null +++ b/platform_web/components/MagicalParticleLayer.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties } from "react"; + +const particleSlots = [ + { x: "12%", y: "16%" }, + { x: "31%", y: "24%" }, + { x: "72%", y: "12%" }, + { x: "88%", y: "36%" }, + { x: "18%", y: "58%" }, + { x: "46%", y: "66%" }, + { x: "78%", y: "74%" }, + { x: "92%", y: "86%" } +]; + +type ParticleStyle = CSSProperties & { + "--particle-index": string; + "--particle-x": string; + "--particle-y": string; +}; + +export function MagicalParticleLayer() { + return ( + <> +