From 7efe97b608f6276c0012e9d0384984c175f06f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 10 Aug 2026 12:15:09 +0200 Subject: [PATCH 1/5] refactor: deploy Community with shared Cloudflare platform --- .env.example | 141 ++-- CONTRIBUTING.md | 55 +- LICENSE.md | 5 +- README.md | 19 +- alchemy.run.ts | 1 + apps/backend/Dockerfile | 6 +- .../infrastructure/DeploymentConfig.ts | 18 + apps/backend/infrastructure/Hyperdrive.ts | 53 ++ .../infrastructure/PaywallArtifactStore.ts | 107 +++ .../infrastructure/ProjectSchemaCache.ts | 32 + .../backend/infrastructure/PublicFileStore.ts | 98 +++ apps/backend/package.json | 4 +- apps/backend/r2/PaywallArtifactsBucket.ts | 16 + apps/backend/r2/PublicFileStorageBucket.ts | 14 + apps/backend/src/agent/AgentNodeWebSocket.ts | 2 +- apps/backend/src/backend/Backend.ts | 11 +- apps/backend/src/backend/ObjectStores.ts | 5 +- apps/backend/src/backend/PlatformProfile.ts | 20 +- apps/backend/src/backend/Thumbnails.ts | 29 +- apps/backend/src/config.ts | 4 +- apps/backend/src/migrations.ts | 6 +- apps/backend/src/mimic/MimicNode.ts | 2 +- apps/backend/src/mimic/MimicNodeWebSocket.ts | 6 +- apps/backend/src/mimic/PgControlStore.ts | 2 +- apps/backend/src/mimic/config.ts | 2 +- apps/backend/src/server.ts | 6 +- apps/backend/stack.ts | 37 + .../AgentNodeWebSocket.integration.test.ts | 2 +- apps/backend/tests/MimicDocumentIdle.test.ts | 23 +- .../tests/MimicNode.integration.test.ts | 8 +- apps/backend/tests/MimicNodeWebSocket.test.ts | 7 +- apps/backend/tsconfig.json | 6 +- apps/backend/vitest.integration.mts | 2 +- apps/backend/workers/BackendWorker.ts | 278 ++++++++ apps/backend/workers/WwwWorker.ts | 64 ++ apps/www/scripts/dev.mjs | 2 +- docker-compose.yml | 19 + docs/architecture.md | 76 +- docs/cloudflare-deployment.md | 56 ++ docs/launch-announcement-draft.md | 20 +- docs/licensing-and-self-hosting-faq.md | 16 +- docs/security/backend-threat-model.md | 104 ++- package.json | 21 +- packages/agent/package.json | 2 +- .../AgentSessionCluster.integration.test.ts | 21 +- packages/agent/tests/AgentSessionCore.test.ts | 4 +- packages/agent/tests/SessionLog.test.ts | 2 +- packages/agent/vitest.integration.mts | 2 +- packages/backend/vitest.integration.mts | 2 +- .../_testing/CoreIntegrationTestHarness.ts | 10 +- .../core/test/_testing/CoreTestConnections.ts | 14 +- packages/core/test/_testing/globalSetup.ts | 11 +- packages/core/vitest.integration.mts | 2 +- packages/db/vitest.integration.mts | 2 +- packages/platform/cloudflare/package.json | 34 + .../platform/cloudflare/src/DurableEntity.ts | 88 +++ .../platform/cloudflare/src/HyperdriveDb.ts | 56 ++ .../cloudflare/src/PlatformRuntime.ts | 25 + packages/platform/cloudflare/src/Queue.ts | 93 +++ .../platform/cloudflare/src/QueueConsumer.ts | 114 +++ .../platform/cloudflare/src/WorkflowRunner.ts | 246 +++++++ packages/platform/cloudflare/src/index.ts | 20 + packages/platform/cloudflare/tsconfig.json | 4 + .../platform/node}/package.json | 4 +- .../node}/src/ClusterDurableEntity.ts | 2 +- .../platform/node}/src/CronScheduler.ts | 29 +- .../platform/node}/src/EntityAlarmStore.ts | 7 +- .../platform/node}/src/KeyValueStore.ts | 8 +- .../platform/node}/src/Mailer.ts | 13 +- .../platform/node}/src/MemoryDurableEntity.ts | 5 +- .../node}/src/NodeDurableEntitySession.ts | 0 .../platform/node}/src/ObjectStore.ts | 16 +- .../platform/node}/src/PlatformRuntime.ts | 4 +- .../platform/node}/src/Postgres.ts | 0 .../platform/node}/src/Queue.ts | 15 +- .../platform/node}/src/Screenshot.ts | 0 .../platform/node}/src/Topology.ts | 7 +- .../platform/node}/src/Workflow.ts | 0 .../platform/node}/src/index.ts | 2 +- .../ChromiumScreenshot.integration.test.ts | 6 +- .../node}/tests/MemoryDurableEntity.test.ts | 0 .../MemoryDurableEntityConformance.test.ts | 0 .../tests/NodeDurableEntitySession.test.ts | 0 .../tests/PgKeyValueStore.integration.test.ts | 23 +- .../tests/S3ObjectStore.integration.test.ts | 20 +- .../platform/node}/tests/Screenshot.test.ts | 0 .../tests/SingleNodePg.integration.test.ts | 20 +- .../tests/SmtpMailer.integration.test.ts | 18 +- .../platform/node}/tests/cluster.test.ts | 6 +- .../platform/node}/tests/conformance.test.ts | 10 +- .../platform/node}/tsconfig.json | 0 .../platform/node}/vitest.integration.mts | 2 +- .../platform/node}/vitest.mts | 0 .../src/vite/define-voidhash-web-config.ts | 2 +- pnpm-lock.yaml | 403 +++++++---- pnpm-workspace.yaml | 2 +- ...ry.mjs => check-node-runtime-boundary.mjs} | 8 +- scripts/check-platform-seam.mjs | 32 +- scripts/check-test-tiers.mjs | 2 +- scripts/integration-suites.mjs | 2 +- scripts/run-local-integration.mjs | 54 +- selfhost/LICENSE.md | 661 ------------------ selfhost/README.md | 269 ------- .../integration}/docker-compose.dev.yml | 13 +- .../integration}/docker-compose.yml | 6 +- .../integration}/release-smoke.mts | 11 +- {selfhost => test/integration}/smoke.mts | 24 +- 107 files changed, 2209 insertions(+), 1654 deletions(-) create mode 100644 alchemy.run.ts create mode 100644 apps/backend/infrastructure/DeploymentConfig.ts create mode 100644 apps/backend/infrastructure/Hyperdrive.ts create mode 100644 apps/backend/infrastructure/PaywallArtifactStore.ts create mode 100644 apps/backend/infrastructure/ProjectSchemaCache.ts create mode 100644 apps/backend/infrastructure/PublicFileStore.ts create mode 100644 apps/backend/r2/PaywallArtifactsBucket.ts create mode 100644 apps/backend/r2/PublicFileStorageBucket.ts create mode 100644 apps/backend/stack.ts create mode 100644 apps/backend/workers/BackendWorker.ts create mode 100644 apps/backend/workers/WwwWorker.ts create mode 100644 docker-compose.yml create mode 100644 docs/cloudflare-deployment.md create mode 100644 packages/platform/cloudflare/package.json create mode 100644 packages/platform/cloudflare/src/DurableEntity.ts create mode 100644 packages/platform/cloudflare/src/HyperdriveDb.ts create mode 100644 packages/platform/cloudflare/src/PlatformRuntime.ts create mode 100644 packages/platform/cloudflare/src/Queue.ts create mode 100644 packages/platform/cloudflare/src/QueueConsumer.ts create mode 100644 packages/platform/cloudflare/src/WorkflowRunner.ts create mode 100644 packages/platform/cloudflare/src/index.ts create mode 100644 packages/platform/cloudflare/tsconfig.json rename {selfhost/platform => packages/platform/node}/package.json (95%) rename {selfhost/platform => packages/platform/node}/src/ClusterDurableEntity.ts (99%) rename {selfhost/platform => packages/platform/node}/src/CronScheduler.ts (94%) rename {selfhost/platform => packages/platform/node}/src/EntityAlarmStore.ts (96%) rename {selfhost/platform => packages/platform/node}/src/KeyValueStore.ts (97%) rename {selfhost/platform => packages/platform/node}/src/Mailer.ts (93%) rename {selfhost/platform => packages/platform/node}/src/MemoryDurableEntity.ts (95%) rename {selfhost/platform => packages/platform/node}/src/NodeDurableEntitySession.ts (100%) rename {selfhost/platform => packages/platform/node}/src/ObjectStore.ts (93%) rename {selfhost/platform => packages/platform/node}/src/PlatformRuntime.ts (67%) rename {selfhost/platform => packages/platform/node}/src/Postgres.ts (100%) rename {selfhost/platform => packages/platform/node}/src/Queue.ts (97%) rename {selfhost/platform => packages/platform/node}/src/Screenshot.ts (100%) rename {selfhost/platform => packages/platform/node}/src/Topology.ts (87%) rename {selfhost/platform => packages/platform/node}/src/Workflow.ts (100%) rename {selfhost/platform => packages/platform/node}/src/index.ts (94%) rename {selfhost/platform => packages/platform/node}/tests/ChromiumScreenshot.integration.test.ts (95%) rename {selfhost/platform => packages/platform/node}/tests/MemoryDurableEntity.test.ts (100%) rename {selfhost/platform => packages/platform/node}/tests/MemoryDurableEntityConformance.test.ts (100%) rename {selfhost/platform => packages/platform/node}/tests/NodeDurableEntitySession.test.ts (100%) rename {selfhost/platform => packages/platform/node}/tests/PgKeyValueStore.integration.test.ts (88%) rename {selfhost/platform => packages/platform/node}/tests/S3ObjectStore.integration.test.ts (82%) rename {selfhost/platform => packages/platform/node}/tests/Screenshot.test.ts (100%) rename {selfhost/platform => packages/platform/node}/tests/SingleNodePg.integration.test.ts (87%) rename {selfhost/platform => packages/platform/node}/tests/SmtpMailer.integration.test.ts (90%) rename {selfhost/platform => packages/platform/node}/tests/cluster.test.ts (97%) rename {selfhost/platform => packages/platform/node}/tests/conformance.test.ts (91%) rename {selfhost/platform => packages/platform/node}/tsconfig.json (100%) rename {selfhost/platform => packages/platform/node}/vitest.integration.mts (89%) rename {selfhost/platform => packages/platform/node}/vitest.mts (100%) rename scripts/{check-selfhost-runtime-boundary.mjs => check-node-runtime-boundary.mjs} (96%) delete mode 100644 selfhost/LICENSE.md delete mode 100644 selfhost/README.md rename {selfhost => test/integration}/docker-compose.dev.yml (53%) rename {selfhost => test/integration}/docker-compose.yml (98%) rename {selfhost => test/integration}/release-smoke.mts (98%) rename {selfhost => test/integration}/smoke.mts (93%) diff --git a/.env.example b/.env.example index 51fc45f67..62f16c557 100644 --- a/.env.example +++ b/.env.example @@ -1,78 +1,70 @@ -# `production` for real deployments; `local-evaluation` relaxes credential -# validation for local development and is what the integration suites expect. -# Evaluation mode accepts the documented default root credentials; production -# refuses them. `pnpm stack:up` / `pnpm test:integration` force evaluation mode -# for the stack they manage, so this value is the one a real deployment gets. -SELFHOST_MODE=production +# Cloudflare deployment. Uncomment these values for a live deployment. The +# domain values are hostnames only; the zones must already exist in the account. +# CLOUDFLARE_ACCOUNT_ID= +# CLOUDFLARE_API_TOKEN= +# VOIDHASH_BACKEND_DOMAIN=api.example.com +# VOIDHASH_WWW_DOMAIN=app.example.com +VOIDHASH_WORKERS_DEV_ENABLED=true + +# Local PostgreSQL used by Alchemy Hyperdrive and the migration CLI. +DATABASE_HOST=127.0.0.1 +DATABASE_PORT=5432 DATABASE_USERNAME=voidhash -DATABASE_PASSWORD=replace-with-a-random-password +DATABASE_PASSWORD=password DATABASE_NAME=voidhash DATABASE_SSL=false -# Direct-TCP overrides for the migration process (`pnpm migrate`) and the local -# migration CLI (`pnpm db:migrate`). Each one falls back to its DATABASE_* -# counterpart, so leave them unset unless DATABASE_HOST points at a sandboxed or -# proxied endpoint — a connection broker, or a -# Hyperdrive-style local socket — that only resolves inside the runtime serving -# requests. Migrations run in their own process and need the origin address. -# DATABASE_DIRECT_HOST=postgres -# DATABASE_DIRECT_PORT=5432 -# DATABASE_DIRECT_NAME=voidhash -# DATABASE_DIRECT_USERNAME=voidhash -# DATABASE_DIRECT_PASSWORD=replace-with-a-random-password -# DATABASE_DIRECT_SSL=false -# Overrides for platform state — cluster mailboxes, workflow executions, -# persisted queues, entity alarms, and the platform key-value store. Each falls -# back to its DATABASE_* counterpart, so leaving them unset keeps that state -# beside application data, which is what a deployment wants. They exist because a -# single-node cluster claims every shard in its database: a process that must not -# contend with the deployment for shards needs a database of its own, which is -# how `pnpm test:integration` isolates the suites that build their own cluster. -# DATABASE_PLATFORM_HOST=postgres -# DATABASE_PLATFORM_PORT=5432 -# DATABASE_PLATFORM_NAME=voidhash -# DATABASE_PLATFORM_USERNAME=voidhash -# DATABASE_PLATFORM_PASSWORD=replace-with-a-random-password -# DATABASE_PLATFORM_SSL=false + +# Optional public backend origin override. This defaults to the backend custom +# domain in live deployments and http://localhost:8787 in local development. +# Set it when deploying without VOIDHASH_BACKEND_DOMAIN. +# PAYWALL_PUBLIC_BASE_URL=https://api.example.com + +# Community root account and session signing. Replace these values for every +# live stage; the defaults are only for loopback development. +VOIDHASH_ROOT_USERNAME=root +VOIDHASH_ROOT_PASSWORD=voidhash +VOIDHASH_ROOT_EMAIL=root@voidhash.local +VOIDHASH_AUTH_SECRET=local-development-secret-at-least-32-chars + +# Optional backend integrations. +APNS_DELIVERY_ENABLED=false +PUSH_REQUIRE_ENCRYPTION=true +ENCRYPTION_KEY= +EXCHANGE_RATE_API_KEY= +GOOGLE_PUBSUB_PUSH_AUDIENCE= +GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT_EMAIL= +SLACK_BOT_TOKEN= +SLACK_FEEDBACK_CHANNEL_ID= + +# Direct-TCP migration overrides. Leave unset unless DATABASE_HOST is a proxy +# that only resolves inside the Worker runtime. +# DATABASE_DIRECT_HOST= +# DATABASE_DIRECT_PORT= +# DATABASE_DIRECT_NAME= +# DATABASE_DIRECT_USERNAME= +# DATABASE_DIRECT_PASSWORD= +# DATABASE_DIRECT_SSL= + +# Test-only Node fixture. `pnpm test:integration` sets evaluation mode and +# derives the PLATFORM_NODE_* connection variables automatically. +DATABASE_HOST_PORT=5432 +COMPILER_HOST_PORT=5002 MIMIC_ROOT_USERNAME=root -MIMIC_ROOT_PASSWORD=replace-with-a-random-password +MIMIC_ROOT_PASSWORD=password +MIMIC_PORT=5001 PUBLIC_BASE_URL=http://localhost:5001 PUBLIC_FILES_BASE_URL=http://localhost:5001 -MIMIC_CORS_ORIGINS=https://voidhash.localhost,https://mimic-admin.voidhash.localhost,http://localhost:3000,http://localhost:3003 +MIMIC_CORS_ORIGINS=http://localhost:3000 MIMIC_DOCUMENT_IDLE_NOTIFY_DEBOUNCE_MS=15000 -MIMIC_PORT=5001 -# The single root account. Voidhash self-host is single-player: these are the -# only credentials that can sign in, and there is no sign-up. Required in -# production mode; `local-evaluation` falls back to root / voidhash. -VOIDHASH_ROOT_USERNAME=root -VOIDHASH_ROOT_PASSWORD=replace-with-a-random-password -# Optional; defaults to root@voidhash.local. Used as the root user's address. -# VOIDHASH_ROOT_EMAIL= -# Signs the dashboard and API session tokens. Required in production mode. -VOIDHASH_AUTH_SECRET=replace-with-at-least-32-random-characters -# Durable agent model access. Configure at least one provider. -OPENAI_API_KEY= -ANTHROPIC_API_KEY= -# OPENAI_BASE_URL=https://your-openai-compatible-host/v1 -# VOIDHASH_AGENT_MODEL_PROVIDER=openai -# VOIDHASH_AGENT_MODEL_ID=gpt-5.4 -# VOIDHASH_AGENT_VISION_MODEL_PROVIDER=openai -# VOIDHASH_AGENT_VISION_MODEL_ID=gpt-5.4 -# Required when Google Play RTDN is enabled. These must match the Pub/Sub push subscription. -GOOGLE_PUBSUB_PUSH_AUDIENCE= -GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT_EMAIL= -# Optional offline Enterprise activation; configure the token and issuer verification key together. -VOIDHASH_LICENSE_KEY= -VOIDHASH_LICENSE_PUBLIC_KEY= -ENCRYPTION_KEY= -APNS_DELIVERY_ENABLED=false -EXCHANGE_RATE_API_KEY= + S3_ACCESS_KEY_ID=voidhash -S3_SECRET_ACCESS_KEY=replace-with-a-random-password +S3_SECRET_ACCESS_KEY=password S3_REGION=us-east-1 S3_PUBLIC_BUCKET=voidhash-public S3_ARTIFACT_BUCKET=voidhash-artifacts MINIO_API_PORT=9000 MINIO_CONSOLE_PORT=9001 + SMTP_HOST=mailpit SMTP_PORT=1025 SMTP_SECURE=false @@ -86,27 +78,4 @@ SMTP_VERIFY_ON_START=true MAILPIT_SMTP_PORT=1025 MAILPIT_UI_PORT=8025 -# ── Local development & integration tests ──────────────────────────────────── -# Used together with docker-compose.dev.yml: -# docker compose -f docker-compose.yml -f docker-compose.dev.yml \ -# up -d --build -# `pnpm test:integration` (repo root) reads this file and derives host-side -# connection settings from the values below, so the whole suite runs against -# this stack with no additional configuration. - -# Host ports published by the dev overlay. Change them only when another local -# service already owns the default. -DATABASE_HOST_PORT=5432 -COMPILER_HOST_PORT=5002 - - -# Browser used by the screenshot integration tests on the host. The container -# ships its own chromium; this is only for host-side test runs. -# PLATFORM_SELFHOST_CHROMIUM_EXECUTABLE_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome - -# ── Values you must provide ────────────────────────────────────────────────── -# VOIDHASH_ROOT_PASSWORD / VOIDHASH_AUTH_SECRET — sign-in. Production mode -# refuses to start until both hold real values. -# OPENAI_API_KEY / ANTHROPIC_API_KEY — required only for the AI designer agent. -# EXCHANGE_RATE_API_KEY — required only for the FX rate sync job. -# ENCRYPTION_KEY — required for payment-provider credential storage. +# PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4994ef990..f15521ce3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,50 +38,33 @@ pnpm typecheck pnpm test ``` -Use `pnpm check:publication` to validate license metadata and the public/private -repository boundary. The [self-hosting guide](selfhost/README.md) documents the -local Compose environment and its smoke tests. +Use `pnpm check:publication` to validate license metadata and the repository +boundary. The [Cloudflare deployment guide](docs/cloudflare-deployment.md) +documents the local and live Alchemy workflow. Linting and formatting go through vite-plus: `pnpm lint` (`vp check`) and `pnpm format` (`vp check --fix`). -`pnpm dev` starts every browser-facing development surface and the services -used by the Mimic example through Portless. The first run creates and trusts a -local certificate authority for the named HTTPS routes: - -Run `pnpm dev` as your normal user, never through `sudo`. Portless elevates only -its HTTPS proxy when necessary, while the application processes remain owned by -your user. Startup also prunes orphaned Portless children left by crashed dev -sessions before checking the fixed ports. Use `pnpm dev:status` to inspect active -routes and `pnpm dev:doctor` to diagnose the proxy, certificate, or DNS setup. - -| Surface | URL | App port | -| ------------------ | ---------------------------------------------- | -------- | -| Dashboard and docs | `https://voidhash.localhost` | `3000` | -| Mimic example API | `https://mimic-example-api.voidhash.localhost` | `3001` | -| Mimic admin | `https://mimic-admin.voidhash.localhost` | `3003` | -| Email previews | `https://emails.voidhash.localhost` | `3010` | -| Studio | `https://studio.voidhash.localhost` | `4830` | -| Mimic database | `https://mimic.voidhash.localhost` | `5001` | -| Mimic example | `https://mimic-example.voidhash.localhost` | `5173` | - -The ports are strict: if another process is using one, startup fails instead of -silently moving an app and breaking its local links. - -The steps above describe a **standalone clone** of this repository, which installs -its own `node_modules` from this repository's lockfile. This repository is also -consumed as a nested workspace by Voidhash's private monorepo. In that mode the -superproject's root install is authoritative: it already covers every package here, -this directory must **not** have its own `node_modules` (two installs give -`drizzle-orm`/`@types/react` duplicate TypeScript type identities), and all commands -are run from the superproject root rather than from here. +Start PostgreSQL, apply migrations, and launch the Community Alchemy stack: + +```sh +cp .env.example .env +docker compose up -d standalone_postgres +pnpm db:migrate +pnpm dev +``` + +Alchemy serves the backend on `http://localhost:8787` and the web application +on `http://localhost:3000`. Ports are strict so local links cannot silently move +between runs. ## Testing Run the smallest relevant package tests while iterating, then run the repository -typecheck and test graph before requesting review. Changes to the Node runtime -or Compose configuration should also pass both self-host smoke tests documented -in [selfhost/README.md](selfhost/README.md#smoke-test). +typecheck and test graph before requesting review. `pnpm test:integration` +provisions the test-only Node fixture used by database and optional Node adapter +tests. Use `pnpm test:infra:up` and `pnpm test:infra:down` when debugging that +fixture directly. ## License zones diff --git a/LICENSE.md b/LICENSE.md index fcd140313..0cb3ceceb 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -17,15 +17,14 @@ The full MIT License is in [LICENSES/MIT.txt](LICENSES/MIT.txt). ## AGPL service code -The backend, dashboard, service packages, and self-hosting code that declare +The backend, dashboard, service packages, and deployment adapters that declare `AGPL-3.0-only` in their package metadata or carry a local AGPL notice are licensed under the GNU Affero General Public License, version 3 only. The full license is in [LICENSES/AGPL-3.0-only.txt](LICENSES/AGPL-3.0-only.txt). ## Enterprise code -Enterprise code is not included in this repository and remains in Voidhash's -private cloud repository. The +Commercial code is not included in this repository. The [Voidhash Enterprise License](LICENSES/Voidhash-Enterprise.md) is retained here as the canonical text for any separately distributed Enterprise Software, but it does not apply to code unless a file or directory expressly says so. diff --git a/README.md b/README.md index f648f7c98..91e6464c0 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ > [!IMPORTANT] > This private validation branch contains the complete Community platform, -> including the backend and self-hosting composition. The repository remains +> including the backend and Cloudflare composition. The repository remains > private through alpha and beta security validation and must not be described > as publicly launched until the publication gate is complete. @@ -56,10 +56,12 @@ voidhash-cli init ## 📚 Documentation -For product documentation, visit [voidhash.com](https://voidhash.com/docs). To -run the Community platform locally, see the [self-hosting guide](selfhost/README.md). -The [architecture overview](docs/architecture.md) explains the Community, -Cloud, and Enterprise composition boundaries, and the +For product documentation, visit [voidhash.com](https://voidhash.com/docs). +`pnpm dev` runs the Community Alchemy/Cloudflare composition; see the +[Cloudflare guide](docs/cloudflare-deployment.md) for local and live +deployment. +The [architecture overview](docs/architecture.md) explains the Community +runtime and package boundaries, and the [licensing and self-hosting FAQ](docs/licensing-and-self-hosting-faq.md) covers AGPL and the self-hosting model. @@ -72,10 +74,9 @@ and [Security Policy](SECURITY.md). ## 📄 License This repository uses explicit license zones. SDKs and client libraries are -MIT-licensed; the backend, dashboard, service packages, and self-hosting code -are AGPL-3.0-only. Closed Enterprise implementation remains in the private -cloud repository and is not included here. See [LICENSE.md](LICENSE.md) for the -authoritative map and full texts. +MIT-licensed; the backend, dashboard, service packages, and deployment adapters +are AGPL-3.0-only. Commercial features are not included here. See +[LICENSE.md](LICENSE.md) for the authoritative map and full texts. ## 🔗 Links diff --git a/alchemy.run.ts b/alchemy.run.ts new file mode 100644 index 000000000..2c4024cc6 --- /dev/null +++ b/alchemy.run.ts @@ -0,0 +1 @@ +export { default } from "./apps/backend/stack.ts"; diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 48e5fc9c1..fa9ccec82 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -10,11 +10,11 @@ RUN apt-get update \ WORKDIR /repo COPY . . RUN corepack pnpm@11.1.3 install --frozen-lockfile --filter @voidhash/backend-app... --filter @voidhash/www... --ignore-scripts --config.node-linker=isolated -RUN VITE_APP_API_URL= VITE_APP_ENV=production VOIDHASH_SELFHOST_BUNDLE=true corepack pnpm@11.1.3 exec turbo build --filter @voidhash/www +RUN VITE_APP_API_URL= VITE_APP_ENV=production VOIDHASH_NODE_BUNDLE=true corepack pnpm@11.1.3 exec turbo build --filter @voidhash/www RUN rm -rf /out && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=isolated --filter @voidhash/backend-app deploy --prod --legacy /out -RUN node scripts/check-selfhost-runtime-boundary.mjs /out +RUN node scripts/check-node-runtime-boundary.mjs /out RUN rm -rf /www && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=hoisted --config.allow-unused-patches=true --filter @voidhash/www deploy --prod --legacy /www -RUN node scripts/check-selfhost-runtime-boundary.mjs /www +RUN node scripts/check-node-runtime-boundary.mjs /www FROM node:24-bookworm-slim AS runtime diff --git a/apps/backend/infrastructure/DeploymentConfig.ts b/apps/backend/infrastructure/DeploymentConfig.ts new file mode 100644 index 000000000..f4248fe16 --- /dev/null +++ b/apps/backend/infrastructure/DeploymentConfig.ts @@ -0,0 +1,18 @@ +import * as Config from "effect/Config"; + +const optionalDomain = (name: string): Config.Config => + Config.string(name).pipe( + Config.map((value) => value.trim() || undefined), + Config.withDefault(undefined), + ); + +/** Custom hostname attached to the Community backend Worker for live deployments. */ +export const CommunityBackendDomain = optionalDomain("VOIDHASH_BACKEND_DOMAIN"); + +/** Custom hostname attached to the Community web Worker for live deployments. */ +export const CommunityWwwDomain = optionalDomain("VOIDHASH_WWW_DOMAIN"); + +/** Whether live Community Workers remain available on their `workers.dev` URLs. */ +export const CommunityWorkersDevEnabled = Config.boolean("VOIDHASH_WORKERS_DEV_ENABLED").pipe( + Config.withDefault(true), +); diff --git a/apps/backend/infrastructure/Hyperdrive.ts b/apps/backend/infrastructure/Hyperdrive.ts new file mode 100644 index 000000000..03cf7bf15 --- /dev/null +++ b/apps/backend/infrastructure/Hyperdrive.ts @@ -0,0 +1,53 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; + +const logicalId = "CommunityDatabaseHyperdrive"; + +// oxlint-disable-next-line effect/noAs -- Worker runtime only needs Alchemy's nominal logical resource reference; there is no deploy-time connection object to construct in workerd. +const runtimeReference = { + Type: "Cloudflare.Hyperdrive", + LogicalId: logicalId, +} as Cloudflare.Hyperdrive.Connection; + +const databaseOrigin = Effect.gen(function* () { + const host = yield* Config.string("DATABASE_HOST").pipe(Config.withDefault("127.0.0.1")); + const port = yield* Config.number("DATABASE_PORT").pipe(Config.withDefault(5432)); + const database = yield* Config.string("DATABASE_NAME").pipe(Config.withDefault("voidhash")); + const user = yield* Config.string("DATABASE_USERNAME").pipe(Config.withDefault("voidhash")); + const password = yield* Config.redacted("DATABASE_PASSWORD").pipe( + Config.withDefault(Redacted.make("password")), + ); + const origin: Cloudflare.Hyperdrive.PublicOrigin = { + scheme: "postgres", + host, + port, + database, + user, + password, + }; + return origin; +}); + +/** + * Hyperdrive connection shared by the Community Worker and managed compositions. + * + * Live deployments read their origin from the `DATABASE_*` deployment + * configuration. Alchemy development uses the same fields and defaults to the + * local PostgreSQL service. + */ +export const DatabaseHyperdrive: Effect.Effect = + Effect.gen(function* () { + const context = yield* Effect.serviceOption(Alchemy.AlchemyContext); + if (Option.isNone(context)) return runtimeReference; + + const origin = yield* databaseOrigin.pipe(Effect.orDie); + return yield* Cloudflare.Hyperdrive.Connection(logicalId, { + caching: { disabled: true }, + origin, + dev: { ...origin, sslmode: "disable" }, + }); + }); diff --git a/apps/backend/infrastructure/PaywallArtifactStore.ts b/apps/backend/infrastructure/PaywallArtifactStore.ts new file mode 100644 index 000000000..d36d12b22 --- /dev/null +++ b/apps/backend/infrastructure/PaywallArtifactStore.ts @@ -0,0 +1,107 @@ +import { + PaywallArtifactStore, + PaywallArtifactStoreError, + type PaywallArtifactStoreShape, +} from "@voidhash/core/services/paywallDeploys/PaywallArtifactStore"; +import { causeMessage } from "@voidhash/lib/lang"; +import * as Cloudflare from "alchemy/Cloudflare"; +import type { RuntimeContext } from "alchemy/RuntimeContext"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export type CloudflareR2Bucket = Effect.Success; + +/** Creates the artifact-store port from an already-resolved R2 binding. */ +export const makePaywallArtifactStore = ( + raw: CloudflareR2Bucket, + bucketName: string, +): PaywallArtifactStoreShape => { + const tryR2 = (operation: string, run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (error) => + new PaywallArtifactStoreError({ + cause: causeMessage(error), + message: `paywall artifact ${operation} failed`, + }), + }); + + const putOptions = (contentType: string | undefined) => { + if (contentType === undefined) return undefined; + return { httpMetadata: { contentType } }; + }; + + return { + bucketName, + putObject: ({ key, body, contentType }) => + tryR2("put", () => raw.put(key, body, putOptions(contentType))).pipe(Effect.asVoid), + getObject: (key) => + Effect.gen(function* () { + const object = yield* tryR2("get", () => raw.get(key)); + if (object === null) return null; + const buffer = yield* tryR2("get", () => object.arrayBuffer()); + return { + body: new Uint8Array(buffer), + contentType: object.httpMetadata?.contentType ?? null, + }; + }), + head: (key) => + Effect.gen(function* () { + const object = yield* tryR2("head", () => raw.head(key)); + if (object === null) return null; + return { size: object.size }; + }), + }; +}; + +/** + * Cloudflare R2 adapter for the core {@link PaywallArtifactStore} port. + * + * Must be called from a Worker's init Effect: `R2.ReadWriteBucket` registers + * the `r2_bucket` Worker binding at plan time, and yielding + * `bucket.bucketName` registers the physical bucket name as an env binding the + * runtime accessor reads back. Both registrations happen during plan + * evaluation. + * + * The returned Layer is built per request (alongside the rest of the backend + * infra graph) and keeps Alchemy's `RuntimeContext` requirement, so the store + * can only materialize inside Worker runtime code — where the binding and the + * bucket-name env var actually exist. The store methods themselves are + * requirement-free (the port's contract): the raw runtime bucket is resolved + * once at layer build and every R2 failure is wrapped into + * {@link PaywallArtifactStoreError}. + * + * @example Wire the store in a Worker init Effect + * ```ts + * const PaywallArtifactStoreLive = yield* makePaywallArtifactStoreLive( + * yield* PaywallArtifactsBucket, + * ); + * ``` + */ +export const makePaywallArtifactStoreLive = ( + bucket: Cloudflare.R2.Bucket, +): Effect.Effect< + Layer.Layer, + never, + Cloudflare.R2.ReadWriteBucket +> => + Effect.gen(function* () { + const client = yield* Cloudflare.R2.ReadWriteBucket(bucket); + // `yield*` on an Output returns a lazy accessor: at plan time this call + // registers the bucket name on the Worker's env; the accessor itself only + // resolves the value when run (below, inside the runtime-only layer build). + const bucketName = yield* bucket.bucketName; + + return Layer.effect( + PaywallArtifactStore, + Effect.gen(function* () { + // The native `R2Bucket` runtime object rather than the Effect wrapper: + // its R2Object properties (`httpMetadata`, `size`) live on workerd + // prototypes, which the wrapper's object spread would lose. + const raw = yield* client.raw; + const name = yield* bucketName; + + return makePaywallArtifactStore(raw, name); + }), + ); + }); diff --git a/apps/backend/infrastructure/ProjectSchemaCache.ts b/apps/backend/infrastructure/ProjectSchemaCache.ts new file mode 100644 index 000000000..ea92fbe3c --- /dev/null +++ b/apps/backend/infrastructure/ProjectSchemaCache.ts @@ -0,0 +1,32 @@ +import { ProjectSchemaCache } from "@voidhash/core/services"; +import { Clock, Effect, Layer } from "effect"; + +interface CacheEntry { + readonly expiresAt: number; + readonly schema: unknown; +} + +/** Isolate-local schema cache used by the Community Cloudflare worker. */ +export const ProjectSchemaCacheLive = Layer.sync(ProjectSchemaCache, () => { + const entries = new Map(); + return { + getByName: (projectId: string) => ({ + get: () => + Effect.gen(function* () { + const entry = entries.get(projectId); + if (!entry) return undefined; + if (entry.expiresAt > (yield* Clock.currentTimeMillis)) return entry.schema; + entries.delete(projectId); + return undefined; + }), + invalidate: () => Effect.sync(() => void entries.delete(projectId)), + set: (schema: unknown, ttlMs: number) => + Clock.currentTimeMillis.pipe( + Effect.tap((now) => + Effect.sync(() => void entries.set(projectId, { expiresAt: now + ttlMs, schema })), + ), + Effect.asVoid, + ), + }), + }; +}); diff --git a/apps/backend/infrastructure/PublicFileStore.ts b/apps/backend/infrastructure/PublicFileStore.ts new file mode 100644 index 000000000..00873cbd3 --- /dev/null +++ b/apps/backend/infrastructure/PublicFileStore.ts @@ -0,0 +1,98 @@ +import { + PublicFileStore, + PublicFileStoreError, + type PublicFileStoreShape, +} from "@voidhash/core/services/storage/PublicFileStore"; +import { causeMessage } from "@voidhash/lib/lang"; +import * as Cloudflare from "alchemy/Cloudflare"; +import type { RuntimeContext } from "alchemy/RuntimeContext"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export type CloudflarePublicR2Bucket = Effect.Success; + +/** R2 `put` options for an optional content type — omitted entirely when absent. */ +const putOptions = (contentType: string | undefined) => { + if (contentType === undefined) return undefined; + return { httpMetadata: { contentType } }; +}; + +/** Creates the public-file port from an already-resolved R2 binding. */ +export const makePublicFileStore = ( + raw: CloudflarePublicR2Bucket, + publicBaseUrl: string, +): PublicFileStoreShape => { + const tryR2 = (operation: string, run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (error) => + new PublicFileStoreError({ + cause: causeMessage(error), + message: `public file ${operation} failed`, + }), + }); + + return { + publicBaseUrl, + publicUrl: (key) => `${publicBaseUrl}/files/${key}`, + putObject: ({ key, body, contentType }) => + tryR2("put", () => raw.put(key, body, putOptions(contentType))).pipe(Effect.asVoid), + getObject: (key) => + Effect.gen(function* () { + const object = yield* tryR2("get", () => raw.get(key)); + if (object === null) return null; + const buffer = yield* tryR2("get", () => object.arrayBuffer()); + return { + body: new Uint8Array(buffer), + contentType: object.httpMetadata?.contentType ?? null, + }; + }), + deleteObject: (key) => tryR2("delete", () => raw.delete(key)).pipe(Effect.asVoid), + }; +}; + +/** + * Cloudflare R2 adapter for the core {@link PublicFileStore} port. + * + * Mirrors the paywall artifact-store adapter: must be called from a Worker's + * init Effect so `R2.ReadWriteBucket` registers the `r2_bucket` + * Worker binding at plan time. The returned Layer is built per request and + * keeps Alchemy's `RuntimeContext` requirement, so the store only materializes + * inside Worker runtime code where the binding exists. The raw runtime bucket + * is resolved once at layer build and every R2 failure is wrapped into + * {@link PublicFileStoreError}. + * + * `publicBaseUrl` is this worker's public origin — the `GET /files/*` serving + * route lives here — so stored objects resolve at `${publicBaseUrl}/files/${key}`. + * + * @example Wire the store in a Worker init Effect + * ```ts + * const PublicFileStoreLive = yield* makePublicFileStoreLive( + * yield* PublicFileStorageBucket, + * publicBaseUrl, + * ); + * ``` + */ +export const makePublicFileStoreLive = ( + bucket: Cloudflare.R2.Bucket, + publicBaseUrl: string, +): Effect.Effect< + Layer.Layer, + never, + Cloudflare.R2.ReadWriteBucket +> => + Effect.gen(function* () { + const client = yield* Cloudflare.R2.ReadWriteBucket(bucket); + + return Layer.effect( + PublicFileStore, + Effect.gen(function* () { + // The native `R2Bucket` runtime object rather than the Effect wrapper: + // its R2Object properties (`httpMetadata`) live on workerd prototypes, + // which the wrapper's object spread would lose. + const raw = yield* client.raw; + + return makePublicFileStore(raw, publicBaseUrl); + }), + ); + }); diff --git a/apps/backend/package.json b/apps/backend/package.json index c3faec8b8..f528a10f1 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,7 +38,9 @@ "@voidhash/paywall-renderer-web-core": "workspace:*", "@voidhash/paywalls": "workspace:*", "@voidhash/platform": "workspace:*", - "@voidhash/platform-selfhost": "workspace:*", + "@voidhash/platform-cloudflare": "workspace:*", + "@voidhash/platform-node": "workspace:*", + "alchemy": "catalog:", "effect": "catalog:", "esbuild": "^0.25.10", "jose": "catalog:", diff --git a/apps/backend/r2/PaywallArtifactsBucket.ts b/apps/backend/r2/PaywallArtifactsBucket.ts new file mode 100644 index 000000000..52b2a9625 --- /dev/null +++ b/apps/backend/r2/PaywallArtifactsBucket.ts @@ -0,0 +1,16 @@ +import * as Cloudflare from "alchemy/Cloudflare"; + +export const PaywallArtifactsBucketBinding = "PaywallArtifactsBucket"; + +/** + * R2 bucket holding paywall code-deploy artifacts (deploy contract §5): + * + * - `blobs//` — content-addressed upload staging written by + * `PaywallDeployService.uploadBlob`. + * - `p//...` — the public, immutable serving layout copied at + * finalize and read back by the backend's `GET /p/:contentHash/*` route. + * + * One bucket per stage (physical name defaults to `${app}-${stage}-${id}`). + * Alchemy development uses its local R2 simulator under `.alchemy/local/r2`. + */ +export const PaywallArtifactsBucket = Cloudflare.R2.Bucket(PaywallArtifactsBucketBinding); diff --git a/apps/backend/r2/PublicFileStorageBucket.ts b/apps/backend/r2/PublicFileStorageBucket.ts new file mode 100644 index 000000000..f4d98bd51 --- /dev/null +++ b/apps/backend/r2/PublicFileStorageBucket.ts @@ -0,0 +1,14 @@ +import * as Cloudflare from "alchemy/Cloudflare"; + +export const PublicFileStorageBucketBinding = "PublicFileStorageBucket"; + +/** + * Unified R2 bucket for public assets (avatars today under + * `avatars///.`, room for more public files later), + * served by this worker's public `GET /files/*` route. Kept separate from + * {@link PaywallArtifactsBucket} so the two have independent lifecycles. + * + * One bucket per stage (physical name defaults to `${app}-${stage}-${id}`). + * Alchemy development uses its local R2 simulator under `.alchemy/local/r2`. + */ +export const PublicFileStorageBucket = Cloudflare.R2.Bucket(PublicFileStorageBucketBinding); diff --git a/apps/backend/src/agent/AgentNodeWebSocket.ts b/apps/backend/src/agent/AgentNodeWebSocket.ts index 34579690c..da58ee969 100644 --- a/apps/backend/src/agent/AgentNodeWebSocket.ts +++ b/apps/backend/src/agent/AgentNodeWebSocket.ts @@ -26,7 +26,7 @@ import { import type { AuthTokenVerifier } from "@voidhash/core/services/auth/AuthTokenVerifier"; import { Db } from "@voidhash/db"; import type { DurableEntityHostShape } from "@voidhash/platform/DurableEntity"; -import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/NodeDurableEntitySession"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; import { Context, Effect, Redacted } from "effect"; import * as HttpHeaders from "effect/unstable/http/Headers"; import { WebSocketServer, type RawData } from "ws"; diff --git a/apps/backend/src/backend/Backend.ts b/apps/backend/src/backend/Backend.ts index 0501f6d55..7f990cf47 100644 --- a/apps/backend/src/backend/Backend.ts +++ b/apps/backend/src/backend/Backend.ts @@ -17,16 +17,13 @@ import type { PublicFileStore } from "@voidhash/core/services/storage/PublicFile import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/PaywallAssetConfig"; import { Db } from "@voidhash/db"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; -import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; +import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; import { Layer, Redacted } from "effect"; import type { SelfhostAuthConfig, SelfhostRuntimeConfig } from "../config.ts"; import { makeHttpComponentCompilerLive } from "../compiler/CompilerClient.ts"; import { makeBackendMimicHostLive } from "./MimicHost.ts"; -import { - makePaywallArtifactStoreLive, - makePublicFileStoreLive, -} from "./ObjectStores.ts"; +import { makePaywallArtifactStoreLive, makePublicFileStoreLive } from "./ObjectStores.ts"; import { MemoryProjectSchemaCacheLive } from "./ProjectSchemaCache.ts"; /** @@ -61,7 +58,7 @@ export const makeBackendInfrastructureLive = ( const publicFileStore = makePublicFileStoreLive( config.publicObjectStore, config.publicFilesBaseUrl, - ).pipe(Layer.provide(SelfhostPlatformRuntimeLive)); + ).pipe(Layer.provide(NodePlatformRuntimeLive)); const db = Db.layer(config.database); return Layer.mergeAll( @@ -72,7 +69,7 @@ export const makeBackendInfrastructureLive = ( publicBaseUrl: config.publicBaseUrl, }), makePaywallArtifactStoreLive(config.artifactObjectStore).pipe( - Layer.provide(SelfhostPlatformRuntimeLive), + Layer.provide(NodePlatformRuntimeLive), ), publicFileStore, BackendPaymentProviderStubsLive, diff --git a/apps/backend/src/backend/ObjectStores.ts b/apps/backend/src/backend/ObjectStores.ts index 27560ee3f..47f381c07 100644 --- a/apps/backend/src/backend/ObjectStores.ts +++ b/apps/backend/src/backend/ObjectStores.ts @@ -8,10 +8,7 @@ import { } from "@voidhash/core/services/storage/PublicFileStore"; import { ObjectStore, ObjectStoreError } from "@voidhash/platform/ObjectStore"; import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; -import { - S3ObjectStoreLive, - type S3ObjectStoreConfig, -} from "@voidhash/platform-selfhost/ObjectStore"; +import { S3ObjectStoreLive, type S3ObjectStoreConfig } from "@voidhash/platform-node/ObjectStore"; import { Effect, Layer, Option } from "effect"; const objectStoreCause = (cause: unknown): string => { diff --git a/apps/backend/src/backend/PlatformProfile.ts b/apps/backend/src/backend/PlatformProfile.ts index 730789e7d..fe3beb84a 100644 --- a/apps/backend/src/backend/PlatformProfile.ts +++ b/apps/backend/src/backend/PlatformProfile.ts @@ -12,15 +12,15 @@ import type { WorkflowRunner } from "@voidhash/platform/WorkflowRunner"; import { ClusterDurableEntityControlLive, ClusterDurableEntityHostLive, -} from "@voidhash/platform-selfhost/ClusterDurableEntity"; -import { ClusterCronSchedulerLive } from "@voidhash/platform-selfhost/CronScheduler"; -import { PgEntityAlarmStoreLive } from "@voidhash/platform-selfhost/EntityAlarmStore"; -import { PgKeyValueStoreLive } from "@voidhash/platform-selfhost/KeyValueStore"; -import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; -import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; -import { ClusterQueueLive } from "@voidhash/platform-selfhost/Queue"; -import { SingleNodeClusterLive } from "@voidhash/platform-selfhost/Topology"; -import * as ClusterWorkflowRunner from "@voidhash/platform-selfhost/Workflow"; +} from "@voidhash/platform-node/ClusterDurableEntity"; +import { ClusterCronSchedulerLive } from "@voidhash/platform-node/CronScheduler"; +import { PgEntityAlarmStoreLive } from "@voidhash/platform-node/EntityAlarmStore"; +import { PgKeyValueStoreLive } from "@voidhash/platform-node/KeyValueStore"; +import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; +import type { PgPlatformConfig } from "@voidhash/platform-node/Postgres"; +import { ClusterQueueLive } from "@voidhash/platform-node/Queue"; +import { SingleNodeClusterLive } from "@voidhash/platform-node/Topology"; +import * as ClusterWorkflowRunner from "@voidhash/platform-node/Workflow"; import { pick } from "@voidhash/lib/lang"; import { Layer, Redacted } from "effect"; import { @@ -126,7 +126,7 @@ const platformLayers = (postgres: PgPlatformConfig): SelfhostPlatformLayers => { Layer.provide(topology), ), workflowRunner: ClusterWorkflowRunner.layer.pipe(Layer.provide(topology)), - runtime: SelfhostPlatformRuntimeLive, + runtime: NodePlatformRuntimeLive, }; }; diff --git a/apps/backend/src/backend/Thumbnails.ts b/apps/backend/src/backend/Thumbnails.ts index 380a8776d..a93fadbd1 100644 --- a/apps/backend/src/backend/Thumbnails.ts +++ b/apps/backend/src/backend/Thumbnails.ts @@ -19,8 +19,8 @@ import { Screenshot } from "@voidhash/platform/Screenshot"; import { ChromiumScreenshotLive, type ChromiumScreenshotConfig, -} from "@voidhash/platform-selfhost/Screenshot"; -import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; +} from "@voidhash/platform-node/Screenshot"; +import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; import { Cause, Effect, Layer } from "effect"; import { mimicDocumentIdleQueueName } from "../mimic/MimicDocumentIdleQueue.ts"; @@ -155,10 +155,7 @@ export const makeSelfhostSnapshotImageRendererLive = ( Layer.provide( SelfhostHtmlScreenshotLive.pipe( Layer.provide( - Layer.merge( - ChromiumScreenshotLive(screenshotConfig), - SelfhostPlatformRuntimeLive, - ), + Layer.merge(ChromiumScreenshotLive(screenshotConfig), NodePlatformRuntimeLive), ), ), ), @@ -168,8 +165,11 @@ export const makeSelfhostSnapshotImageRendererLive = ( /** Builds the Chromium-backed thumbnail service for the self-host runtime. */ export const makeSelfhostPaywallThumbnailServiceLive = ( screenshotConfig: ChromiumScreenshotConfig, - renderer: Layer.Layer = - makeSelfhostSnapshotImageRendererLive(screenshotConfig), + renderer: Layer.Layer< + SnapshotImageRenderer, + never, + PublicFileStore + > = makeSelfhostSnapshotImageRendererLive(screenshotConfig), ) => { return PaywallThumbnailService.layer.pipe( Layer.provide(renderer), @@ -198,14 +198,11 @@ export const runSelfhostPaywallThumbnailConsumer = Effect.gen(function* () { }) .pipe( Effect.tapCause((cause) => - Effect.logWarning( - "paywall thumbnail render failed; will retry then drop", - { - cause: Cause.pretty(cause), - paywallDocumentId: message.documentId, - seq: message.seq, - }, - ), + Effect.logWarning("paywall thumbnail render failed; will retry then drop", { + cause: Cause.pretty(cause), + paywallDocumentId: message.documentId, + seq: message.seq, + }), ), ), { discard: true }, diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index 6c5a444ed..c7884111d 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -5,8 +5,8 @@ // scope in which a `Config` provider could be used. // oxlint-disable effect/noGlobals -- synchronous process.env adapter; callers read these config records from synchronous positions before any Effect runtime exists. import type { DbConfig } from "@voidhash/db/db"; -import type { SmtpMailerConfig } from "@voidhash/platform-selfhost/Mailer"; -import type { S3ObjectStoreConfig } from "@voidhash/platform-selfhost/ObjectStore"; +import type { SmtpMailerConfig } from "@voidhash/platform-node/Mailer"; +import type { S3ObjectStoreConfig } from "@voidhash/platform-node/ObjectStore"; import { isPlaceholderSecret, resolveStandaloneAuthConfig, diff --git a/apps/backend/src/migrations.ts b/apps/backend/src/migrations.ts index 14c120885..b78922371 100644 --- a/apps/backend/src/migrations.ts +++ b/apps/backend/src/migrations.ts @@ -1,5 +1,5 @@ import { runAppDatabaseMigrations } from "@voidhash/db/migrations"; -import { PgClusterDurableEntityLive } from "@voidhash/platform-selfhost/ClusterDurableEntity"; +import { PgClusterDurableEntityLive } from "@voidhash/platform-node/ClusterDurableEntity"; import { Effect, Layer } from "effect"; import { selfhostPlatformPostgres } from "./backend/PlatformProfile.ts"; @@ -41,8 +41,6 @@ export const runSelfhostMigrations = (options: SelfhostMigrationOptions = {}) => // value and alarm stores in whichever database holds platform state. const mimicConfig = getMimicNodeConfig(connection); const platform = selfhostPlatformPostgres(getSelfhostPlatformDatabaseConfig(connection)); - yield* Layer.build( - makeMimicNodeHostLive(mimicConfig, PgClusterDurableEntityLive(platform)), - ); + yield* Layer.build(makeMimicNodeHostLive(mimicConfig, PgClusterDurableEntityLive(platform))); yield* Effect.logInfo("Self-host database migrations are ready", { applied, skipped }); }); diff --git a/apps/backend/src/mimic/MimicNode.ts b/apps/backend/src/mimic/MimicNode.ts index c4467c91d..10cf39f3b 100644 --- a/apps/backend/src/mimic/MimicNode.ts +++ b/apps/backend/src/mimic/MimicNode.ts @@ -15,7 +15,7 @@ import type { DurableEntityAlarmControl, DurableEntityHost, } from "@voidhash/platform/DurableEntity"; -import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; +import type { PgPlatformConfig } from "@voidhash/platform-node/Postgres"; import { Effect, Layer } from "effect"; import { PgControlStoreLive } from "./PgControlStore.ts"; diff --git a/apps/backend/src/mimic/MimicNodeWebSocket.ts b/apps/backend/src/mimic/MimicNodeWebSocket.ts index c668f25a1..b6b2f7b47 100644 --- a/apps/backend/src/mimic/MimicNodeWebSocket.ts +++ b/apps/backend/src/mimic/MimicNodeWebSocket.ts @@ -26,7 +26,7 @@ import { type DurableEntitySession, makeDurableEntityAddress, } from "@voidhash/platform/DurableEntity"; -import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/NodeDurableEntitySession"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; import { Clock, Duration, Effect, Fiber, Semaphore } from "effect"; import WebSocket, { WebSocketServer, type RawData } from "ws"; @@ -252,8 +252,8 @@ export const installMimicNodeWebSocketServer = ( Effect.runSync(socket.entitySession.setAttachment(attachment)); }, send: (socket, message) => - Effect.sync(() => socket.webSocket.send(encodeServerMessage(message))), - close: (socket, code, reason) => Effect.sync(() => socket.webSocket.close(code, reason)), + Effect.sync(() => socket.webSocket.send(encodeServerMessage(message))), + close: (socket, code, reason) => Effect.sync(() => socket.webSocket.close(code, reason)), authenticate: (token, attachment) => withoutRequirements( host.authenticateDocumentToken( diff --git a/apps/backend/src/mimic/PgControlStore.ts b/apps/backend/src/mimic/PgControlStore.ts index 01eca2929..ea716c994 100644 --- a/apps/backend/src/mimic/PgControlStore.ts +++ b/apps/backend/src/mimic/PgControlStore.ts @@ -10,7 +10,7 @@ import type { UserRecord, } from "@voidhash/mimic-db/core/store"; import { ControlStore } from "@voidhash/mimic-db/core/store"; -import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; +import type { PgPlatformConfig } from "@voidhash/platform-node/Postgres"; import { Effect, Layer, Predicate, Schema } from "effect"; import { SqlClient } from "effect/unstable/sql"; diff --git a/apps/backend/src/mimic/config.ts b/apps/backend/src/mimic/config.ts index 97e4e23d2..843b1da94 100644 --- a/apps/backend/src/mimic/config.ts +++ b/apps/backend/src/mimic/config.ts @@ -1,6 +1,6 @@ import type { DbConfig } from "@voidhash/db/db"; import { makePgDocumentConfig } from "@voidhash/mimic-db/core/pg-store"; -import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; +import type { PgPlatformConfig } from "@voidhash/platform-node/Postgres"; import { Redacted } from "effect"; import { getSelfhostDatabaseConfig } from "../config.ts"; diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index cfdc38b9e..267fcf1e2 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -23,7 +23,7 @@ import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import { getConfig as getMimicConfig } from "@voidhash/mimic-db/config"; import { makeRoutesLive } from "@voidhash/mimic-db/http/rpc-app"; import { DurableEntityAlarmControl, DurableEntityHost } from "@voidhash/platform/DurableEntity"; -import { SmtpMailerLive } from "@voidhash/platform-selfhost/Mailer"; +import { SmtpMailerLive } from "@voidhash/platform-node/Mailer"; import { causeMessage } from "@voidhash/lib/lang"; import { Config, Context, Data, Effect, Layer, Option } from "effect"; import { HttpRouter } from "effect/unstable/http"; @@ -250,9 +250,7 @@ export const runSelfhostServer = < yield* Effect.forkScoped( runSelfhostPushDeliveryConsumers(config).pipe(Effect.provide(runtimeContext)), ); - yield* Effect.forkScoped( - runSelfhostCronJobs.pipe(Effect.provide(runtimeContext)), - ); + yield* Effect.forkScoped(runSelfhostCronJobs.pipe(Effect.provide(runtimeContext))); if (chromiumConfig !== undefined) { const thumbnailContext = yield* Layer.build( makeSelfhostPaywallThumbnailServiceLive(chromiumConfig), diff --git a/apps/backend/stack.ts b/apps/backend/stack.ts new file mode 100644 index 000000000..9b7ee2b72 --- /dev/null +++ b/apps/backend/stack.ts @@ -0,0 +1,37 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; + +import { DatabaseHyperdrive } from "./infrastructure/Hyperdrive.ts"; +import { PaywallArtifactsBucket } from "./r2/PaywallArtifactsBucket.ts"; +import { PublicFileStorageBucket } from "./r2/PublicFileStorageBucket.ts"; +import { CommunityWebsite } from "./workers/WwwWorker.ts"; +import CommunityBackend from "./workers/BackendWorker.ts"; + +/** Resolved Community Cloudflare deployment outputs. */ +export interface CommunityStackOutput { + readonly backendUrl: string; + readonly hyperdriveId: string; + readonly wwwUrl: string; +} + +export default Alchemy.Stack( + "VoidhashCommunity", + { + providers: Cloudflare.providers(), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const hyperdrive = yield* DatabaseHyperdrive; + yield* PaywallArtifactsBucket; + yield* PublicFileStorageBucket; + const backend = yield* CommunityBackend; + const www = yield* CommunityWebsite({ apiUrl: backend.url.as() }); + + return { + backendUrl: backend.url, + hyperdriveId: hyperdrive.hyperdriveId, + wwwUrl: www.url, + }; + }), +); diff --git a/apps/backend/tests/AgentNodeWebSocket.integration.test.ts b/apps/backend/tests/AgentNodeWebSocket.integration.test.ts index 817cbbab3..89f3e7fee 100644 --- a/apps/backend/tests/AgentNodeWebSocket.integration.test.ts +++ b/apps/backend/tests/AgentNodeWebSocket.integration.test.ts @@ -10,7 +10,7 @@ import { } from "@voidhash/core/services"; import { Db } from "@voidhash/db"; import { causeMessage, constant } from "@voidhash/lib/lang"; -import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; import { Context, Data, DateTime, Effect, Latch, Redacted, Schema } from "effect"; import { WebSocket } from "ws"; import { describe, expect, it } from "vite-plus/test"; diff --git a/apps/backend/tests/MimicDocumentIdle.test.ts b/apps/backend/tests/MimicDocumentIdle.test.ts index f18464e26..e33f68866 100644 --- a/apps/backend/tests/MimicDocumentIdle.test.ts +++ b/apps/backend/tests/MimicDocumentIdle.test.ts @@ -7,16 +7,13 @@ import { type DurableEntityAlarmControlShape, makeDurableEntityAddress, } from "@voidhash/platform/DurableEntity"; -import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; import { Effect } from "effect"; import { describe, expect, it, vi } from "vitest"; import { dispatchMimicDocumentIdleAlarms } from "../src/mimic/MimicNodeWebSocket.ts"; -const address = makeDurableEntityAddress( - "mimic-document", - "collection-1:document-1", -); +const address = makeDurableEntityAddress("mimic-document", "collection-1:document-1"); const control: DurableEntityAlarmControlShape = { listDueAlarms: () => Effect.succeed([{ address, scheduledTime: 0 }]), @@ -90,13 +87,9 @@ describe("Mimic Node idle alarm dispatch", () => { { collectionId: "collection-1", documentId: "document-1", seq: 7 }, ]); expect( - yield* entities.run(address, (entity) => - entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), - ), + yield* entities.run(address, (entity) => entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY)), ).toBe(7); - expect( - yield* entities.run(address, (entity) => entity.alarm.get), - ).toBeUndefined(); + expect(yield* entities.run(address, (entity) => entity.alarm.get)).toBeUndefined(); }), )); @@ -128,13 +121,9 @@ describe("Mimic Node idle alarm dispatch", () => { expect(published).toEqual([]); expect( - yield* entities.run(address, (entity) => - entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), - ), + yield* entities.run(address, (entity) => entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY)), ).toBe(7); - expect( - yield* entities.run(address, (entity) => entity.alarm.get), - ).toBeUndefined(); + expect(yield* entities.run(address, (entity) => entity.alarm.get)).toBeUndefined(); }), )); }); diff --git a/apps/backend/tests/MimicNode.integration.test.ts b/apps/backend/tests/MimicNode.integration.test.ts index 1da954aae..0f8784f4e 100644 --- a/apps/backend/tests/MimicNode.integration.test.ts +++ b/apps/backend/tests/MimicNode.integration.test.ts @@ -10,8 +10,8 @@ import { DurableEntityHost, makeDurableEntityAddress, } from "@voidhash/platform/DurableEntity"; -import { PgClusterDurableEntityLive } from "@voidhash/platform-selfhost/ClusterDurableEntity"; -import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; +import { PgClusterDurableEntityLive } from "@voidhash/platform-node/ClusterDurableEntity"; +import type { PgPlatformConfig } from "@voidhash/platform-node/Postgres"; import { Config, Data, Effect, Layer, ManagedRuntime, Redacted, Schema } from "effect"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; @@ -203,9 +203,7 @@ describe("self-host mimic Node composition", () => { Effect.timeoutOrElse({ duration: "5 seconds", orElse: () => - Effect.fail( - new MimicNodeTestError({ message: "timed out waiting for snapshot" }), - ), + Effect.fail(new MimicNodeTestError({ message: "timed out waiting for snapshot" })), }), ); expect(messages).toContainEqual( diff --git a/apps/backend/tests/MimicNodeWebSocket.test.ts b/apps/backend/tests/MimicNodeWebSocket.test.ts index e5079e6de..344d34bab 100644 --- a/apps/backend/tests/MimicNodeWebSocket.test.ts +++ b/apps/backend/tests/MimicNodeWebSocket.test.ts @@ -5,7 +5,7 @@ import { constant } from "@voidhash/lib/lang"; import { objectValue } from "@voidhash/mimic-core"; import type { HostService } from "@voidhash/mimic-db/app/hostService"; import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; -import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; import { Data, Effect, Option, Schema } from "effect"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; @@ -158,10 +158,7 @@ describe("mimic Node WebSocket sessions", () => { }), ); - const address = makeDurableEntityAddress( - "mimic-document", - `${collectionId}:${documentId}`, - ); + const address = makeDurableEntityAddress("mimic-document", `${collectionId}:${documentId}`); const attachments = yield* entities.run(address, (entity) => entity.sessions.list.pipe( Effect.flatMap((sessions) => diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index f040f8ac8..0ac9d24bf 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@voidhash/tsconfig/typescript-6.json", + "extends": "@voidhash/tsconfig/alchemy-base.json", "compilerOptions": { "types": ["node"], "noEmit": true, @@ -7,6 +7,6 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true }, - "include": ["src", "tests", "vitest.mts"], - "exclude": ["**/node_modules/**"] + "include": ["."], + "exclude": ["**/node_modules/**", ".alchemy", "dist"] } diff --git a/apps/backend/vitest.integration.mts b/apps/backend/vitest.integration.mts index 0353a4ffb..a7eecb6ef 100644 --- a/apps/backend/vitest.integration.mts +++ b/apps/backend/vitest.integration.mts @@ -1,6 +1,6 @@ import { defineConfig } from "vite-plus"; -// Integration tier: runs against the provisioned self-host stack via +// Integration tier: runs against the provisioned Node test fixture via // `pnpm test:integration`. Timeouts are generous because these tests wait on // real containers rather than fakes. // diff --git a/apps/backend/workers/BackendWorker.ts b/apps/backend/workers/BackendWorker.ts new file mode 100644 index 000000000..92b553d44 --- /dev/null +++ b/apps/backend/workers/BackendWorker.ts @@ -0,0 +1,278 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import { RuntimeContext } from "alchemy/RuntimeContext"; +import { EventCaptureApi } from "@voidhash/api-contracts/event-capture"; +import { + BackendComponentCompilerStubLive, + BackendMimicHostStubLive, + BackendNoopIdentityProjectionPublisherLive, + BackendPaymentProviderStubsLive, + BackendSnapshotImageRendererStubLive, + NoBackendFeatures, + NoBackendRpcExtension, + buildBackendFetch, +} from "@voidhash/backend/BackendApp"; +import { RpcAuthLive } from "@voidhash/backend/RpcMiddlewares"; +import { EventCaptureGroupLive } from "@voidhash/backend/routes/event-capture"; +import { AnalyticsEventStore } from "@voidhash/core/services/analytics/AnalyticsEventStore"; +import { AnalyticsDispatchService } from "@voidhash/core/services/analyticsIngest/AnalyticsDispatchService"; +import { EventCaptureService } from "@voidhash/core/services/analyticsIngest/EventCaptureService"; +import { AuthTokenVerifier } from "@voidhash/core/services/auth/AuthTokenVerifier"; +import { + StandaloneAuthTokenVerifierLive, + StandaloneIdentityProviderLive, +} from "@voidhash/core/services/auth/StandaloneIdentityProvider"; +import { StandaloneOrgDirectoryLive } from "@voidhash/core/services/organizations/StandaloneOrgDirectory"; +import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/PaywallAssetConfig"; +import { backendWorkflows } from "@voidhash/core/workflows/registry"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import * as MemoryWorkflowRunner from "@voidhash/platform/MemoryWorkflowRunner"; +import { WorkflowRunner } from "@voidhash/platform/WorkflowRunner"; +import { DbFromContextLive, HyperdriveDbLayer } from "@voidhash/platform-cloudflare/HyperdriveDb"; +import { providePlatformRuntime } from "@voidhash/platform-cloudflare/PlatformRuntime"; +import * as CloudflareWorkflowRunner from "@voidhash/platform-cloudflare/WorkflowRunner"; +import * as Cause from "effect/Cause"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { DatabaseHyperdrive } from "../infrastructure/Hyperdrive.ts"; +import { + CommunityBackendDomain, + CommunityWorkersDevEnabled, +} from "../infrastructure/DeploymentConfig.ts"; +import { makePaywallArtifactStoreLive } from "../infrastructure/PaywallArtifactStore.ts"; +import { makePublicFileStoreLive } from "../infrastructure/PublicFileStore.ts"; +import { ProjectSchemaCacheLive } from "../infrastructure/ProjectSchemaCache.ts"; +import { PaywallArtifactsBucket } from "../r2/PaywallArtifactsBucket.ts"; +import { PublicFileStorageBucket } from "../r2/PublicFileStorageBucket.ts"; + +const paywallPublicBaseUrl = (fallback: string | undefined): Config.Config => { + const configured = Config.string("PAYWALL_PUBLIC_BASE_URL"); + return Option.match(Option.fromNullishOr(fallback), { + onNone: () => configured, + onSome: (value) => configured.pipe(Config.withDefault(value)), + }); +}; + +const workerEnvironment = (publicBaseUrl: string | undefined) => ({ + APNS_DELIVERY_ENABLED: Config.string("APNS_DELIVERY_ENABLED").pipe(Config.withDefault("false")), + ENCRYPTION_KEY: Config.redacted("ENCRYPTION_KEY").pipe(Config.withDefault(Redacted.make(""))), + EXCHANGE_RATE_API_KEY: Config.redacted("EXCHANGE_RATE_API_KEY").pipe( + Config.withDefault(Redacted.make("")), + ), + GOOGLE_PUBSUB_PUSH_AUDIENCE: Config.string("GOOGLE_PUBSUB_PUSH_AUDIENCE").pipe( + Config.withDefault(""), + ), + GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT_EMAIL: Config.string( + "GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT_EMAIL", + ).pipe(Config.withDefault("")), + PAYWALL_PUBLIC_BASE_URL: paywallPublicBaseUrl(publicBaseUrl), + PUSH_REQUIRE_ENCRYPTION: Config.string("PUSH_REQUIRE_ENCRYPTION").pipe( + Config.withDefault("true"), + ), + SLACK_BOT_TOKEN: Config.redacted("SLACK_BOT_TOKEN").pipe(Config.withDefault(Redacted.make(""))), + SLACK_FEEDBACK_CHANNEL_ID: Config.string("SLACK_FEEDBACK_CHANNEL_ID").pipe( + Config.withDefault(""), + ), + VOIDHASH_AUTH_SECRET: Config.redacted("VOIDHASH_AUTH_SECRET"), +}); + +/** + * Community backend Worker composed from the portable application services and + * Cloudflare platform adapters. + */ +export default Cloudflare.Worker( + "CommunityBackend", + Effect.gen(function* () { + const planContext = Option.getOrUndefined(yield* Effect.serviceOption(Alchemy.AlchemyContext)); + const dev = planContext?.dev === true; + const configuredDomain = yield* CommunityBackendDomain; + const domain: string | undefined = Match.value(dev).pipe( + Match.when(true, () => undefined), + Match.orElse(() => configuredDomain), + ); + const domainOption: Option.Option = Option.fromNullishOr(domain); + const publicBaseUrl = Option.match(domainOption, { + onNone: () => + Match.value(dev).pipe( + Match.when(true, () => "http://localhost:8787"), + Match.orElse(() => undefined), + ), + onSome: (value) => `https://${value}`, + }); + + return { + main: import.meta.filename, + domain, + workersDev: { + enabled: yield* CommunityWorkersDevEnabled, + previewsEnabled: false, + }, + compatibility: { date: "2026-03-17", flags: ["nodejs_compat"] }, + dev: { host: "0.0.0.0", port: 8787, strictPort: true }, + env: workerEnvironment(publicBaseUrl), + }; + }), + Effect.gen(function* () { + const planContext = Option.getOrUndefined(yield* Effect.serviceOption(Alchemy.AlchemyContext)); + const environment = Option.getOrUndefined( + yield* Effect.serviceOption(Cloudflare.WorkerEnvironment), + ); + const runtimeContext = yield* RuntimeContext; + const isDev = + planContext?.dev ?? (environment === undefined || !("DeliverWebhookWorkflow" in environment)); + + const authSecret = Redacted.value( + yield* Config.redacted("VOIDHASH_AUTH_SECRET").pipe(Effect.orDie), + ); + const authContext = yield* Layer.build(StandaloneAuthTokenVerifierLive(authSecret)); + const authTokenVerifier = Context.get(authContext, AuthTokenVerifier); + const dbConnection = yield* Cloudflare.Hyperdrive.Connect(DatabaseHyperdrive); + + const artifactStore = yield* makePaywallArtifactStoreLive(yield* PaywallArtifactsBucket); + const publicBaseUrl = yield* Config.string("PAYWALL_PUBLIC_BASE_URL").pipe( + Config.withDefault("http://localhost:8787"), + Effect.orDie, + ); + const publicFileStore = yield* makePublicFileStoreLive( + yield* PublicFileStorageBucket, + publicBaseUrl, + ); + + const workflowRunnerLayer = Match.value(isDev).pipe( + Match.when(true, () => MemoryWorkflowRunner.layer), + Match.orElse(() => CloudflareWorkflowRunner.layer), + ); + const workflowRunnerContext = yield* Layer.build( + workflowRunnerLayer.pipe(Layer.provide(Layer.succeed(RuntimeContext, runtimeContext))), + ); + const workflowRunner = Context.get(workflowRunnerContext, WorkflowRunner); + const workflowRuntime = Layer.mergeAll( + Layer.succeed(WorkflowRunner, workflowRunner), + Layer.succeed(PlatformRuntime, PlatformRuntime.of({})), + ); + + const workflowDb = HyperdriveDbLayer.make(dbConnection).pipe( + Layer.provide(Layer.succeed(RuntimeContext, runtimeContext)), + ); + const workflowEvents = AnalyticsEventStore.layer.pipe(Layer.provide(workflowDb)); + const workflowDispatch = AnalyticsDispatchService.layer.pipe(Layer.provide(workflowEvents)); + const workflowInfrastructure = Layer.mergeAll(workflowDb, workflowDispatch); + + yield* Effect.forEach( + backendWorkflows, + (registration) => registration.register(workflowInfrastructure), + { discard: true }, + ).pipe(Effect.provide(workflowRuntime), Effect.orDie); + + if (!isDev) { + yield* Effect.forEach( + backendWorkflows, + (registration) => { + if (registration.cron === undefined) return Effect.void; + return Cloudflare.cron(registration.cron.schedule, (controller) => + registration + .cron!.dispatch(DateTime.toDateUtc(DateTime.makeUnsafe(controller.scheduledTime))) + .pipe(Effect.provide(workflowRuntime)), + ); + }, + { discard: true }, + ); + } + + const identity = StandaloneIdentityProviderLive(authSecret); + const directory = StandaloneOrgDirectoryLive.pipe(Layer.provide(DbFromContextLive)); + const paywallAssets = Layer.succeed(PaywallAssetConfig, { + cdnUrl: publicBaseUrl, + publicBaseUrl, + }); + const infrastructure = Layer.mergeAll( + DbFromContextLive, + identity, + directory, + paywallAssets, + artifactStore, + publicFileStore, + BackendPaymentProviderStubsLive, + BackendNoopIdentityProjectionPublisherLive, + BackendMimicHostStubLive, + BackendComponentCompilerStubLive, + BackendSnapshotImageRendererStubLive, + ProjectSchemaCacheLive, + ); + + const requestInfrastructure = Layer.mergeAll( + workflowRuntime, + HyperdriveDbLayer.make(dbConnection), + ); + + const captureHandler = HttpApiBuilder.layer(EventCaptureApi, { + openapiPath: "/i/docs/openapi.json", + }).pipe( + Layer.provide(EventCaptureGroupLive), + Layer.provide(EventCaptureService.layer.pipe(Layer.provide(AnalyticsEventStore.layer))), + Layer.provide(HttpServer.layerServices), + HttpRouter.toHttpEffect, + Effect.flatMap((handler) => handler), + ); + + // Build scoped connections in the ambient request scope so a streaming + // response retains them until its body closes. + const captureFetch = Effect.gen(function* () { + const requestContext = yield* Layer.build(requestInfrastructure); + return yield* captureHandler.pipe(Effect.provide(requestContext)); + }); + + const backendFetch = Effect.gen(function* () { + const requestContext = yield* Layer.build(requestInfrastructure); + return yield* Effect.gen(function* () { + const handler = yield* buildBackendFetch({ + auth: RpcAuthLive(authTokenVerifier), + features: NoBackendFeatures, + infrastructure, + rpcExtension: NoBackendRpcExtension, + }); + return yield* handler; + }).pipe(Effect.provide(requestContext)); + }); + + const routedFetch = Effect.gen(function* () { + const request = yield* Cloudflare.Request; + const pathname = new URL(request.url).pathname; + if (pathname === "/i" || pathname.startsWith("/i/")) { + return yield* captureFetch; + } + return yield* backendFetch; + }); + + const fetch = routedFetch.pipe( + providePlatformRuntime, + Effect.provideService(RuntimeContext, runtimeContext), + Effect.catchCause((cause) => + Effect.logError(`Community backend request failed: ${Cause.pretty(cause)}`).pipe( + Effect.as(HttpServerResponse.text("Internal Server Error", { status: 500 })), + ), + ), + ); + + return { fetch }; + }).pipe( + Effect.provide( + Layer.mergeAll( + Cloudflare.CronEventSourceLive, + Cloudflare.Hyperdrive.ConnectBinding, + Cloudflare.R2.ReadWriteBucketBinding, + ), + ), + ), +); diff --git a/apps/backend/workers/WwwWorker.ts b/apps/backend/workers/WwwWorker.ts new file mode 100644 index 000000000..afde9b817 --- /dev/null +++ b/apps/backend/workers/WwwWorker.ts @@ -0,0 +1,64 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Match from "effect/Match"; +import * as Option from "effect/Option"; +import { fileURLToPath } from "node:url"; + +import { + CommunityWorkersDevEnabled, + CommunityWwwDomain, +} from "../infrastructure/DeploymentConfig.ts"; + +const wwwRootDir = fileURLToPath(new URL("../../../apps/www", import.meta.url)); + +export interface CommunityWebsiteConfig { + readonly apiUrl: Alchemy.Input; +} + +/** Deploys the Community TanStack application as an Alchemy-managed Worker. */ +export const CommunityWebsite = Effect.fnUntraced(function* (config: CommunityWebsiteConfig) { + const { stage } = yield* Alchemy.Stack; + const dev = Option.match(yield* Effect.serviceOption(Alchemy.AlchemyContext), { + onNone: () => false, + onSome: (context) => context.dev, + }); + const configuredDomain = yield* CommunityWwwDomain; + const domain: string | undefined = Match.value(dev).pipe( + Match.when(true, () => undefined), + Match.orElse(() => configuredDomain), + ); + const apiUrl = Match.value(dev).pipe( + Match.when(true, () => "http://localhost:8787"), + Match.orElse(() => config.apiUrl), + ); + const appEnvironment = Match.value(stage).pipe( + Match.when("production", () => "production"), + Match.when("preview", () => "preview"), + Match.orElse(() => "development"), + ); + + return yield* Cloudflare.Website.Vite("CommunityWww", { + rootDir: wwwRootDir, + domain, + workersDev: { + enabled: yield* CommunityWorkersDevEnabled, + previewsEnabled: false, + }, + compatibility: { date: "2026-03-17", flags: ["nodejs_compat"] }, + dev: { host: "0.0.0.0", port: 3000, strictPort: true }, + env: { + VITE_APP_API_URL: apiUrl, + VITE_APP_ENV: appEnvironment, + VOIDHASH_AUTH_SECRET: Config.redacted("VOIDHASH_AUTH_SECRET"), + VOIDHASH_ROOT_EMAIL: Config.string("VOIDHASH_ROOT_EMAIL").pipe( + Config.withDefault("root@voidhash.local"), + ), + VOIDHASH_ROOT_PASSWORD: Config.redacted("VOIDHASH_ROOT_PASSWORD"), + VOIDHASH_ROOT_USERNAME: Config.string("VOIDHASH_ROOT_USERNAME").pipe( + Config.withDefault("root"), + ), + }, + }); +}); diff --git a/apps/www/scripts/dev.mjs b/apps/www/scripts/dev.mjs index 4eb39af82..ef13bd21b 100644 --- a/apps/www/scripts/dev.mjs +++ b/apps/www/scripts/dev.mjs @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; // Vite only exposes `VITE_`-prefixed values, and only to `import.meta.env` — the // server routes read plain `process.env` (root credentials, auth secret), so the -// repo-root `.env` the self-host stack uses has to be loaded here too. Real +// repo-root `.env` used by the Alchemy stack has to be loaded here too. Real // environment variables win: Node's parser skips names that are already set, and // a deployment without the file keeps every documented default. const rootEnvFile = `${import.meta.dirname}/../../../.env`; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..6681aa705 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + standalone_postgres: + image: postgres:16 + environment: + POSTGRES_USER: voidhash + POSTGRES_PASSWORD: password + POSTGRES_DB: voidhash + ports: + - "5432:5432" + volumes: + - standalone_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U voidhash -d voidhash"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + standalone_postgres_data: diff --git a/docs/architecture.md b/docs/architecture.md index 0277beba5..ec25f95ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,25 +1,24 @@ # Voidhash architecture -Voidhash has one canonical Community codebase and two runtime compositions. -This repository contains every MIT and AGPL Community component. The private -cloud repository pins it as a submodule and adds Cloudflare infrastructure, -closed Enterprise packages, the Overwatch operations plane, and cloud-only -integration tests. Community source is never mirrored back into the private -repository. +Voidhash has one canonical Community codebase and one deployment composition. +This repository contains every MIT and AGPL Community component, including the +reusable Alchemy, Cloudflare, and Node platform adapters. Application services +depend on provider-neutral contracts and deployment composition stays at the +repository edge. ```mermaid flowchart TD Community["voidhash Community codebase
MIT SDKs + AGPL services"] Platform["@voidhash/platform
provider-neutral contracts"] - Node["Community self-host
Node + PostgreSQL + MinIO"] - Cloud["Managed Cloud
Cloudflare + PlanetScale adapters"] - Private["Private composition
Enterprise + Overwatch + deployment graph"] + Cloud["@voidhash/platform-cloudflare
Alchemy + Workers primitives"] + Deploy["apps/backend/stack.ts
Community composition"] + Node["@voidhash/platform-node
retained optional adapters"] Community --> Platform - Platform --> Node Platform --> Cloud - Community --> Private - Cloud --> Private + Platform -.-> Node + Cloud --> Deploy + Community --> Deploy ``` ## Community packages @@ -36,54 +35,35 @@ flowchart TD - `@voidhash/platform` defines provider-neutral Effect services and application primitives for durable entities, queues, workflows, scheduled jobs, key-value storage, object storage, screenshots, and mail. +- `@voidhash/platform-cloudflare` implements the reusable Cloudflare side of + those seams with Alchemy-native Workers, Queues, Workflows, Hyperdrive, and + Durable Object capabilities. - `packages/core`, `packages/db`, `packages/rpc`, and the remaining service packages own portable application and domain behavior. -- `@voidhash/platform-selfhost` implements those contracts for a single Node - deployment on PostgreSQL. Durable execution — queues, workflows, cron, and - durable entities — runs on Effect Cluster and Effect Workflow over that same - Postgres; the plain infrastructure adapters (object storage, mail, - screenshots) are direct clients. Entity WebSocket sessions are process-local - and therefore require the runner that owns the entity's shard, which the - single-runner topology guarantees. `apps/backend` composes the Community - application. +- `@voidhash/platform-node` retains Node implementations of the same contracts + for portability and conformance testing. It is not a supported deployment + composition. Runtime backends are selected per primitive, not per provider, so a deployment can move one primitive to a managed service without touching the others. Every adapter is validated against the shared conformance suite in `@voidhash/platform/conformance`. -The publication-boundary check rejects private package scopes, infrastructure -directories, Enterprise code, operations-plane code, and incomplete package -license metadata from this repository. +The publication-boundary check rejects non-Community package scopes and +incomplete package license metadata from this repository. -## Self-host composition +## Deployment composition -The self-host runtime is a modular monolith. One Node process serves the API -and dashboard and runs Mimic entities, queue consumers, workflows, and cron -fibers. PostgreSQL provides transactional state and durable scheduling; MinIO -provides S3-compatible objects; the compiler is isolated in a private-network -sidecar; Chromium renders paywall artifacts. PostgreSQL also stores the -portable Community analytics event log. Community authenticates a single root account from the -environment and needs no external identity service. +Cloudflare adapters live in this repository and deploy the same application +primitives through Alchemy. Product services continue to import +provider-neutral interfaces; only composition roots and +`@voidhash/platform-cloudflare` import Alchemy or Cloudflare APIs. -See [the self-hosting guide](../selfhost/README.md) for the supported Compose -path and operational requirements. +## Repository boundary -## Managed cloud composition - -The private repository can deploy the same application primitives to -Cloudflare through Alchemy, and owns deployment state, environments, -secrets, and cloud-only integration tests. Product services continue to import -provider-neutral interfaces; a zero-baseline seam check rejects new Cloudflare -or Alchemy imports from application code. - -## Enterprise and operations boundaries - -Enterprise packages and Overwatch are private. Enterprise features mount -through explicit Community extension points; the Community application boots -and passes its tests with the private packages absent. Staff authentication, -admin RPC groups, impersonation, support tooling, and license issuance exist -only in the private operations plane. +Commercial feature implementations and operations tooling are not included in +the Community repository. The Community application boots and passes its tests +using only the packages present here. ## Security boundaries diff --git a/docs/cloudflare-deployment.md b/docs/cloudflare-deployment.md new file mode 100644 index 000000000..65dfae629 --- /dev/null +++ b/docs/cloudflare-deployment.md @@ -0,0 +1,56 @@ +# Cloudflare deployment + +The Community composition in `apps/backend/stack.ts` uses Alchemy to deploy the +backend and web application to Cloudflare Workers. Hyperdrive fronts +PostgreSQL, R2 stores public files and paywall artifacts, and Cloudflare +Workflows run the application workflow registry. Cloudflare-specific adapters +live in `packages/platform/cloudflare`. + +## Local development + +Start PostgreSQL, apply the Community migrations, and run Alchemy: + +```sh +cp .env.example .env +docker compose up -d standalone_postgres +pnpm db:migrate +pnpm dev +``` + +The backend listens on `http://localhost:8787` and the web application on +`http://localhost:3000`. Alchemy watches the stack and updates both local +Workers as their source changes. + +## Deployment + +A live deployment needs Cloudflare credentials plus a PostgreSQL origin that +Cloudflare Hyperdrive can reach. Copy `.env.example` to `.env`, then configure: + +- `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` for the target account. +- `VOIDHASH_BACKEND_DOMAIN` and `VOIDHASH_WWW_DOMAIN` with hostnames whose + Cloudflare zones already exist in that account. +- `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, and + `DATABASE_PASSWORD` for the PostgreSQL origin. +- Production values for the root account and session-signing settings. + +Run migrations against the configured origin, then deploy: + +```sh +pnpm db:migrate +pnpm deploy -- --stage production +``` + +`PAYWALL_PUBLIC_BASE_URL` defaults to `https://`. Set +it explicitly only when the backend is deployed without a custom domain. +`VOIDHASH_WORKERS_DEV_ENABLED` controls whether both Workers also retain their +`workers.dev` URLs. + +The Community composition uses the fixed `VoidhashCommunity` Alchemy stack +name. Alchemy includes the stack and stage in deployment state and generated +resource names, so it can coexist with other stacks in the same Cloudflare +account. Do not reuse that stack name for an unrelated installation in the same +account. + +Alchemy owns the Workers, custom-domain attachments, Hyperdrive configuration, +R2 buckets, workflow registrations, and deployment state; it does not own the +Cloudflare zones or PostgreSQL origin. diff --git a/docs/launch-announcement-draft.md b/docs/launch-announcement-draft.md index 6ab4442a6..2054fc437 100644 --- a/docs/launch-announcement-draft.md +++ b/docs/launch-announcement-draft.md @@ -6,27 +6,27 @@ Today we are publishing the complete Voidhash Community platform: the mobile and web SDKs, paywall designer and renderer, backend, purchase integrations, -analytics pipeline, Mimic collaboration engine, and a self-hosted Docker -composition. +analytics pipeline, Mimic collaboration engine, and an Alchemy composition for +deployment to your own Cloudflare account. The SDK and integration surface is MIT licensed. The service platform and -self-host runtime are AGPL-3.0-only, which allows commercial self-hosting while +deployment adapters are AGPL-3.0-only, which allows commercial self-hosting while requiring operators of modified network services to follow the AGPL's source availability terms. Closed Enterprise features and our internal operations and deployment systems are not part of the Community repository. -The self-host composition runs the same application services as Voidhash Cloud -through provider-neutral platform contracts. It uses Node, PostgreSQL, MinIO, -an isolated component compiler, Chromium, and SMTP. PostgreSQL also stores the -Community analytics event log. +The Community composition runs through provider-neutral platform contracts. It +uses Cloudflare Workers, Hyperdrive, R2, Queues, Durable Objects, and Workflows +through reusable Alchemy adapters. +PostgreSQL stores the Community application and analytics data. Community signs in with a root account you configure in the environment and uses your own provider credentials. Cloud remains the zero-operations path; pricing is not being announced with this release. We assembled and tested the complete repository privately before publication, including tenant-boundary tests, provider-signature and replay tests, secret -and dependency scanning, clean Compose builds, release-level self-host smokes, -and a real cloud deployment. The security policy and threat model are included +and dependency scanning, Alchemy plan and local-worker checks, and a real cloud +deployment. The security policy and threat model are included in the repository, and vulnerabilities can be reported privately to security@voidhash.com. @@ -36,7 +36,7 @@ acceptance workflow. Issues and responsible security reports are welcome. Suggested launch links: - Repository: https://github.com/voidhashcom/voidhash -- Self-hosting guide: `selfhost/README.md` +- Cloudflare deployment guide: `docs/cloudflare-deployment.md` - Architecture: `docs/architecture.md` - Licensing FAQ: `docs/licensing-and-self-hosting-faq.md` - Security policy: `SECURITY.md` diff --git a/docs/licensing-and-self-hosting-faq.md b/docs/licensing-and-self-hosting-faq.md index 90327d55f..f586ee574 100644 --- a/docs/licensing-and-self-hosting-faq.md +++ b/docs/licensing-and-self-hosting-faq.md @@ -16,7 +16,7 @@ MIT text. ## Which code is AGPL-3.0-only? The backend, dashboard, Mimic services and tooling, service packages, paywall -build/render pipeline, and self-host runtime are AGPL-3.0-only. Operators may +build/render pipeline, and deployment adapters are AGPL-3.0-only. Operators may modify and self-host that code, including commercially, subject to the AGPL's terms. In particular, the AGPL contains source-availability obligations for modified versions used to provide network services. @@ -30,17 +30,17 @@ from the repository name alone. ## Is Enterprise code included? -No. Closed Enterprise implementation remains in the private cloud repository. -It composes over explicit Community extension points and is not copied into -this repository or the Community image. Any separately distributed Enterprise -Software is governed only by terms that expressly identify it. +No. Commercial implementation is not included in this repository. Any +separately distributed Enterprise Software is governed only by terms that +expressly identify it. ## Is self-hosting production supported today? Not yet. The repository is in private alpha and the latest `main` branch is -the only security-maintained line. The supported path for evaluation is the -documented Docker Compose configuration. A production support matrix and -version table will replace this answer before the first public release. +the only security-maintained line. The supported evaluation path deploys the +Community Alchemy composition to the operator's Cloudflare account and connects +it to operator-managed PostgreSQL. A production support matrix and version +table will replace this answer before the first public release. ## How does authentication work, and why only one user? diff --git a/docs/security/backend-threat-model.md b/docs/security/backend-threat-model.md index 135a6ecee..5e28db584 100644 --- a/docs/security/backend-threat-model.md +++ b/docs/security/backend-threat-model.md @@ -1,9 +1,9 @@ # Backend Threat Model Status: alpha review draft -Last updated: 2026-07-12
+Last updated: 2026-08-10
Scope: `packages/backend`, `apps/mimic-db`, `apps/www`, `packages/core`, and -`selfhost` +`apps/backend` This document records the security analysis required before the repository can be made public. It describes current controls and known gaps; it is not a claim @@ -24,7 +24,7 @@ review required by the publication plan have not happened yet. - Malformed or excessive input fails within bounded memory, time, and retry budgets. -Availability of the managed service against volumetric denial of service is an +Availability of a deployed service against volumetric denial of service is an operational objective, but not a guarantee made by the Community Edition. ## Assets and actors @@ -37,7 +37,7 @@ artifacts, object-store credentials, and compiler/container integrity. Relevant actors are anonymous internet clients, SDK clients holding a publishable key, users holding a dashboard session or user API key, server integrations holding a project secret key, tenant administrators, payment and -identity providers, self-host operators, and a malicious authenticated tenant +identity providers, deployment operators, and a malicious authenticated tenant submitting component source. ## Trust boundaries @@ -47,16 +47,14 @@ submitting component source. 3. Tenant-scoped services to PostgreSQL adapters. 4. Provider webhook ingress to provider verification and idempotent ledgers. 5. Backend to queues, workflows, object stores, SMTP, and screenshot services. -6. Backend to the component compiler container/sidecar. +6. Backend to an enabled component compiler boundary. 7. Public, content-addressed artifact serving to browsers and SDKs. -8. Self-host operator configuration to the Compose network and persistent - stores. +8. Deployment configuration to Cloudflare resources and the PostgreSQL origin. -The cloud and self-host compositions use the same application services. Their -infrastructure boundaries differ: Cloudflare Workers, Durable Objects, Queues, -Workflows, R2, Hyperdrive, and an isolated compiler container in cloud; a Node -process, PostgreSQL-backed primitives, S3-compatible storage, and a separate -compiler sidecar in self-host. +The Community composition deploys application services through Alchemy using +Cloudflare Workers, Durable Objects, Queues, Workflows, R2, and Hyperdrive. The +optional Node adapters and Compose services under `test/integration` are test +fixtures, not a supported production boundary. ## Authentication and sessions @@ -119,8 +117,8 @@ Trust rests on the root password and on transport security, so the controls are: The documented evaluation defaults (`root` / `voidhash` and the shared signing secret) are public knowledge and reachable only under -`SELFHOST_MODE=local-evaluation`, which the self-hosting guide restricts to -loopback. +`SELFHOST_MODE=local-evaluation`, which is reserved for the loopback-only Node +integration fixture. ## API keys and credential storage @@ -235,7 +233,7 @@ Current controls: Residual work: verify bucket IAM, overwrite policy, maximum object sizes, SVG handling, cache poisoning resistance, and browser behavior for every served -content type in both cloud and self-host deployments. +content type in the Community deployment. ## Analytics ingest @@ -271,15 +269,14 @@ Current controls: - Rendering and screenshot capabilities are platform ports, keeping privileged infrastructure adapters outside tenant application code. - Paywall manifests and preview trees are schema-validated before release. -- The runtime runs as an unprivileged user in the self-host image. -- Self-host Chromium disables JavaScript, blocks service workers, switches each - fresh context offline, and aborts every document/resource request before - setting inline HTML. The Cloudflare Browser Run request rejects every external - request pattern. Because no outbound navigation is permitted, redirects and - DNS rebinding cannot reach private or link-local services. -- Both screenshot adapters cap HTML at 4 MiB, viewport edges at 4,096 pixels, +- The test-only Node Chromium adapter disables JavaScript, blocks service + workers, switches each fresh context offline, and aborts every + document/resource request before setting inline HTML. Because no outbound + navigation is permitted, redirects and DNS rebinding cannot reach private or + link-local services. +- Screenshot adapters cap HTML at 4 MiB, viewport edges at 4,096 pixels, scale at 4, and the rendered output at 16,777,216 pixels. Browser operations - have a 15-second timeout and self-host thumbnail consumption is serialized + have a 15-second timeout and Node thumbnail consumption is serialized with a bounded retry count. Budget unit tests cover normal and oversized inputs. The real-Chromium test @@ -300,12 +297,13 @@ Current controls: - Module evaluation runs in a VM context with string/Wasm code generation disabled and a 500 ms synchronous execution budget. - Request bodies are capped at 1 MiB and compiler concurrency is limited. -- Self-host runs the compiler as an unprivileged user with a read-only root - filesystem, dropped Linux capabilities, `no-new-privileges`, a PID limit, and - a private internal Docker network shared only with the application. It does - not receive database, object-store, identity, or payment credentials. -- Cloud invokes a dedicated container through a Durable Object boundary and - bounds the caller's compile round trip. +- The Node integration fixture runs the compiler as an unprivileged user with a + read-only root filesystem, dropped Linux capabilities, + `no-new-privileges`, a PID limit, and a private internal Docker network shared + only with the application. It does not receive database, object-store, + identity, or payment credentials. +- Production compositions that enable compilation must supply and review their + own isolated implementation of the compiler port. Residual work: independently test container escape resistance, host-object VM escape attempts, memory bombs, asynchronous work, file reads, internal-service @@ -313,39 +311,33 @@ SSRF, crash/restart behavior, and concurrent denial of service. Apply explicit memory/CPU quotas in each production deployment and keep the compiler image and Node runtime patched. -## Self-host operator boundary +## Deployment operator boundary -The sample Compose defaults are for loopback evaluation only. They include -known passwords and the documented default root credentials. Compose explicitly -marks the no-env quick start as `local-evaluation`; exposing that composition -unchanged would compromise all stored data. +The sample environment values are for loopback evaluation only. They include +known passwords and the documented default root credentials; using them in a +live Cloudflare stage would compromise all stored data. Before any non-local deployment, the operator must replace every example -password, set real root credentials and a real session signing secret, -configure HTTPS at the reverse proxy, restrict MinIO and Mailpit host -ports, configure CORS and public URLs, use real SMTP credentials, back up -persistent volumes, and apply host/container updates. - -Production mode validates configuration before migrations or the application -start. It refuses missing and known example root credentials, session signing -secret, database, object-store, and Mimic credentials, and -requires HTTPS for every public, file, and Mimic URL. Tests cover explicit mode -selection, every credential class, and every URL -boundary. Independent -review must still confirm the list remains complete as new infrastructure is -added. +password, set real root credentials and a real session signing secret, restrict +the PostgreSQL origin to Hyperdrive, configure public URLs and CORS, back up the +database, and review Cloudflare account access and resource policies. + +Alchemy validates required configuration while planning Workers and bindings. +Application tests cover credential and URL validation used by the optional Node +fixture. Independent review must still confirm the live-stage configuration +remains complete as new infrastructure is added. ## Publication risk register -| ID | Severity | Status | Required evidence | -| --- | --- | --- | --- | -| VH-TM-001 | High | Mitigated, review pending | Pub/Sub OIDC negative tests and payment-ledger duplicate-delivery tests pass; deployment settings and implementation require independent review. | -| VH-TM-002 | High | Mitigated, review pending | Production startup refuses known example credentials and insecure public URLs; configuration coverage requires independent review. | -| VH-TM-003 | High | Mitigated, review pending | Compiler VM budget and container/network hardening pass adversarial and independent review. | -| VH-TM-004 | High | Mitigated, review pending | Cloud and self-host browsers deny outbound requests and enforce time/input/output budgets; real-browser redirect/private-network coverage requires independent review. | -| VH-TM-005 | High | Mitigated, review pending | Machine-checked endpoint matrix is complete; database-backed negatives cover every tenant-selectable service group, including persisted chat-ID collisions and raw paywall IDs. | -| VH-TM-006 | Process gate | Open | Real beta traffic and security-log/incident review completed. | -| VH-TM-007 | Process gate | Open | Independent reviewer signs off and residual risks have owners/deadlines. | +| ID | Severity | Status | Required evidence | +| --------- | ------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| VH-TM-001 | High | Mitigated, review pending | Pub/Sub OIDC negative tests and payment-ledger duplicate-delivery tests pass; deployment settings and implementation require independent review. | +| VH-TM-002 | High | Mitigated, review pending | Live-stage credentials, public URLs, Hyperdrive origin policy, and configuration coverage require independent review. | +| VH-TM-003 | High | Mitigated, review pending | Compiler VM budget and container/network hardening pass adversarial and independent review. | +| VH-TM-004 | High | Mitigated, review pending | Enabled browser adapters deny outbound requests and enforce time/input/output budgets; real-browser redirect/private-network coverage requires independent review. | +| VH-TM-005 | High | Mitigated, review pending | Machine-checked endpoint matrix is complete; database-backed negatives cover every tenant-selectable service group, including persisted chat-ID collisions and raw paywall IDs. | +| VH-TM-006 | Process gate | Open | Real beta traffic and security-log/incident review completed. | +| VH-TM-007 | Process gate | Open | Independent reviewer signs off and residual risks have owners/deadlines. | Repository visibility must not change while a High item is open. Accepted residual risk must be recorded with an owner, deadline, and rationale in this diff --git a/package.json b/package.json index 0fbdd2661..7b31caac2 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,10 @@ "workspaces": { "packages": [ "packages/*", + "packages/platform/*", "apps/*", "libraries/*", - "examples/*", - "selfhost/*" + "examples/*" ], "catalogs": { "react18": { @@ -36,7 +36,9 @@ "type": "module", "scripts": { "build": "turbo build", - "dev": "node scripts/check-dev-runtime.mjs && portless prune && node scripts/check-dev-ports.mjs && turbo dev dev:server", + "dev": "pnpm alchemy dev", + "deploy": "pnpm alchemy deploy", + "destroy": "pnpm alchemy destroy", "dev:doctor": "portless doctor", "dev:status": "portless list", "clean": "turbo clean && rm -rf node_modules", @@ -52,16 +54,15 @@ "sync:plugins:check": "tsx ./scripts/sync-plugins.ts --check", "check:publication": "node ./scripts/check-publication-boundary.mjs", "check:platform-seam": "node ./scripts/check-platform-seam.mjs", - "check:selfhost-runtime": "node ./scripts/check-selfhost-runtime-boundary.mjs", "check:test-tiers": "node ./scripts/check-test-tiers.mjs", - "stack:up": "SELFHOST_MODE=local-evaluation docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env --project-directory selfhost up -d --build", - "stack:down": "SELFHOST_MODE=local-evaluation docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env --project-directory selfhost down", + "test:infra:up": "SELFHOST_MODE=local-evaluation docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --env-file .env --project-directory test/integration up -d --build", + "test:infra:down": "SELFHOST_MODE=local-evaluation docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --env-file .env --project-directory test/integration down", "verify": "pnpm verify:quick && pnpm test:integration && pnpm test:e2e && pnpm test:e2e:release", - "verify:quick": "pnpm check:publication && pnpm check:platform-seam && pnpm check:selfhost-runtime && pnpm check:test-tiers && pnpm lint && pnpm typecheck && pnpm test", + "verify:quick": "pnpm check:publication && pnpm check:platform-seam && pnpm check:test-tiers && pnpm lint && pnpm typecheck && pnpm test", "test": "turbo test", "test:integration": "node ./scripts/run-local-integration.mjs", - "test:e2e": "tsx selfhost/smoke.mts", - "test:e2e:release": "tsx selfhost/release-smoke.mts", + "test:e2e": "tsx test/integration/smoke.mts", + "test:e2e:release": "tsx test/integration/release-smoke.mts", "test:purchase-restore": "pnpm --filter @voidhash/paywalls build && pnpm --filter @voidhash/react-native specs && pnpm --filter @voidhash/react-native typecheck && pnpm --filter @voidhash/react-native test && pnpm --filter @voidhash/react-native test:android-purchase-coordinator && pnpm test:android-native-compile && pnpm --filter @voidhash/generated-clients typecheck && pnpm --filter @voidhash/api-contracts typecheck && pnpm --filter @voidhash/backend typecheck && pnpm --filter @voidhash/backend test", "test:android-native-compile": "pnpm --filter @voidhash/react-native-voidhash-example exec expo prebuild --platform android --no-install && examples/react-native-example/android/gradlew -p examples/react-native-example/android :voidhash_react-native:compileDebugKotlin --no-daemon", "typecheck": "turbo typecheck", @@ -84,6 +85,7 @@ }, "devDependencies": { "@typescript/native-preview": "7.0.0-dev.20260302.1", + "alchemy": "catalog:", "dotenv-cli": "^8.0.0", "oxlint-plugin-effect": "^0.6.0", "portless": "0.15.5", @@ -92,6 +94,7 @@ "tslib": "2.8.1", "tsx": "^4.19.3", "vite-plus": "catalog:", + "wrangler": "^4.0.0", "zustand": "^5.0.9" }, "resolutions": { diff --git a/packages/agent/package.json b/packages/agent/package.json index bfeb838cd..786b11c3a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -38,7 +38,7 @@ }, "devDependencies": { "@effect/platform-node": "catalog:", - "@voidhash/platform-selfhost": "workspace:*", + "@voidhash/platform-node": "workspace:*", "@voidhash/tsconfig": "workspace:*", "typescript": "catalog:", "vite-plus": "catalog:", diff --git a/packages/agent/tests/AgentSessionCluster.integration.test.ts b/packages/agent/tests/AgentSessionCluster.integration.test.ts index c48b4c710..aa3eafb27 100644 --- a/packages/agent/tests/AgentSessionCluster.integration.test.ts +++ b/packages/agent/tests/AgentSessionCluster.integration.test.ts @@ -5,13 +5,10 @@ import { type Model, } from "@earendil-works/pi-ai"; import { NodeCrypto } from "@effect/platform-node"; -import { - DurableEntityAlarmControl, - DurableEntityHost, -} from "@voidhash/platform/DurableEntity"; -import { PgClusterDurableEntityLive } from "@voidhash/platform-selfhost/ClusterDurableEntity"; -import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/NodeDurableEntitySession"; -import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; +import { DurableEntityAlarmControl, DurableEntityHost } from "@voidhash/platform/DurableEntity"; +import { PgClusterDurableEntityLive } from "@voidhash/platform-node/ClusterDurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; +import type { PgPlatformConfig } from "@voidhash/platform-node/Postgres"; import { Clock, Config, Crypto, Effect, ManagedRuntime, Redacted, Schema } from "effect"; import { describe, expect, it } from "vitest"; @@ -27,15 +24,15 @@ const encodeClientMessage = Schema.encodeSync(Schema.fromJsonString(AgentClientM const loadConfig: Effect.Effect = Effect.gen(function* () { return { - host: yield* Config.string("PLATFORM_SELFHOST_PG_HOST").pipe(Config.withDefault("127.0.0.1")), - port: yield* Config.int("PLATFORM_SELFHOST_PG_PORT").pipe(Config.withDefault(5432)), - database: yield* Config.string("PLATFORM_SELFHOST_PG_DATABASE").pipe( + host: yield* Config.string("PLATFORM_NODE_PG_HOST").pipe(Config.withDefault("127.0.0.1")), + port: yield* Config.int("PLATFORM_NODE_PG_PORT").pipe(Config.withDefault(5432)), + database: yield* Config.string("PLATFORM_NODE_PG_DATABASE").pipe( Config.withDefault("voidhash"), ), - username: yield* Config.string("PLATFORM_SELFHOST_PG_USERNAME").pipe( + username: yield* Config.string("PLATFORM_NODE_PG_USERNAME").pipe( Config.withDefault("voidhash"), ), - password: yield* Config.redacted("PLATFORM_SELFHOST_PG_PASSWORD").pipe( + password: yield* Config.redacted("PLATFORM_NODE_PG_PASSWORD").pipe( Config.withDefault(Redacted.make("password")), ), }; diff --git a/packages/agent/tests/AgentSessionCore.test.ts b/packages/agent/tests/AgentSessionCore.test.ts index 443e0b508..7d70ccbe3 100644 --- a/packages/agent/tests/AgentSessionCore.test.ts +++ b/packages/agent/tests/AgentSessionCore.test.ts @@ -5,8 +5,8 @@ import { type Context as PiContext, type Model, } from "@earendil-works/pi-ai"; -import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; -import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/NodeDurableEntitySession"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; import { Clock, Deferred, Effect, Schema } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/packages/agent/tests/SessionLog.test.ts b/packages/agent/tests/SessionLog.test.ts index e07e87135..7b0f55494 100644 --- a/packages/agent/tests/SessionLog.test.ts +++ b/packages/agent/tests/SessionLog.test.ts @@ -1,4 +1,4 @@ -import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; import { Effect, Schema } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/packages/agent/vitest.integration.mts b/packages/agent/vitest.integration.mts index b2228511b..3a87d4886 100644 --- a/packages/agent/vitest.integration.mts +++ b/packages/agent/vitest.integration.mts @@ -1,6 +1,6 @@ import { defineConfig } from "vite-plus"; -// Integration tier: runs against the provisioned self-host stack via +// Integration tier: runs against the provisioned Node test fixture via // `pnpm test:integration`. Timeouts are generous because these tests wait on // real containers rather than fakes. export default defineConfig({ diff --git a/packages/backend/vitest.integration.mts b/packages/backend/vitest.integration.mts index be239ff1a..df93c97a9 100644 --- a/packages/backend/vitest.integration.mts +++ b/packages/backend/vitest.integration.mts @@ -1,7 +1,7 @@ import { defineConfig } from "vite-plus"; // Backend RPC + webhook smoke against a provisioned environment. Locally the -// self-host stack supplies it via the shared core globalSetup; downstream +// Node test fixture supplies it via the shared core globalSetup; downstream // compositions substitute their own globalSetup providing the same // `coreStackOutput` contract. export default defineConfig({ diff --git a/packages/core/test/_testing/CoreIntegrationTestHarness.ts b/packages/core/test/_testing/CoreIntegrationTestHarness.ts index 98731b00f..bded40a66 100644 --- a/packages/core/test/_testing/CoreIntegrationTestHarness.ts +++ b/packages/core/test/_testing/CoreIntegrationTestHarness.ts @@ -126,11 +126,7 @@ const AuditLogPortTestLive: Layer.Layer = Layer.effect( */ const makeHarnessLayer = (tc: CoreTestConnections): Layer.Layer => { const DbLive: Layer.Layer = Db.layer(tc.db); - const InfraLayer = Layer.mergeAll( - DbLive, - ProjectSchemaCacheStubLive, - PublicFileStoreStubLive, - ); + const InfraLayer = Layer.mergeAll(DbLive, ProjectSchemaCacheStubLive, PublicFileStoreStubLive); const AuditLogSupportLayer = AuditLogPortTestLive.pipe(Layer.provide(InfraLayer)); @@ -178,8 +174,8 @@ export const CoreIntegrationTestHarness = { * ``` * * The environment is provisioned once per run by the active composition's - * `globalSetup` (locally: `test/_testing/globalSetup.ts` over the self-host - * stack) and shared through vitest's `provide`/`inject` channel. + * `globalSetup` (locally: `test/_testing/globalSetup.ts` over the Node test + * fixture) and shared through vitest's `provide`/`inject` channel. */ make: () => { // Resolved lazily inside each test/effect: vitest's injected context is set diff --git a/packages/core/test/_testing/CoreTestConnections.ts b/packages/core/test/_testing/CoreTestConnections.ts index 8ffaf83fa..dbfbb77d1 100644 --- a/packages/core/test/_testing/CoreTestConnections.ts +++ b/packages/core/test/_testing/CoreTestConnections.ts @@ -2,9 +2,9 @@ * The complete environment contract for the core integration suite. * * This is the seam between the open-core tests and whatever composition runs - * them: the Community repo's `globalSetup` derives these values from the local - * self-host stack's environment, while downstream compositions (the managed - * cloud) provision their own infrastructure and inject the same shape. Tests + * them: the Community `globalSetup` derives these values from the local Node + * test fixture, while downstream compositions provision their own + * infrastructure and inject the same shape. Tests * never know which composition produced it. */ export interface CoreTestConnections { @@ -19,8 +19,8 @@ export interface CoreTestConnections { /** * The once-per-run output a composition's `globalSetup` shares with every test - * file. Compositions may inject a structural superset (the managed cloud adds - * deploy artifacts such as URLs); the suite only relies on this shape. + * file. Compositions may inject a structural superset; the suite only relies + * on this shape. */ export interface CoreStackOutput { readonly testConnections: CoreTestConnections | null; @@ -28,8 +28,8 @@ export interface CoreStackOutput { /** * Builds the contract from environment variables, matching the names the - * self-host stack (repo-root `.env`) and `scripts/run-local-integration.mjs` - * already use. Defaults target the local docker-compose dev stack. + * Node test fixture (repo-root `.env`) and `scripts/run-local-integration.mjs` + * already use. Defaults target the local Compose fixture. */ export const coreTestConnectionsFromEnv = ( // oxlint-disable-next-line effect/noGlobals -- synchronous config adapter: the default argument is evaluated at call sites that run before any Effect runtime exists (vitest globalSetup and the local integration runner). diff --git a/packages/core/test/_testing/globalSetup.ts b/packages/core/test/_testing/globalSetup.ts index 94d6b2a80..20aa51835 100644 --- a/packages/core/test/_testing/globalSetup.ts +++ b/packages/core/test/_testing/globalSetup.ts @@ -1,15 +1,12 @@ import { Db } from "@voidhash/db"; import * as Effect from "effect/Effect"; -import { - coreTestConnectionsFromEnv, - type CoreStackOutput, -} from "./CoreTestConnections.ts"; +import { coreTestConnectionsFromEnv, type CoreStackOutput } from "./CoreTestConnections.ts"; import { cleanupFixture, seedFixture } from "./CoreTestSeed.ts"; /** * Community composition of the core integration environment: the local - * self-host stack. Connections are derived from the environment (see + * Node test fixture. Connections are derived from the environment (see * the repo-root `.env.example` and `scripts/run-local-integration.mjs`), the shared * fixture is seeded, and the contract is shared with every test file via * vitest's `provide`/`inject`. @@ -34,8 +31,8 @@ export default function setup({ Effect.catchCause((cause) => Effect.die( new Error( - "Core integration setup could not seed the fixture. Is the self-host stack running? " + - "Start it with `pnpm stack:up` (see selfhost/README.md) or point DATABASE_* at a migrated database.", + "Core integration setup could not seed the fixture. Is the integration fixture running? " + + "Start it with `pnpm test:infra:up` or point DATABASE_* at a migrated database.", { cause }, ), ), diff --git a/packages/core/vitest.integration.mts b/packages/core/vitest.integration.mts index 331d18572..16a0e69cc 100644 --- a/packages/core/vitest.integration.mts +++ b/packages/core/vitest.integration.mts @@ -1,7 +1,7 @@ import { defineConfig } from "vite-plus"; // The integration suite runs against a provisioned environment: locally the -// self-host stack (`pnpm test:integration`), downstream whatever the +// Node test fixture (`pnpm test:integration`), downstream whatever the // composition's globalSetup provides. Files run sequentially — they share one // database and one seeded fixture container. // diff --git a/packages/db/vitest.integration.mts b/packages/db/vitest.integration.mts index b2228511b..3a87d4886 100644 --- a/packages/db/vitest.integration.mts +++ b/packages/db/vitest.integration.mts @@ -1,6 +1,6 @@ import { defineConfig } from "vite-plus"; -// Integration tier: runs against the provisioned self-host stack via +// Integration tier: runs against the provisioned Node test fixture via // `pnpm test:integration`. Timeouts are generous because these tests wait on // real containers rather than fakes. export default defineConfig({ diff --git a/packages/platform/cloudflare/package.json b/packages/platform/cloudflare/package.json new file mode 100644 index 000000000..42ba36eb1 --- /dev/null +++ b/packages/platform/cloudflare/package.json @@ -0,0 +1,34 @@ +{ + "name": "@voidhash/platform-cloudflare", + "version": "0.0.1-alpha.1", + "private": true, + "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "packages/platform/cloudflare" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./DurableEntity": "./src/DurableEntity.ts", + "./HyperdriveDb": "./src/HyperdriveDb.ts", + "./PlatformRuntime": "./src/PlatformRuntime.ts", + "./Queue": "./src/Queue.ts", + "./QueueConsumer": "./src/QueueConsumer.ts", + "./WorkflowRunner": "./src/WorkflowRunner.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@voidhash/db": "workspace:*", + "@voidhash/platform": "workspace:*", + "alchemy": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@voidhash/tsconfig": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/packages/platform/cloudflare/src/DurableEntity.ts b/packages/platform/cloudflare/src/DurableEntity.ts new file mode 100644 index 000000000..518471173 --- /dev/null +++ b/packages/platform/cloudflare/src/DurableEntity.ts @@ -0,0 +1,88 @@ +import type { + DurableEntityAddress, + DurableEntityAlarm, + DurableEntityHostShape, + DurableEntityKeyValue, + DurableEntitySession, +} from "@voidhash/platform/DurableEntity"; +import type * as Cloudflare from "alchemy/Cloudflare"; +import { RuntimeContext } from "alchemy/RuntimeContext"; +import { Effect, Semaphore } from "effect"; + +/** First-party storage capabilities backed by one Durable Object instance. */ +export interface CloudflareDurableEntityStorage { + readonly keyValue: DurableEntityKeyValue; + readonly alarm: DurableEntityAlarm; +} + +/** Adapts Durable Object KV and alarm storage to the first-party entity contract. */ +export const makeCloudflareDurableEntityStorage = ( + storage: Cloudflare.DurableObjectStorage, + runtimeContext: RuntimeContext["Service"], +): CloudflareDurableEntityStorage => ({ + keyValue: { + get: (key) => + storage.get(key).pipe(Effect.provideService(RuntimeContext, runtimeContext)), + put: (key, value) => + storage.put(key, value).pipe(Effect.provideService(RuntimeContext, runtimeContext)), + delete: (key) => + storage.delete(key).pipe(Effect.provideService(RuntimeContext, runtimeContext)), + }, + alarm: { + get: storage.getAlarm().pipe( + Effect.map((scheduledTime) => scheduledTime ?? undefined), + Effect.provideService(RuntimeContext, runtimeContext), + ), + set: (scheduledTime) => + storage.setAlarm(scheduledTime).pipe(Effect.provideService(RuntimeContext, runtimeContext)), + delete: storage.deleteAlarm().pipe(Effect.provideService(RuntimeContext, runtimeContext)), + }, +}); + +/** Adapts a hibernatable Cloudflare socket to the portable entity session contract. */ +export const makeCloudflareDurableEntitySession = ( + id: string, + socket: Cloudflare.WebSocket, +): DurableEntitySession => ({ + id, + send: (message) => socket.send(message), + close: (code = 1000, reason = "") => socket.close(code, reason), + getAttachment: Effect.sync(() => socket.deserializeAttachment() ?? undefined), + setAttachment: (attachment) => Effect.sync(() => socket.serializeAttachment(attachment)), +}); + +/** Creates a serialized portable entity host over one Durable Object instance. */ +export const makeCloudflareDurableEntityHost = ( + state: Cloudflare.DurableObjectState["Service"], + runtimeContext: RuntimeContext["Service"], + localAddress: DurableEntityAddress, + sessions: Map, +): DurableEntityHostShape => { + const lock = Semaphore.makeUnsafe(1); + const storage = makeCloudflareDurableEntityStorage(state.storage, runtimeContext); + return { + run: (address, operation) => { + if (address.type !== localAddress.type || address.id !== localAddress.id) { + return Effect.die( + new Error( + `Durable Object ${localAddress.type}/${localAddress.id} cannot host ${address.type}/${address.id}`, + ), + ); + } + return lock.withPermit( + Effect.suspend(() => + operation({ + address: localAddress, + ...storage, + sessions: { + get: (id) => Effect.sync(() => sessions.get(id)), + list: Effect.sync(() => [...sessions.values()]), + attach: (session) => Effect.sync(() => void sessions.set(session.id, session)), + remove: (id) => Effect.sync(() => void sessions.delete(id)), + }, + }), + ), + ); + }, + }; +}; diff --git a/packages/platform/cloudflare/src/HyperdriveDb.ts b/packages/platform/cloudflare/src/HyperdriveDb.ts new file mode 100644 index 000000000..a43aa8568 --- /dev/null +++ b/packages/platform/cloudflare/src/HyperdriveDb.ts @@ -0,0 +1,56 @@ +import type * as Cloudflare from "alchemy/Cloudflare"; +import type { RuntimeContext } from "alchemy/RuntimeContext"; +import { Db } from "@voidhash/db"; +import { Effect, Layer, Redacted } from "effect"; + +const isRuntimeHyperdriveHost = (host: string): boolean => + host.trim().toLowerCase().endsWith(".hyperdrive.local"); + +/** + * For layer graphs that must expose {@link Db} while deferring the concrete + * runtime implementation to an enclosing + * `Effect.provide(HyperdriveDbLayer.make(conn))`. + */ +export const DbFromContextLive: Layer.Layer = Layer.effect(Db)(Db); + +/** + * Build a {@link Db} layer from a bound Cloudflare Hyperdrive connection. + * Hyperdrive credentials carry Alchemy's runtime-phase marker, so this effect + * can only be run inside Worker/runtime code. + */ +export const makeHyperdriveDbLayer = ( + conn: Cloudflare.Hyperdrive.ConnectClient, +): Effect.Effect, never, RuntimeContext> => + Effect.gen(function* () { + const host = yield* conn.host; + const username = yield* conn.user; + const password = yield* conn.password; + const databaseName = yield* conn.database; + const port = yield* conn.port; + + const dbConfig = { + databaseName, + host, + password: Redacted.value(password), + port, + username, + }; + + if (isRuntimeHyperdriveHost(host)) return Db.layer({ ...dbConfig, ssl: undefined }); + return Db.layer(dbConfig); + }); + +/** + * Builds a request/task-scoped {@link Db} layer from a bound Cloudflare + * Hyperdrive connection. + * + * The layer keeps `RuntimeContext` as a requirement (Hyperdrive credentials + * carry Alchemy's runtime-phase marker), so it can only be built inside + * Worker/Workflow runtime code. Provide it with `Effect.provide` to satisfy + * {@link Db} dependencies — the layer is scoped, so `Effect.provide` releases + * the connection automatically and callers do NOT need `Effect.scoped`. + */ +export const HyperdriveDbLayer = { + make: (conn: Cloudflare.Hyperdrive.ConnectClient): Layer.Layer => + Layer.unwrap(makeHyperdriveDbLayer(conn)), +}; diff --git a/packages/platform/cloudflare/src/PlatformRuntime.ts b/packages/platform/cloudflare/src/PlatformRuntime.ts new file mode 100644 index 000000000..cc904a78d --- /dev/null +++ b/packages/platform/cloudflare/src/PlatformRuntime.ts @@ -0,0 +1,25 @@ +import { RuntimeContext, type BaseRuntimeContext } from "alchemy/RuntimeContext"; +import { Effect, Layer } from "effect"; + +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; + +/** Cloudflare implementation of the provider-neutral runtime marker. */ +export const PlatformRuntimeLive: Layer.Layer = + Layer.effect(PlatformRuntime, RuntimeContext.pipe(Effect.as(PlatformRuntime.of({})))); + +/** + * Provides the Cloudflare runtime behind a captured platform operation while + * retaining the provider-neutral runtime requirement exposed to callers. + */ +export const requirePlatformRuntime = ( + effect: Effect.Effect, + runtimeContext: BaseRuntimeContext, +): Effect.Effect => + PlatformRuntime.pipe( + Effect.andThen(Effect.provideService(effect, RuntimeContext, runtimeContext)), + ); + +/** Translates the provider-neutral runtime requirement at a Cloudflare boundary. */ +export const providePlatformRuntime = ( + effect: Effect.Effect, +): Effect.Effect => Effect.provide(effect, PlatformRuntimeLive); diff --git a/packages/platform/cloudflare/src/Queue.ts b/packages/platform/cloudflare/src/Queue.ts new file mode 100644 index 000000000..af84e08fa --- /dev/null +++ b/packages/platform/cloudflare/src/Queue.ts @@ -0,0 +1,93 @@ +import * as Cloudflare from "alchemy/Cloudflare"; +import { RuntimeContext } from "alchemy/RuntimeContext"; +import { Effect, Schema, SchemaParser } from "effect"; + +import { QueueProducerError, type QueueProducer } from "@voidhash/platform/Queue"; +import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { requirePlatformRuntime } from "./PlatformRuntime.ts"; + +// Re-export the abstract surface so existing concrete consumers keep importing +// the producer contract from this module. +export { QueueProducerError, type QueueProducer }; + +/** + * Build a typed producer for the given Cloudflare queue resource. + * + * Must be called from the Worker's init Effect (it depends on the queue binding + * which is only available in a runtime context). The producer captures the + * {@link Cloudflare.Queues.WriteQueueClient} once and reuses it for the lifetime of the + * Worker. The send effects keep the provider-neutral `PlatformRuntime` + * requirement so they can only run inside a configured runtime. + * + * @example + * ```ts + * const producer = yield* makeQueueProducer(CoreEventBus, EventBusEnvelope); + * yield* producer.publish({ deliveryId, attemptNumber: 1, ... }); + * ``` + */ +export const makeQueueProducer = ( + queue: Cloudflare.Queues.Queue, + schema: Schema.Codec, +) => + Effect.gen(function* () { + const sender = yield* Cloudflare.Queues.WriteQueue(queue); + const runtimeContext = yield* RuntimeContext; + const encode = SchemaParser.encodeUnknownEffect(schema); + const queueName = queue.LogicalId; + + const sendOne = (message: A): Effect.Effect => + Effect.gen(function* () { + const encoded = yield* encode(message).pipe( + Effect.mapError( + (cause) => + new QueueProducerError({ + cause: `encode failed: ${String(cause)}`, + queueName, + }), + ), + ); + yield* requirePlatformRuntime(sender.send(encoded), runtimeContext).pipe( + Effect.mapError( + (error) => + new QueueProducerError({ + cause: error.message, + queueName, + }), + ), + ); + }); + + const sendMany = ( + messages: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + const encoded = yield* Effect.forEach(messages, (m) => + encode(m).pipe( + Effect.mapError( + (cause) => + new QueueProducerError({ + cause: `encode failed: ${String(cause)}`, + queueName, + }), + ), + ), + ); + yield* requirePlatformRuntime( + sender.sendBatch(encoded.map((body) => ({ body }))), + runtimeContext, + ).pipe( + Effect.mapError( + (error) => + new QueueProducerError({ + cause: error.message, + queueName, + }), + ), + ); + }); + + return { + publish: sendOne, + publishBatch: sendMany, + }; + }); diff --git a/packages/platform/cloudflare/src/QueueConsumer.ts b/packages/platform/cloudflare/src/QueueConsumer.ts new file mode 100644 index 000000000..0d5ecfab8 --- /dev/null +++ b/packages/platform/cloudflare/src/QueueConsumer.ts @@ -0,0 +1,114 @@ +import * as Cloudflare from "alchemy/Cloudflare"; +import { Effect, Schema, SchemaParser, Stream } from "effect"; + +/** + * Catch-all queue-consumer error. Wraps Schema decode failures and handler + * errors at the consumer boundary. Decode failures are logged and acked + * (poison-pill protection); handler failures cause the batch to retry per + * the queue's `maxRetries` / `retryDelay` settings. + */ +export class QueueConsumerError extends Schema.TaggedErrorClass( + "QueueConsumerError", +)("QueueConsumerError", { + cause: Schema.String, + queueName: Schema.String, +}) {} + +/** + * Subscriber settings passed through to the underlying Cloudflare + * `consumeQueueMessages(...)` call. Mirrors {@link Cloudflare.Queues.MessagesProps} + * with no additions — kept as a re-export so callers don't reach into + * `alchemy/Cloudflare` directly. + */ +export type QueueConsumerOptions = Cloudflare.Queues.MessagesProps; + +/** + * Subscribe to a Cloudflare Queue with a Schema-typed handler. + * + * Each batch is streamed through `handle`; messages that fail Schema decode + * are logged and acked individually so a single bad message never poisons + * the batch. Handler failures bubble up to the outer subscribe, which calls + * `msg.retry()` on every message in the batch — Cloudflare then applies the + * configured `maxRetries` / `retryDelay` and dead-letters on exhaustion. + * + * Must be called from the Worker's init Effect. + * + * @example + * ```ts + * yield* consumeQueue(CoreEventBus, EventBusEnvelope, (msg) => + * eventBus.dispatch(msg), + * { batchSize: 10, maxRetries: 3, deadLetterQueue: CoreEventBusDlq.queueName as unknown as string }, + * ); + * ``` + */ +export const consumeQueue = ( + queue: Cloudflare.Queues.Queue, + schema: Schema.Codec, + handle: (message: A) => Effect.Effect, + options: QueueConsumerOptions = {}, +) => { + const queueName = queue.LogicalId; + const decode = SchemaParser.decodeUnknownEffect(schema); + return Cloudflare.Queues.consumeQueueMessages(queue, options, (stream) => + Stream.runForEach(stream, (raw) => + decode(raw.body).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.logWarning("queue payload decode failed; acking poison message", { + queueName, + messageId: raw.id, + cause: String(cause), + }).pipe(Effect.tap(() => Effect.sync(() => raw.ack()))), + onSuccess: (message) => handle(message), + }), + ), + ), + ); +}; + +/** + * Batch variant of {@link consumeQueue}: the whole delivered batch is decoded, + * poison (decode-failure) messages are acked individually, and the surviving + * messages are handed to `handleBatch` in a SINGLE call. Use this when the + * downstream work is cheaper amortized over a batch — e.g. one ClickHouse + * insert and one dedup query per delivery instead of one per message. + * + * Retry semantics match {@link consumeQueue}: if `handleBatch` fails, every + * non-poison message in the batch is retried per the queue's `maxRetries`, then + * dead-lettered on exhaustion — so the batch handler MUST be idempotent. + * + * Must be called from the Worker's init Effect. + */ +export const consumeQueueBatch = ( + queue: Cloudflare.Queues.Queue, + schema: Schema.Codec, + handleBatch: (messages: ReadonlyArray) => Effect.Effect, + options: QueueConsumerOptions = {}, +) => { + const queueName = queue.LogicalId; + const decode = SchemaParser.decodeUnknownEffect(schema); + return Cloudflare.Queues.consumeQueueMessages(queue, options, (stream) => + Effect.gen(function* () { + const decoded: Array = []; + yield* Stream.runForEach(stream, (raw) => + decode(raw.body).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.logWarning("queue payload decode failed; acking poison message", { + queueName, + messageId: raw.id, + cause: String(cause), + }).pipe(Effect.tap(() => Effect.sync(() => raw.ack()))), + onSuccess: (message) => + Effect.sync(() => { + decoded.push(message); + }), + }), + ), + ); + if (decoded.length > 0) { + yield* handleBatch(decoded); + } + }), + ); +}; diff --git a/packages/platform/cloudflare/src/WorkflowRunner.ts b/packages/platform/cloudflare/src/WorkflowRunner.ts new file mode 100644 index 000000000..4de34c97d --- /dev/null +++ b/packages/platform/cloudflare/src/WorkflowRunner.ts @@ -0,0 +1,246 @@ +import * as Cloudflare from "alchemy/Cloudflare"; +import { RuntimeContext, type BaseRuntimeContext } from "alchemy/RuntimeContext"; +import { Cause, Effect, Layer, Option, Schema } from "effect"; + +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import * as Workflow from "@voidhash/platform/Workflow"; +import { + type WorkflowExecutionResult, + WorkflowRunner, + WorkflowRunnerError, + type WorkflowRunnerShape, +} from "@voidhash/platform/WorkflowRunner"; + +type Handle = Cloudflare.WorkflowHandle; + +class NonRetryableError extends Error { + override readonly name = "NonRetryableError"; +} + +/** + * Defect a failed durable step dies with: a `NonRetryableError` when the step + * opted out of retries (Cloudflare Workflows treats that name as terminal), + * otherwise the squashed original cause so the platform retries it. + */ +const stepDefect = (retry: unknown, cause: Cause.Cause): unknown => { + if (retry === "none") return new NonRetryableError(Cause.pretty(cause)); + return Cause.squash(cause); +}; + +const runnerError = (workflowName: string, operation: string, cause: unknown) => + new WorkflowRunnerError({ cause: String(cause), operation, workflowName }); + +const catchRunnerCause = ( + effect: Effect.Effect, + workflowName: string, + operation: string, +): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => + Effect.fail(runnerError(workflowName, operation, Cause.pretty(cause))), + ), + ); + +/** + * SHA-256 hex digest of `value`. + * + * WebCrypto is read directly (rather than through effect's `Crypto` service) + * because this adapter implements a port whose methods are pinned to + * `R = PlatformRuntime`: a `Crypto` requirement here would leak into every + * `dispatch` caller. workerd always provides `crypto.subtle`. + */ +const sha256 = (value: string): Effect.Effect => + // oxlint-disable-next-line effect/noGlobals -- see the doc comment above: this port's methods are pinned to `R = PlatformRuntime`, and Effect v4's `Crypto` is a `Context.Service` with no Workers-safe layer, so requiring it here would leak a `Crypto` dependency into every `dispatch` caller. workerd always provides `crypto.subtle`. + Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))).pipe( + Effect.map((digest) => + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + ); + +const workflowHandle = ( + handles: ReadonlyMap, + workflowName: string, + operation: string, +): Effect.Effect => { + const handle = handles.get(workflowName); + if (handle) return Effect.succeed(handle); + return Effect.fail( + runnerError(workflowName, operation, `Workflow ${workflowName} is not registered`), + ); +}; + +const provideRuntime = ( + effect: Effect.Effect, + runtimeContext: BaseRuntimeContext, + runner: WorkflowRunnerShape, +): Effect.Effect => + effect.pipe( + Effect.provideService(RuntimeContext, runtimeContext), + Effect.provideService(PlatformRuntime, PlatformRuntime.of({})), + Effect.provideService(WorkflowRunner, runner), + ); + +/** Builds a Cloudflare Workflows adapter for one Worker initialization. */ +export const make = (runtimeContext: BaseRuntimeContext): WorkflowRunnerShape => { + const handles = new Map(); + let runner: WorkflowRunnerShape; + + runner = { + register: (workflow, run, dependencies) => { + const payloadSchema = Schema.Struct(workflow.payload); + // oxlint-disable-next-line effect/noAs -- Cloudflare's `WorkflowImpl` is a nominal alchemy type whose generator body cannot be structurally inferred from this closure; the cast pins the erased input/output pair. `satisfies` would demand the un-erased schema types the adapter no longer has. + const implementation = Effect.succeed(((encodedInput: unknown) => + Effect.gen(function* () { + const event = yield* Cloudflare.WorkflowEvent; + const input = yield* Schema.decodeUnknownEffect(payloadSchema)(encodedInput).pipe( + Effect.orDie, + ); + const context: Workflow.Context = { + executionId: event.instanceId, + // oxlint-disable-next-line effect/noAs -- `Workflow.Context.step` is generic per call site over the step's success schema; this adapter encodes/decodes through the erased schema, so the built function cannot be re-related to that generic signature without a cast. `satisfies` cannot widen an erased type back into a generic position. + step: ((options) => + Workflow.durableOperationName(options.name).pipe( + Effect.flatMap((name) => + Cloudflare.task( + name, + provideRuntime( + options.execute.pipe( + Effect.provide(dependencies), + Effect.flatMap((value) => + Schema.encodeUnknownEffect(options.success)(value), + ), + Effect.catchCause((cause) => Effect.die(stepDefect(options.retry, cause))), + ), + runtimeContext, + runner, + ), + ), + ), + Effect.flatMap((value) => Schema.decodeUnknownEffect(options.success)(value)), + Effect.mapError((cause) => + runnerError(workflow.name, `step:${options.name}`, cause), + ), + )) as Workflow.Context["step"], + // oxlint-disable-next-line effect/noAs -- `Workflow.Context.sleepUntil` is an overloaded signature that `Cloudflare.sleepUntil` cannot be structurally checked against; the `as unknown as` bridges the Cloudflare durable-sleep shape to the port's. `satisfies` cannot bridge two unrelated call signatures. + sleepUntil: ((name: string, scheduledTime: Date) => + Workflow.durableOperationName(name).pipe( + Effect.flatMap((durableName) => Cloudflare.sleepUntil(durableName, scheduledTime)), + Effect.mapError((cause) => runnerError(workflow.name, `sleep:${name}`, cause)), + )) as unknown as Workflow.Context["sleepUntil"], + }; + const result = yield* provideRuntime(run(input, context), runtimeContext, runner).pipe( + Effect.orDie, + ); + return yield* Schema.encodeUnknownEffect(workflow.success)(result).pipe(Effect.orDie); + })) as Cloudflare.WorkflowImpl); + + // oxlint-disable-next-line effect/noAs -- `register` in `WorkflowRunnerShape` is generic over the workflow definition, which this adapter has already erased to `Cloudflare.WorkflowImpl`; the resulting effect cannot be re-related to the port's type parameter without a cast. `satisfies` cannot widen an erased type back into a generic position. + return catchRunnerCause( + Effect.gen(function* () { + const registered = yield* Cloudflare.Workflow()(workflow.name, implementation); + handles.set(workflow.name, registered); + }), + workflow.name, + "register", + ) as never; + }, + dispatch: (workflow, payload) => + // oxlint-disable-next-line effect/noAs -- `dispatch` in `WorkflowRunnerShape` is generic over the workflow definition; the adapter works with the erased `Schema.Struct(workflow.payload)`, so the produced effect cannot be re-related to the port's type parameter without a cast. `satisfies` cannot widen an erased type back into a generic position. + catchRunnerCause( + Effect.gen(function* () { + yield* PlatformRuntime; + const handle = yield* workflowHandle(handles, workflow.name, "dispatch"); + const encoded = yield* Schema.encodeUnknownEffect(Schema.Struct(workflow.payload))( + payload, + ); + const executionId = yield* sha256(workflow.idempotencyKey(payload)); + const instance = yield* handle + .create({ id: executionId, params: encoded }) + .pipe(Effect.catchCause(() => handle.get(executionId))); + return instance.id; + }), + workflow.name, + "dispatch", + ) as never, + execute: (workflow, payload) => + catchRunnerCause( + Effect.gen(function* () { + const executionId = yield* runner.dispatch(workflow, payload); + while (true) { + const result = yield* runner.poll(workflow, executionId); + if (Option.isSome(result)) { + if (result.value.status === "succeeded") return result.value.value; + if (result.value.status === "failed") return yield* result.value.error; + if (result.value.status === "interrupted") { + return yield* runnerError(workflow.name, "execute", "Workflow interrupted"); + } + } + yield* Effect.sleep("250 millis"); + } + }), + workflow.name, + "execute", + ), + poll: (workflow, executionId) => + // oxlint-disable-next-line effect/noAs -- `poll` in `WorkflowRunnerShape` is generic over the workflow's success type; this adapter only sees the erased `Schema`, so the concrete `Option>` cannot be re-related to the port's type parameter without a cast. `satisfies` cannot widen an erased type back into a generic position. + catchRunnerCause( + Effect.gen(function* () { + yield* PlatformRuntime; + const handle = yield* workflowHandle(handles, workflow.name, "poll"); + const instance = yield* handle.get(executionId); + const status = yield* instance.status(); + + if (status.status === "terminated") { + return Option.some>({ status: "interrupted" }); + } + if (status.status === "errored") { + return Option.some>({ + status: "failed", + error: runnerError(workflow.name, "poll", status.error?.message ?? "Workflow failed"), + }); + } + if (status.status === "complete") { + const value = yield* Schema.decodeUnknownEffect(workflow.success)(status.output); + return Option.some>({ + status: "succeeded", + value, + }); + } + if (status.status === "unknown") return Option.none(); + return Option.some>({ status: "suspended" }); + }), + workflow.name, + "poll", + ) as never, + resume: (workflow, executionId) => + catchRunnerCause( + Effect.gen(function* () { + yield* PlatformRuntime; + const handle = yield* workflowHandle(handles, workflow.name, "resume"); + const instance = yield* handle.get(executionId); + yield* instance.resume(); + }), + workflow.name, + "resume", + ), + interrupt: (workflow, executionId) => + catchRunnerCause( + Effect.gen(function* () { + yield* PlatformRuntime; + const handle = yield* workflowHandle(handles, workflow.name, "interrupt"); + const instance = yield* handle.get(executionId); + yield* instance.terminate(); + }), + workflow.name, + "interrupt", + ), + }; + + return runner; +}; + +/** Provides a Cloudflare workflow runner from the current Alchemy runtime. */ +export const layer: Layer.Layer = Layer.effect( + WorkflowRunner, + RuntimeContext.pipe(Effect.map(make)), +); diff --git a/packages/platform/cloudflare/src/index.ts b/packages/platform/cloudflare/src/index.ts new file mode 100644 index 000000000..b87bee3f1 --- /dev/null +++ b/packages/platform/cloudflare/src/index.ts @@ -0,0 +1,20 @@ +export { + makeCloudflareDurableEntityHost, + makeCloudflareDurableEntitySession, + makeCloudflareDurableEntityStorage, + type CloudflareDurableEntityStorage, +} from "./DurableEntity.ts"; +export { DbFromContextLive, HyperdriveDbLayer, makeHyperdriveDbLayer } from "./HyperdriveDb.ts"; +export { + PlatformRuntimeLive, + providePlatformRuntime, + requirePlatformRuntime, +} from "./PlatformRuntime.ts"; +export { makeQueueProducer } from "./Queue.ts"; +export { + consumeQueue, + consumeQueueBatch, + QueueConsumerError, + type QueueConsumerOptions, +} from "./QueueConsumer.ts"; +export * as CloudflareWorkflowRunner from "./WorkflowRunner.ts"; diff --git a/packages/platform/cloudflare/tsconfig.json b/packages/platform/cloudflare/tsconfig.json new file mode 100644 index 000000000..ef2d33e63 --- /dev/null +++ b/packages/platform/cloudflare/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@voidhash/tsconfig/alchemy-base.json", + "include": ["src"] +} diff --git a/selfhost/platform/package.json b/packages/platform/node/package.json similarity index 95% rename from selfhost/platform/package.json rename to packages/platform/node/package.json index b54d974f7..1d6242d98 100644 --- a/selfhost/platform/package.json +++ b/packages/platform/node/package.json @@ -1,12 +1,12 @@ { - "name": "@voidhash/platform-selfhost", + "name": "@voidhash/platform-node", "version": "0.0.1-alpha.1", "private": true, "license": "AGPL-3.0-only", "repository": { "type": "git", "url": "https://github.com/voidhashcom/voidhash", - "directory": "selfhost/platform" + "directory": "packages/platform/node" }, "type": "module", "exports": { diff --git a/selfhost/platform/src/ClusterDurableEntity.ts b/packages/platform/node/src/ClusterDurableEntity.ts similarity index 99% rename from selfhost/platform/src/ClusterDurableEntity.ts rename to packages/platform/node/src/ClusterDurableEntity.ts index 4e525d1be..22cff32c7 100644 --- a/selfhost/platform/src/ClusterDurableEntity.ts +++ b/packages/platform/node/src/ClusterDurableEntity.ts @@ -21,7 +21,7 @@ import { SingleNodeClusterLive } from "./Topology.ts"; const entityKey = (address: DurableEntityAddress): string => `${address.type}\u0000${address.id}`; const valueKey = (address: DurableEntityAddress, key: string): string => - `voidhash/platform-selfhost/entity/${address.type}/${address.id}/kv/${key}`; + `voidhash/platform-node/entity/${address.type}/${address.id}/kv/${key}`; interface LocalEntityState { readonly lock: Semaphore.Semaphore; diff --git a/selfhost/platform/src/CronScheduler.ts b/packages/platform/node/src/CronScheduler.ts similarity index 94% rename from selfhost/platform/src/CronScheduler.ts rename to packages/platform/node/src/CronScheduler.ts index 5cb1d8a16..f8db49936 100644 --- a/selfhost/platform/src/CronScheduler.ts +++ b/packages/platform/node/src/CronScheduler.ts @@ -6,7 +6,18 @@ import { type CronSchedulerShape, } from "@voidhash/platform/CronScheduler"; import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; -import { Cause, Clock, Cron, DateTime, Effect, Layer, Result, Schema, SchemaParser, Semaphore } from "effect"; +import { + Cause, + Clock, + Cron, + DateTime, + Effect, + Layer, + Result, + Schema, + SchemaParser, + Semaphore, +} from "effect"; import { ClusterCron, Sharding } from "effect/unstable/cluster"; import { KeyValueStore } from "effect/unstable/persistence"; @@ -36,7 +47,7 @@ const parseCron = (job: CronSchedule) => { return Effect.fail(schedulerError(job.name, "parse", parsed.failure.message)); }; -const stateKey = (jobName: string): string => `voidhash/platform-selfhost/cron/${jobName}`; +const stateKey = (jobName: string): string => `voidhash/platform-node/cron/${jobName}`; const SlotStateJson = Schema.fromJsonString( Schema.Struct({ @@ -52,10 +63,12 @@ const encodeSlotState = SchemaParser.encodeUnknownEffect(SlotStateJson); const decodeState = (raw: string | undefined): Effect.Effect => { if (raw === undefined) return Effect.succeed(undefined); return decodeSlotState(raw).pipe( - Effect.map((parsed): SlotState => ({ - lastScheduledAtMs: parsed.lastScheduledAtMs, - nextScheduledAtMs: parsed.nextScheduledAtMs, - })), + Effect.map( + (parsed): SlotState => ({ + lastScheduledAtMs: parsed.lastScheduledAtMs, + nextScheduledAtMs: parsed.nextScheduledAtMs, + }), + ), Effect.catchCause(() => Effect.succeed(undefined)), ); }; @@ -159,8 +172,8 @@ const makeTick = * registers is never discarded: the storage read loop retries it in a tight * loop ("Could not find entity manager for address, retrying") and starves the * runner, stalling unrelated workflows and queue consumers. Renaming or - * removing a job therefore requires draining its messages by hand — see - * "Renaming or removing a cron job" in selfhost/README.md. + * removing a job therefore requires draining its messages and migrating its + * persisted state key. */ const makeRun = (sharding: Sharding.Sharding["Service"]) => diff --git a/selfhost/platform/src/EntityAlarmStore.ts b/packages/platform/node/src/EntityAlarmStore.ts similarity index 96% rename from selfhost/platform/src/EntityAlarmStore.ts rename to packages/platform/node/src/EntityAlarmStore.ts index 69b6ef548..28ba940a2 100644 --- a/selfhost/platform/src/EntityAlarmStore.ts +++ b/packages/platform/node/src/EntityAlarmStore.ts @@ -1,7 +1,4 @@ -import type { - DueDurableEntityAlarm, - DurableEntityAddress, -} from "@voidhash/platform/DurableEntity"; +import type { DueDurableEntityAlarm, DurableEntityAddress } from "@voidhash/platform/DurableEntity"; import { Context, Effect, Layer } from "effect"; import { SqlClient } from "effect/unstable/sql"; @@ -26,7 +23,7 @@ export interface DurableEntityAlarmStoreShape { export class DurableEntityAlarmStore extends Context.Service< DurableEntityAlarmStore, DurableEntityAlarmStoreShape ->()("@voidhash/platform-selfhost/DurableEntityAlarmStore") {} +>()("@voidhash/platform/node/DurableEntityAlarmStore") {} interface AlarmRow { readonly type: string; diff --git a/selfhost/platform/src/KeyValueStore.ts b/packages/platform/node/src/KeyValueStore.ts similarity index 97% rename from selfhost/platform/src/KeyValueStore.ts rename to packages/platform/node/src/KeyValueStore.ts index 36980f23a..3a709e6b4 100644 --- a/selfhost/platform/src/KeyValueStore.ts +++ b/packages/platform/node/src/KeyValueStore.ts @@ -178,7 +178,9 @@ const makeStore = (sql: SqlClient.SqlClient): KeyValueStoreShape => ({ }, delete: (namespace, key) => PlatformRuntime.pipe( - Effect.andThen(sql`DELETE FROM platform_key_value WHERE namespace = ${namespace} AND key = ${key}`), + Effect.andThen( + sql`DELETE FROM platform_key_value WHERE namespace = ${namespace} AND key = ${key}`, + ), Effect.asVoid, Effect.mapError((cause) => storeError(namespace, "delete", cause)), ), @@ -256,9 +258,7 @@ const makeStore = (sql: SqlClient.SqlClient): KeyValueStoreShape => ({ }); /** Postgres-backed typed key-value store with TTL and atomic counters. */ -export const PgKeyValueStoreLive = ( - config: PgPlatformConfig, -): Layer.Layer => +export const PgKeyValueStoreLive = (config: PgPlatformConfig): Layer.Layer => Layer.effect( KeyValueStore, Effect.gen(function* () { diff --git a/selfhost/platform/src/Mailer.ts b/packages/platform/node/src/Mailer.ts similarity index 93% rename from selfhost/platform/src/Mailer.ts rename to packages/platform/node/src/Mailer.ts index 243e3f775..810f97cac 100644 --- a/selfhost/platform/src/Mailer.ts +++ b/packages/platform/node/src/Mailer.ts @@ -96,16 +96,11 @@ const send = ( })), ); -const makeMailer = ( - transporter: Transporter, - config: SmtpMailerConfig, -): MailerShape => ({ +const makeMailer = (transporter: Transporter, config: SmtpMailerConfig): MailerShape => ({ send: (message) => send(transporter, config, message), }); -const makeTransporter = ( - config: SmtpMailerConfig, -): Effect.Effect => { +const makeTransporter = (config: SmtpMailerConfig): Effect.Effect => { if ((config.username === undefined) !== (config.password === undefined)) { return Effect.fail( mailerError("configure", "SMTP username and password must be provided together"), @@ -128,9 +123,7 @@ const makeTransporter = ( }; /** SMTP-backed mailer with optional authenticated TLS and startup verification. */ -export const SmtpMailerLive = ( - config: SmtpMailerConfig, -): Layer.Layer => +export const SmtpMailerLive = (config: SmtpMailerConfig): Layer.Layer => Layer.effect( Mailer, Effect.acquireRelease( diff --git a/selfhost/platform/src/MemoryDurableEntity.ts b/packages/platform/node/src/MemoryDurableEntity.ts similarity index 95% rename from selfhost/platform/src/MemoryDurableEntity.ts rename to packages/platform/node/src/MemoryDurableEntity.ts index 0ef4e4832..4c28b25aa 100644 --- a/selfhost/platform/src/MemoryDurableEntity.ts +++ b/packages/platform/node/src/MemoryDurableEntity.ts @@ -63,13 +63,12 @@ export const makeMemoryDurableEntity = (): MemoryDurableEntity => { alarm: { get: Effect.sync(() => state.alarm), set: (scheduledTime) => Effect.sync(() => void (state.alarm = scheduledTime)), - delete: Effect.sync(() => (state.alarm = undefined)), + delete: Effect.sync(() => (state.alarm = undefined)), }, sessions: { get: (sessionId) => Effect.sync(() => state.sessions.get(sessionId)), list: Effect.sync(() => [...state.sessions.values()]), - attach: (session) => - Effect.sync(() => void state.sessions.set(session.id, session)), + attach: (session) => Effect.sync(() => void state.sessions.set(session.id, session)), remove: (sessionId) => Effect.sync(() => void state.sessions.delete(sessionId)), }, }; diff --git a/selfhost/platform/src/NodeDurableEntitySession.ts b/packages/platform/node/src/NodeDurableEntitySession.ts similarity index 100% rename from selfhost/platform/src/NodeDurableEntitySession.ts rename to packages/platform/node/src/NodeDurableEntitySession.ts diff --git a/selfhost/platform/src/ObjectStore.ts b/packages/platform/node/src/ObjectStore.ts similarity index 93% rename from selfhost/platform/src/ObjectStore.ts rename to packages/platform/node/src/ObjectStore.ts index 385005b30..a62d7b481 100644 --- a/selfhost/platform/src/ObjectStore.ts +++ b/packages/platform/node/src/ObjectStore.ts @@ -18,12 +18,7 @@ export interface S3ObjectStoreConfig { readonly forcePathStyle?: boolean; } -const storeError = ( - config: S3ObjectStoreConfig, - key: string, - operation: string, - cause: unknown, -) => +const storeError = (config: S3ObjectStoreConfig, key: string, operation: string, cause: unknown) => new ObjectStoreError({ bucketName: config.bucketName, key, @@ -50,10 +45,7 @@ const checksumCompatibility = (endpoint: string | undefined) => { }); }; -const makeStore = ( - config: S3ObjectStoreConfig, - client: S3.Type, -): ObjectStoreShape => ({ +const makeStore = (config: S3ObjectStoreConfig, client: S3.Type): ObjectStoreShape => ({ bucketName: config.bucketName, put: ({ key, body, contentType, cacheControl }) => PlatformRuntime.pipe( @@ -121,9 +113,7 @@ const makeStore = ( }); /** S3-compatible object store layer for AWS S3, MinIO, Garage, or R2. */ -export const S3ObjectStoreLive = ( - config: S3ObjectStoreConfig, -): Layer.Layer => +export const S3ObjectStoreLive = (config: S3ObjectStoreConfig): Layer.Layer => Layer.effect( ObjectStore, Effect.map(S3, (client) => makeStore(config, client)), diff --git a/selfhost/platform/src/PlatformRuntime.ts b/packages/platform/node/src/PlatformRuntime.ts similarity index 67% rename from selfhost/platform/src/PlatformRuntime.ts rename to packages/platform/node/src/PlatformRuntime.ts index 1ffa97432..ffa17ae3f 100644 --- a/selfhost/platform/src/PlatformRuntime.ts +++ b/packages/platform/node/src/PlatformRuntime.ts @@ -2,12 +2,12 @@ import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { Layer } from "effect"; /** - * Marks effects as running inside a configured self-host runtime. + * Marks effects as running inside a configured Node platform runtime. * * The marker carries no capabilities; it exists so application code cannot * reach a platform primitive without a composition root having installed one. */ -export const SelfhostPlatformRuntimeLive: Layer.Layer = Layer.succeed( +export const NodePlatformRuntimeLive: Layer.Layer = Layer.succeed( PlatformRuntime, PlatformRuntime.of({}), ); diff --git a/selfhost/platform/src/Postgres.ts b/packages/platform/node/src/Postgres.ts similarity index 100% rename from selfhost/platform/src/Postgres.ts rename to packages/platform/node/src/Postgres.ts diff --git a/selfhost/platform/src/Queue.ts b/packages/platform/node/src/Queue.ts similarity index 97% rename from selfhost/platform/src/Queue.ts rename to packages/platform/node/src/Queue.ts index e281717fe..1f42097e2 100644 --- a/selfhost/platform/src/Queue.ts +++ b/packages/platform/node/src/Queue.ts @@ -44,7 +44,10 @@ const resolvedOptions = (options: QueueConsumerOptions | undefined) => ({ batchSize: positiveInteger(options?.batchSize, defaultOptions.batchSize), maxRetries: nonNegativeInteger(options?.maxRetries, defaultOptions.maxRetries), retryDelayMillis: nonNegativeInteger(options?.retryDelayMillis, defaultOptions.retryDelayMillis), - pollIntervalMillis: positiveInteger(options?.pollIntervalMillis, defaultOptions.pollIntervalMillis), + pollIntervalMillis: positiveInteger( + options?.pollIntervalMillis, + defaultOptions.pollIntervalMillis, + ), deadLetterQueue: options?.deadLetterQueue, }); @@ -115,10 +118,7 @@ const makeQueueDriver = ( ); }); - const producer = ( - queueName: string, - schema: Schema.Codec, - ): QueueProducer => { + const producer = (queueName: string, schema: Schema.Codec): QueueProducer => { const encode = SchemaParser.encodeUnknownEffect(schema); const publishOne = (message: A) => @@ -304,7 +304,4 @@ export const ClusterQueueLive: Layer.Layer< QueueDriver, never, PersistedQueue.PersistedQueueFactory -> = Layer.effect( - QueueDriver, - Effect.map(PersistedQueue.PersistedQueueFactory, makeQueueDriver), -); +> = Layer.effect(QueueDriver, Effect.map(PersistedQueue.PersistedQueueFactory, makeQueueDriver)); diff --git a/selfhost/platform/src/Screenshot.ts b/packages/platform/node/src/Screenshot.ts similarity index 100% rename from selfhost/platform/src/Screenshot.ts rename to packages/platform/node/src/Screenshot.ts diff --git a/selfhost/platform/src/Topology.ts b/packages/platform/node/src/Topology.ts similarity index 87% rename from selfhost/platform/src/Topology.ts rename to packages/platform/node/src/Topology.ts index 82d15c7b4..9004fa0a8 100644 --- a/selfhost/platform/src/Topology.ts +++ b/packages/platform/node/src/Topology.ts @@ -22,7 +22,7 @@ export type ClusterTopology = Sharding.Sharding | Runners.Runners | MessageStora /** * Single-process cluster over the ambient SQL client. * - * This is the default self-host topology: one runner owns every shard, while + * This is the default single-process Node topology: one runner owns every shard, while * mailboxes, workflow state, and cron slots persist in SQL so nothing is lost * across restarts. */ @@ -39,6 +39,5 @@ export const SingleNodeClusterLive = (options?: { * Fully in-memory cluster with no SQL dependency, for tests and ephemeral * local development. State does not survive the process. */ -export const TestClusterLive: Layer.Layer< - ClusterTopology | MessageStorage.MemoryDriver -> = TestRunner.layer; +export const TestClusterLive: Layer.Layer = + TestRunner.layer; diff --git a/selfhost/platform/src/Workflow.ts b/packages/platform/node/src/Workflow.ts similarity index 100% rename from selfhost/platform/src/Workflow.ts rename to packages/platform/node/src/Workflow.ts diff --git a/selfhost/platform/src/index.ts b/packages/platform/node/src/index.ts similarity index 94% rename from selfhost/platform/src/index.ts rename to packages/platform/node/src/index.ts index 3c8fac3bf..67427ca72 100644 --- a/selfhost/platform/src/index.ts +++ b/packages/platform/node/src/index.ts @@ -25,7 +25,7 @@ export { type NodeWebSocketLike, } from "./NodeDurableEntitySession.ts"; export { S3ObjectStoreLive, type S3ObjectStoreConfig } from "./ObjectStore.ts"; -export { SelfhostPlatformRuntimeLive } from "./PlatformRuntime.ts"; +export { NodePlatformRuntimeLive } from "./PlatformRuntime.ts"; export { PgPlatformClientLive, type PgPlatformConfig } from "./Postgres.ts"; export { ClusterQueueLive } from "./Queue.ts"; export { ChromiumScreenshotLive, type ChromiumScreenshotConfig } from "./Screenshot.ts"; diff --git a/selfhost/platform/tests/ChromiumScreenshot.integration.test.ts b/packages/platform/node/tests/ChromiumScreenshot.integration.test.ts similarity index 95% rename from selfhost/platform/tests/ChromiumScreenshot.integration.test.ts rename to packages/platform/node/tests/ChromiumScreenshot.integration.test.ts index 87e414747..d56cc101c 100644 --- a/selfhost/platform/tests/ChromiumScreenshot.integration.test.ts +++ b/packages/platform/node/tests/ChromiumScreenshot.integration.test.ts @@ -4,7 +4,7 @@ import { Config, Effect, Layer, Option } from "effect"; import { HttpServer, HttpServerResponse } from "effect/unstable/http"; import { describe, expect, it } from "vitest"; -import { SelfhostPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; import { ChromiumScreenshotLive } from "../src/Screenshot.ts"; const darwinChrome = (): string | undefined => { @@ -14,7 +14,7 @@ const darwinChrome = (): string | undefined => { return undefined; }; -const readExecutablePath = Config.string("PLATFORM_SELFHOST_CHROMIUM_EXECUTABLE_PATH").pipe( +const readExecutablePath = Config.string("PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH").pipe( Config.option, Effect.map(Option.getOrUndefined), Effect.map((configured) => configured ?? darwinChrome()), @@ -25,7 +25,7 @@ const screenshotLayer = () => Layer.unwrap( readExecutablePath.pipe( Effect.map((executablePath) => - Layer.merge(ChromiumScreenshotLive({ executablePath }), SelfhostPlatformRuntimeLive), + Layer.merge(ChromiumScreenshotLive({ executablePath }), NodePlatformRuntimeLive), ), ), ); diff --git a/selfhost/platform/tests/MemoryDurableEntity.test.ts b/packages/platform/node/tests/MemoryDurableEntity.test.ts similarity index 100% rename from selfhost/platform/tests/MemoryDurableEntity.test.ts rename to packages/platform/node/tests/MemoryDurableEntity.test.ts diff --git a/selfhost/platform/tests/MemoryDurableEntityConformance.test.ts b/packages/platform/node/tests/MemoryDurableEntityConformance.test.ts similarity index 100% rename from selfhost/platform/tests/MemoryDurableEntityConformance.test.ts rename to packages/platform/node/tests/MemoryDurableEntityConformance.test.ts diff --git a/selfhost/platform/tests/NodeDurableEntitySession.test.ts b/packages/platform/node/tests/NodeDurableEntitySession.test.ts similarity index 100% rename from selfhost/platform/tests/NodeDurableEntitySession.test.ts rename to packages/platform/node/tests/NodeDurableEntitySession.test.ts diff --git a/selfhost/platform/tests/PgKeyValueStore.integration.test.ts b/packages/platform/node/tests/PgKeyValueStore.integration.test.ts similarity index 88% rename from selfhost/platform/tests/PgKeyValueStore.integration.test.ts rename to packages/platform/node/tests/PgKeyValueStore.integration.test.ts index a94bea634..e9e58e130 100644 --- a/selfhost/platform/tests/PgKeyValueStore.integration.test.ts +++ b/packages/platform/node/tests/PgKeyValueStore.integration.test.ts @@ -3,20 +3,20 @@ import { Config, Effect, Layer, Option, Random, Redacted, Schema } from "effect" import { describe, expect, it } from "vitest"; import { PgKeyValueStoreLive } from "../src/KeyValueStore.ts"; -import { SelfhostPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; import type { PgPlatformConfig } from "../src/Postgres.ts"; const readConfig = Effect.gen(function* () { const config: PgPlatformConfig = { - host: yield* Config.string("PLATFORM_SELFHOST_PG_HOST").pipe(Config.withDefault("127.0.0.1")), - port: yield* Config.int("PLATFORM_SELFHOST_PG_PORT").pipe(Config.withDefault(5432)), - database: yield* Config.string("PLATFORM_SELFHOST_PG_DATABASE").pipe( + host: yield* Config.string("PLATFORM_NODE_PG_HOST").pipe(Config.withDefault("127.0.0.1")), + port: yield* Config.int("PLATFORM_NODE_PG_PORT").pipe(Config.withDefault(5432)), + database: yield* Config.string("PLATFORM_NODE_PG_DATABASE").pipe( Config.withDefault("voidhash"), ), - username: yield* Config.string("PLATFORM_SELFHOST_PG_USERNAME").pipe( + username: yield* Config.string("PLATFORM_NODE_PG_USERNAME").pipe( Config.withDefault("voidhash"), ), - password: yield* Config.redacted("PLATFORM_SELFHOST_PG_PASSWORD").pipe( + password: yield* Config.redacted("PLATFORM_NODE_PG_PASSWORD").pipe( Config.withDefault(Redacted.make("password")), ), }; @@ -26,9 +26,7 @@ const readConfig = Effect.gen(function* () { const storeLayer = () => Layer.unwrap( readConfig.pipe( - Effect.map((config) => - Layer.merge(PgKeyValueStoreLive(config), SelfhostPlatformRuntimeLive), - ), + Effect.map((config) => Layer.merge(PgKeyValueStoreLive(config), NodePlatformRuntimeLive)), ), ); @@ -47,12 +45,7 @@ describe("Postgres key-value store", () => { yield* Effect.gen(function* () { const store = yield* KeyValueStore; - yield* store.put( - namespace, - "profile", - { name: "node", version: 1 }, - profileSchema, - ); + yield* store.put(namespace, "profile", { name: "node", version: 1 }, profileSchema); yield* store.put(namespace, "label", "node-string", Schema.String); }).pipe(Effect.provide(storeLayer())); diff --git a/selfhost/platform/tests/S3ObjectStore.integration.test.ts b/packages/platform/node/tests/S3ObjectStore.integration.test.ts similarity index 82% rename from selfhost/platform/tests/S3ObjectStore.integration.test.ts rename to packages/platform/node/tests/S3ObjectStore.integration.test.ts index b1804bd3a..92f317579 100644 --- a/selfhost/platform/tests/S3ObjectStore.integration.test.ts +++ b/packages/platform/node/tests/S3ObjectStore.integration.test.ts @@ -3,23 +3,21 @@ import { Config, Effect, Layer, Option, Random, Redacted } from "effect"; import { describe, expect, it } from "vitest"; import { S3ObjectStoreLive, type S3ObjectStoreConfig } from "../src/ObjectStore.ts"; -import { SelfhostPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; const readConfig = Effect.gen(function* () { const config: S3ObjectStoreConfig = { - bucketName: yield* Config.string("PLATFORM_SELFHOST_S3_BUCKET").pipe( + bucketName: yield* Config.string("PLATFORM_NODE_S3_BUCKET").pipe( Config.withDefault("voidhash-public"), ), - region: yield* Config.string("PLATFORM_SELFHOST_S3_REGION").pipe( - Config.withDefault("us-east-1"), - ), - endpoint: yield* Config.string("PLATFORM_SELFHOST_S3_ENDPOINT").pipe( + region: yield* Config.string("PLATFORM_NODE_S3_REGION").pipe(Config.withDefault("us-east-1")), + endpoint: yield* Config.string("PLATFORM_NODE_S3_ENDPOINT").pipe( Config.withDefault("http://127.0.0.1:9000"), ), - accessKeyId: yield* Config.string("PLATFORM_SELFHOST_S3_ACCESS_KEY_ID").pipe( + accessKeyId: yield* Config.string("PLATFORM_NODE_S3_ACCESS_KEY_ID").pipe( Config.withDefault("voidhash"), ), - secretAccessKey: yield* Config.redacted("PLATFORM_SELFHOST_S3_SECRET_ACCESS_KEY").pipe( + secretAccessKey: yield* Config.redacted("PLATFORM_NODE_S3_SECRET_ACCESS_KEY").pipe( Config.withDefault(Redacted.make("password")), ), forcePathStyle: true, @@ -33,7 +31,7 @@ const storeLayer = ( Layer.unwrap( readConfig.pipe( Effect.map((config) => - Layer.merge(S3ObjectStoreLive(adjust(config)), SelfhostPlatformRuntimeLive), + Layer.merge(S3ObjectStoreLive(adjust(config)), NodePlatformRuntimeLive), ), ), ); @@ -93,9 +91,7 @@ describe("S3-compatible object store", () => { const error = yield* Effect.gen(function* () { const store = yield* ObjectStore; return yield* store.get("missing").pipe(Effect.flip); - }).pipe( - Effect.provide(storeLayer((input) => ({ ...input, bucketName: missingBucket }))), - ); + }).pipe(Effect.provide(storeLayer((input) => ({ ...input, bucketName: missingBucket })))); expect(error).toBeInstanceOf(ObjectStoreError); expect(error.bucketName).toBe(missingBucket); diff --git a/selfhost/platform/tests/Screenshot.test.ts b/packages/platform/node/tests/Screenshot.test.ts similarity index 100% rename from selfhost/platform/tests/Screenshot.test.ts rename to packages/platform/node/tests/Screenshot.test.ts diff --git a/selfhost/platform/tests/SingleNodePg.integration.test.ts b/packages/platform/node/tests/SingleNodePg.integration.test.ts similarity index 87% rename from selfhost/platform/tests/SingleNodePg.integration.test.ts rename to packages/platform/node/tests/SingleNodePg.integration.test.ts index 19cd1062d..4d21ff364 100644 --- a/selfhost/platform/tests/SingleNodePg.integration.test.ts +++ b/packages/platform/node/tests/SingleNodePg.integration.test.ts @@ -15,29 +15,27 @@ import { ClusterDurableEntityHostLive, } from "../src/ClusterDurableEntity.ts"; import { PgEntityAlarmStoreLive } from "../src/EntityAlarmStore.ts"; -import { SelfhostPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; import { ClusterQueueLive } from "../src/Queue.ts"; import { SingleNodeClusterLive } from "../src/Topology.ts"; import * as ClusterWorkflowRunner from "../src/Workflow.ts"; /** - * Exercises the production self-host topology: one runner whose mailboxes, + * Exercises the single-process Node topology: one runner whose mailboxes, * workflow state, queues, and entity state all live in Postgres. */ const SqlLive = Layer.unwrap( Effect.gen(function* () { return PgClient.layer({ - host: yield* Config.string("PLATFORM_SELFHOST_PG_HOST").pipe( - Config.withDefault("127.0.0.1"), - ), - port: yield* Config.int("PLATFORM_SELFHOST_PG_PORT").pipe(Config.withDefault(5432)), - database: yield* Config.string("PLATFORM_SELFHOST_PG_DATABASE").pipe( + host: yield* Config.string("PLATFORM_NODE_PG_HOST").pipe(Config.withDefault("127.0.0.1")), + port: yield* Config.int("PLATFORM_NODE_PG_PORT").pipe(Config.withDefault(5432)), + database: yield* Config.string("PLATFORM_NODE_PG_DATABASE").pipe( Config.withDefault("voidhash"), ), - username: yield* Config.string("PLATFORM_SELFHOST_PG_USERNAME").pipe( + username: yield* Config.string("PLATFORM_NODE_PG_USERNAME").pipe( Config.withDefault("voidhash"), ), - password: yield* Config.redacted("PLATFORM_SELFHOST_PG_PASSWORD").pipe( + password: yield* Config.redacted("PLATFORM_NODE_PG_PASSWORD").pipe( Config.withDefault(Redacted.make("password")), ), }); @@ -54,7 +52,7 @@ const ClusterLive = SingleNodeClusterLive({ runnerStorage: "memory" }).pipe( const workflowLayer = () => ClusterWorkflowRunner.layer.pipe( Layer.provide(ClusterLive), - Layer.merge(SelfhostPlatformRuntimeLive), + Layer.merge(NodePlatformRuntimeLive), ); const queueLayer = () => @@ -69,7 +67,7 @@ const queueLayer = () => Layer.orDie, ), ), - Layer.merge(SelfhostPlatformRuntimeLive), + Layer.merge(NodePlatformRuntimeLive), ); const entityLayer = () => diff --git a/selfhost/platform/tests/SmtpMailer.integration.test.ts b/packages/platform/node/tests/SmtpMailer.integration.test.ts similarity index 90% rename from selfhost/platform/tests/SmtpMailer.integration.test.ts rename to packages/platform/node/tests/SmtpMailer.integration.test.ts index 617f5982a..238c46481 100644 --- a/selfhost/platform/tests/SmtpMailer.integration.test.ts +++ b/packages/platform/node/tests/SmtpMailer.integration.test.ts @@ -4,14 +4,12 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { describe, expect, it } from "vitest"; import { SmtpMailerLive, type SmtpMailerConfig } from "../src/Mailer.ts"; -import { SelfhostPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; const readConfig = Effect.gen(function* () { const config: SmtpMailerConfig = { - host: yield* Config.string("PLATFORM_SELFHOST_SMTP_HOST").pipe( - Config.withDefault("127.0.0.1"), - ), - port: yield* Config.int("PLATFORM_SELFHOST_SMTP_PORT").pipe(Config.withDefault(1025)), + host: yield* Config.string("PLATFORM_NODE_SMTP_HOST").pipe(Config.withDefault("127.0.0.1")), + port: yield* Config.int("PLATFORM_NODE_SMTP_PORT").pipe(Config.withDefault(1025)), defaultFrom: { address: "noreply@voidhash.local", name: "Voidhash" }, verifyOnStart: true, }; @@ -21,13 +19,11 @@ const readConfig = Effect.gen(function* () { const mailerLayer = (adjust: (config: SmtpMailerConfig) => SmtpMailerConfig = (input) => input) => Layer.unwrap( readConfig.pipe( - Effect.map((config) => - Layer.merge(SmtpMailerLive(adjust(config)), SelfhostPlatformRuntimeLive), - ), + Effect.map((config) => Layer.merge(SmtpMailerLive(adjust(config)), NodePlatformRuntimeLive)), ), ); -const mailpitApi = Config.string("PLATFORM_SELFHOST_MAILPIT_API").pipe( +const mailpitApi = Config.string("PLATFORM_NODE_MAILPIT_API").pipe( Config.withDefault("http://127.0.0.1:8025"), Effect.orDie, ); @@ -120,9 +116,7 @@ describe("SMTP mailer", () => { const error = yield* Effect.gen(function* () { const mailer = yield* Mailer; return yield* mailer.send({ to: [], subject: "invalid" }).pipe(Effect.flip); - }).pipe( - Effect.provide(mailerLayer((config) => ({ ...config, verifyOnStart: false }))), - ); + }).pipe(Effect.provide(mailerLayer((config) => ({ ...config, verifyOnStart: false })))); expect(error).toBeInstanceOf(MailerError); expect(error.operation).toBe("validate"); diff --git a/selfhost/platform/tests/cluster.test.ts b/packages/platform/node/tests/cluster.test.ts similarity index 97% rename from selfhost/platform/tests/cluster.test.ts rename to packages/platform/node/tests/cluster.test.ts index dd29f50af..3087f2001 100644 --- a/selfhost/platform/tests/cluster.test.ts +++ b/packages/platform/node/tests/cluster.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from "vitest"; import { ClusterDurableEntityHostLive } from "../src/ClusterDurableEntity.ts"; import { MemoryEntityAlarmStoreLive } from "../src/EntityAlarmStore.ts"; -import { SelfhostPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; import { ClusterQueueLive } from "../src/Queue.ts"; import { TestClusterLive } from "../src/Topology.ts"; import * as ClusterWorkflowRunner from "../src/Workflow.ts"; @@ -19,12 +19,12 @@ const Message = Schema.Struct({ id: Schema.String }); const queueLayer = ClusterQueueLive.pipe( Layer.provide(PersistedQueue.layer), Layer.provide(PersistedQueue.layerStoreMemory), - Layer.merge(SelfhostPlatformRuntimeLive), + Layer.merge(NodePlatformRuntimeLive), ); const workflowLayer = ClusterWorkflowRunner.layer.pipe( Layer.provide(TestClusterLive), - Layer.merge(SelfhostPlatformRuntimeLive), + Layer.merge(NodePlatformRuntimeLive), ); const entityLayer = ClusterDurableEntityHostLive.pipe( diff --git a/selfhost/platform/tests/conformance.test.ts b/packages/platform/node/tests/conformance.test.ts similarity index 91% rename from selfhost/platform/tests/conformance.test.ts rename to packages/platform/node/tests/conformance.test.ts index b0a8c006e..03e39e615 100644 --- a/selfhost/platform/tests/conformance.test.ts +++ b/packages/platform/node/tests/conformance.test.ts @@ -17,7 +17,7 @@ import { makeClusterDurableEntityHost, } from "../src/ClusterDurableEntity.ts"; import { DurableEntityAlarmStore, MemoryEntityAlarmStoreLive } from "../src/EntityAlarmStore.ts"; -import { SelfhostPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; import { ClusterQueueLive } from "../src/Queue.ts"; import { TestClusterLive } from "../src/Topology.ts"; import * as ClusterWorkflowRunner from "../src/Workflow.ts"; @@ -30,14 +30,14 @@ const queueLayer = () => ClusterQueueLive.pipe( Layer.provide(PersistedQueue.layer), Layer.provide(PersistedQueue.layerStoreMemory), - Layer.merge(SelfhostPlatformRuntimeLive), + Layer.merge(NodePlatformRuntimeLive), ); const cronLayer = () => ClusterCronSchedulerLive.pipe( Layer.provide(KeyValueStore.layerMemory), Layer.provide(TestClusterLive), - Layer.merge(SelfhostPlatformRuntimeLive), + Layer.merge(NodePlatformRuntimeLive), ); const entityLayer = () => @@ -77,11 +77,11 @@ const unownedEntityLayer = () => const workflowLayer = () => ClusterWorkflowRunner.layer.pipe( Layer.provide(TestClusterLive), - Layer.merge(SelfhostPlatformRuntimeLive), + Layer.merge(NodePlatformRuntimeLive), ); const memoryWorkflowLayer = () => - MemoryWorkflowRunner.layer.pipe(Layer.merge(SelfhostPlatformRuntimeLive)); + MemoryWorkflowRunner.layer.pipe(Layer.merge(NodePlatformRuntimeLive)); queueDriverConformance({ name: "cluster", layer: queueLayer }); cronSchedulerConformance({ name: "cluster", layer: cronLayer }); diff --git a/selfhost/platform/tsconfig.json b/packages/platform/node/tsconfig.json similarity index 100% rename from selfhost/platform/tsconfig.json rename to packages/platform/node/tsconfig.json diff --git a/selfhost/platform/vitest.integration.mts b/packages/platform/node/vitest.integration.mts similarity index 89% rename from selfhost/platform/vitest.integration.mts rename to packages/platform/node/vitest.integration.mts index 462272a41..4288520e6 100644 --- a/selfhost/platform/vitest.integration.mts +++ b/packages/platform/node/vitest.integration.mts @@ -1,6 +1,6 @@ import { defineConfig } from "vite-plus"; -// Integration tier: runs against the provisioned self-host stack via +// Integration tier: runs against the provisioned Node test fixture via // `pnpm test:integration`. Timeouts are generous because these tests wait on // real containers rather than fakes. // diff --git a/selfhost/platform/vitest.mts b/packages/platform/node/vitest.mts similarity index 100% rename from selfhost/platform/vitest.mts rename to packages/platform/node/vitest.mts diff --git a/packages/web-app/src/vite/define-voidhash-web-config.ts b/packages/web-app/src/vite/define-voidhash-web-config.ts index 2b9c69023..84c2a8d9c 100644 --- a/packages/web-app/src/vite/define-voidhash-web-config.ts +++ b/packages/web-app/src/vite/define-voidhash-web-config.ts @@ -151,7 +151,7 @@ export function defineVoidhashWebConfig(options: VoidhashWebConfigOptions): User const routesDirectory = relative(fileURLToPath(new URL("./src/", options.appRoot)), workspaceRoot) || "."; const ssr: SSROptions | undefined = - process.env.VOIDHASH_SELFHOST_BUNDLE === "true" ? { noExternal: true } : undefined; + process.env.VOIDHASH_NODE_BUNDLE === "true" ? { noExternal: true } : undefined; return defineConfig( () => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05cbb0fbd..d2c7adc45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,6 +254,9 @@ importers: specifier: 'catalog:' version: 6.0.3 devDependencies: + alchemy: + specifier: 'catalog:' + version: 2.0.0-beta.66(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@types/react@19.1.17)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(effect@4.0.0-beta.100)(react-devtools-core@6.1.5)(rollup@4.59.0)(typescript@6.0.3)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1)(ws@8.21.0) dotenv-cli: specifier: ^8.0.0 version: 8.0.0 @@ -277,7 +280,10 @@ importers: version: 4.21.0 vite-plus: specifier: 'catalog:' - version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + wrangler: + specifier: ^4.0.0 + version: 4.114.0(@cloudflare/workers-types@5.20260731.1) zustand: specifier: ^5.0.9 version: 5.0.14(@types/react@19.1.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) @@ -332,9 +338,15 @@ importers: '@voidhash/platform': specifier: workspace:* version: link:../../packages/platform - '@voidhash/platform-selfhost': + '@voidhash/platform-cloudflare': specifier: workspace:* - version: link:../../selfhost/platform + version: link:../../packages/platform/cloudflare + '@voidhash/platform-node': + specifier: workspace:* + version: link:../../packages/platform/node + alchemy: + specifier: 'catalog:' + version: 2.0.0-beta.66(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@types/react@19.1.17)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(effect@4.0.0-beta.100)(react-devtools-core@6.1.5)(rollup@4.59.0)(typescript@6.0.3)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1)(ws@8.21.0) effect: specifier: 4.0.0-beta.100 version: 4.0.0-beta.100 @@ -377,13 +389,13 @@ importers: version: 6.0.3 vite-plus: specifier: 'catalog:' - version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) apps/cli: dependencies: '@better-auth/api-key': specifier: 'catalog:' - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(drizzle-kit@1.0.0-rc.4)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0))(mysql2@3.16.0)(next@16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.6.3)))(better-call@1.3.7(zod@4.3.6)) + version: 1.6.23(c15c58349f2d3d36744031b1f8785632) '@effect/platform-node': specifier: 4.0.0-beta.100 version: 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1) @@ -401,7 +413,7 @@ importers: version: link:../studio better-auth: specifier: 'catalog:' - version: 1.6.23(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(drizzle-kit@1.0.0-rc.4)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0))(mysql2@3.16.0)(next@16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.6.3)) + version: 1.6.23(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0))(mysql2@3.16.0)(next@16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.6.3)) effect: specifier: 4.0.0-beta.100 version: 4.0.0-beta.100 @@ -1080,9 +1092,9 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1) - '@voidhash/platform-selfhost': + '@voidhash/platform-node': specifier: workspace:* - version: link:../../selfhost/platform + version: link:../platform/node '@voidhash/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -1260,16 +1272,16 @@ importers: version: link:../tsconfig alchemy: specifier: 'catalog:' - version: 2.0.0-beta.66(02a466f039edb2b8425f2a7410b99d3a) + version: 2.0.0-beta.66(f09abdaaa75b5aa3c10f8f33d5a1d709) typescript: specifier: 'catalog:' version: 6.0.3 vite-plus: specifier: 'catalog:' - version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) vitest: specifier: 'catalog:' - version: '@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/ui@4.1.0(@voidzero-dev/vite-plus-test@0.1.24))(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' packages/core: dependencies: @@ -1762,6 +1774,71 @@ importers: specifier: 'catalog:' version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + packages/platform/cloudflare: + dependencies: + '@voidhash/db': + specifier: workspace:* + version: link:../../db + '@voidhash/platform': + specifier: workspace:* + version: link:.. + alchemy: + specifier: 'catalog:' + version: 2.0.0-beta.66(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@types/react@19.1.17)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(effect@4.0.0-beta.100)(react-devtools-core@6.1.5)(rollup@4.59.0)(typescript@6.0.3)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1)(ws@8.21.0) + effect: + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 + devDependencies: + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../../tsconfig + typescript: + specifier: 'catalog:' + version: 6.0.3 + + packages/platform/node: + dependencies: + '@effect-aws/client-s3': + specifier: 'catalog:' + version: 2.0.0-beta.4(effect@4.0.0-beta.100) + '@effect/sql-pg': + specifier: 'catalog:' + version: 4.0.0-beta.100(effect@4.0.0-beta.100) + '@voidhash/lib': + specifier: workspace:* + version: link:../../lib + '@voidhash/platform': + specifier: workspace:* + version: link:.. + effect: + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 + nodemailer: + specifier: 'catalog:' + version: 9.0.3 + playwright-core: + specifier: 'catalog:' + version: 1.61.1 + devDependencies: + '@effect/platform-node': + specifier: 'catalog:' + version: 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1) + '@types/node': + specifier: ^24.0.12 + version: 24.10.4 + '@types/nodemailer': + specifier: 'catalog:' + version: 8.0.1 + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../../tsconfig + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + packages/rpc: dependencies: '@voidhash/lib': @@ -2224,49 +2301,6 @@ importers: specifier: 'catalog:' version: '@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/ui@4.1.0(@voidzero-dev/vite-plus-test@0.1.24))(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' - selfhost/platform: - dependencies: - '@effect-aws/client-s3': - specifier: 'catalog:' - version: 2.0.0-beta.4(effect@4.0.0-beta.100) - '@effect/sql-pg': - specifier: 'catalog:' - version: 4.0.0-beta.100(effect@4.0.0-beta.100) - '@voidhash/lib': - specifier: workspace:* - version: link:../../packages/lib - '@voidhash/platform': - specifier: workspace:* - version: link:../../packages/platform - effect: - specifier: 4.0.0-beta.100 - version: 4.0.0-beta.100 - nodemailer: - specifier: 'catalog:' - version: 9.0.3 - playwright-core: - specifier: 'catalog:' - version: 1.61.1 - devDependencies: - '@effect/platform-node': - specifier: 'catalog:' - version: 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1) - '@types/node': - specifier: ^24.0.12 - version: 24.10.4 - '@types/nodemailer': - specifier: 'catalog:' - version: 8.0.1 - '@voidhash/tsconfig': - specifier: workspace:* - version: link:../../packages/tsconfig - typescript: - specifier: 'catalog:' - version: 6.0.3 - vite-plus: - specifier: 'catalog:' - version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) - packages: '@0no-co/graphql.web@1.2.0': @@ -17929,11 +17963,11 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(drizzle-kit@1.0.0-rc.4)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0))(mysql2@3.16.0)(next@16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.6.3)))(better-call@1.3.7(zod@4.3.6))': + '@better-auth/api-key@1.6.23(c15c58349f2d3d36744031b1f8785632)': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 - better-auth: 1.6.23(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(drizzle-kit@1.0.0-rc.4)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0))(mysql2@3.16.0)(next@16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.6.3)) + better-auth: 1.6.23(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0))(mysql2@3.16.0)(next@16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.6.3)) better-call: 1.3.7(zod@4.3.6) zod: 4.3.6 @@ -17952,10 +17986,12 @@ snapshots: '@cloudflare/workers-types': 5.20260731.1 '@opentelemetry/api': 1.9.0 - '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0) '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.28.17)': dependencies: @@ -18209,14 +18245,13 @@ snapshots: '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) effect: 4.0.0-beta.100 - '@distilled.cloud/cloudflare-rolldown-plugin@0.15.0(rolldown@1.1.5)(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1)': + '@distilled.cloud/cloudflare-rolldown-plugin@0.15.0(rolldown@1.1.5)(workerd@1.20260722.1)': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260722.1) magic-string: 0.30.21 unenv: 2.0.0-rc.24 optionalDependencies: rolldown: 1.1.5 - vite: 7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - workerd @@ -18230,13 +18265,12 @@ snapshots: '@effect/platform-bun': 4.0.0-beta.100(effect@4.0.0-beta.100) '@effect/platform-node': 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1) - '@distilled.cloud/cloudflare-vite-plugin@0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1)': + '@distilled.cloud/cloudflare-vite-plugin@0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(workerd@1.20260722.1)': dependencies: '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(workerd@1.20260722.1) '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100) effect: 4.0.0-beta.100 - vite: 7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: '@effect/platform-bun': 4.0.0-beta.100(effect@4.0.0-beta.100) '@effect/platform-node': 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1) @@ -18420,10 +18454,10 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/vitest@4.0.0-beta.100(@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))(effect@4.0.0-beta.100)': + '@effect/vitest@4.0.0-beta.100(@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3))(effect@4.0.0-beta.100)': dependencies: effect: 4.0.0-beta.100 - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/ui@4.1.0(@voidzero-dev/vite-plus-test@0.1.24))(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' '@effect/vitest@4.0.0-beta.100(@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))(effect@4.0.0-beta.100)': dependencies: @@ -19995,7 +20029,7 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.10.0 + '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.1 optional: true @@ -24794,48 +24828,6 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) - es-module-lexer: 1.7.0 - obug: 2.1.3 - pixelmatch: 7.2.0 - pngjs: 7.0.0 - sirv: 3.0.2 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - vite: 7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - ws: 8.21.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 20.19.43 - jsdom: 26.1.0 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@tsdown/css' - - '@tsdown/exe' - - '@vitejs/devtools' - - bufferutil - - esbuild - - jiti - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - yaml - '@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': dependencies: '@standard-schema/spec': 1.1.0 @@ -25294,7 +25286,75 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.66(02a466f039edb2b8425f2a7410b99d3a): + alchemy@2.0.0-beta.66(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@types/react@19.1.17)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(effect@4.0.0-beta.100)(react-devtools-core@6.1.5)(rollup@4.59.0)(typescript@6.0.3)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1)(ws@8.21.0): + dependencies: + '@alchemy.run/node-utils': 0.0.5 + '@aws-sdk/credential-providers': 3.1081.0 + '@clack/prompts': 1.7.0 + '@distilled.cloud/aws': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/axiom': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(workerd@1.20260722.1) + '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100) + '@distilled.cloud/cloudflare-vite-plugin': 0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(workerd@1.20260722.1) + '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/neon': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/planetscale': 0.30.3(effect@4.0.0-beta.100) + '@effect/sql-d1': 4.0.0-beta.102(effect@4.0.0-beta.100) + '@effect/vitest': 4.0.0-beta.100(effect@4.0.0-beta.100)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@libsql/client': 0.17.4 + '@octokit/rest': 22.0.1 + '@octokit/webhooks': 14.2.0 + '@prisma/dev': 0.20.0(typescript@6.0.3) + '@smithy/node-config-provider': 4.5.8 + '@smithy/shared-ini-file-loader': 4.6.8 + '@smithy/types': 4.16.1 + '@types/aws-lambda': 8.10.162 + '@vercel/nft': 1.10.2(rollup@4.59.0) + aws4fetch: 1.0.20 + capnweb: 0.6.1 + effect: 4.0.0-beta.100 + fast-glob: 3.3.3 + fast-xml-parser: 5.10.1 + ink: 6.8.0(@types/react@19.1.17)(react-devtools-core@6.1.5)(react@19.2.7) + jszip: 3.10.1 + libsodium-wrappers: 0.8.4 + mongodb: 6.21.0(@aws-sdk/credential-providers@3.1081.0) + mysql2: 3.16.0 + pathe: 2.0.3 + pg: 8.22.0 + picomatch: 4.0.5 + react: 19.2.7 + rolldown: 1.1.5 + undici: 7.29.0 + yaml: 2.8.3 + optionalDependencies: + '@effect/platform-bun': 4.0.0-beta.100(effect@4.0.0-beta.100) + '@effect/platform-node': 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1) + '@effect/sql-pg': 4.0.0-beta.100(effect@4.0.0-beta.100) + drizzle-kit: 1.0.0-rc.4 + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) + ws: 8.21.0 + transitivePeerDependencies: + - '@mongodb-js/zstd' + - '@types/react' + - bufferutil + - encoding + - gcp-metadata + - kerberos + - mongodb-client-encryption + - pg-native + - react-devtools-core + - rollup + - snappy + - socks + - supports-color + - typescript + - utf-8-validate + - vitest + - workerd + + alchemy@2.0.0-beta.66(f09abdaaa75b5aa3c10f8f33d5a1d709): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1081.0 @@ -25302,14 +25362,14 @@ snapshots: '@distilled.cloud/aws': 0.30.3(effect@4.0.0-beta.100) '@distilled.cloud/axiom': 0.30.3(effect@4.0.0-beta.100) '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(workerd@1.20260722.1) '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100) - '@distilled.cloud/cloudflare-vite-plugin': 0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1) + '@distilled.cloud/cloudflare-vite-plugin': 0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(workerd@1.20260722.1) '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) '@distilled.cloud/neon': 0.30.3(effect@4.0.0-beta.100) '@distilled.cloud/planetscale': 0.30.3(effect@4.0.0-beta.100) '@effect/sql-d1': 4.0.0-beta.102(effect@4.0.0-beta.100) - '@effect/vitest': 4.0.0-beta.100(@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))(effect@4.0.0-beta.100) + '@effect/vitest': 4.0.0-beta.100(@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3))(effect@4.0.0-beta.100) '@libsql/client': 0.17.4 '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 @@ -25342,7 +25402,6 @@ snapshots: '@effect/sql-pg': 4.0.0-beta.100(effect@4.0.0-beta.100) drizzle-kit: 1.0.0-rc.4 drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.3.6) - vite: 7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) ws: 8.21.0 transitivePeerDependencies: - '@mongodb-js/zstd' @@ -25759,10 +25818,10 @@ snapshots: before-after-hook@4.0.0: {} - better-auth@1.6.23(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(drizzle-kit@1.0.0-rc.4)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0))(mysql2@3.16.0)(next@16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.6.3)): + better-auth@1.6.23(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0))(mysql2@3.16.0)(next@16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.6.3)): dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0) - '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)) '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.28.17) '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2) '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260731.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1081.0)) @@ -25780,6 +25839,7 @@ snapshots: zod: 4.3.6 optionalDependencies: drizzle-kit: 1.0.0-rc.4 + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) mongodb: 6.21.0(@aws-sdk/credential-providers@3.1081.0) mysql2: 3.16.0 next: 16.2.11(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -31176,7 +31236,7 @@ snapshots: '@oxfmt/binding-win32-x64-msvc': 0.52.0 vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) - oxfmt@0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): + oxfmt@0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -31199,7 +31259,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.52.0 '@oxfmt/binding-win32-ia32-msvc': 0.52.0 '@oxfmt/binding-win32-x64-msvc': 0.52.0 - vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) oxfmt@0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): dependencies: @@ -31251,6 +31311,31 @@ snapshots: '@oxfmt/binding-win32-x64-msvc': 0.52.0 vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt@0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 + vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + oxfmt@0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.6.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): dependencies: tinypool: 2.1.0 @@ -31437,7 +31522,7 @@ snapshots: oxlint-tsgolint: 0.23.0 vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) - oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.67.0 '@oxlint/binding-android-arm64': 1.67.0 @@ -31459,7 +31544,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.67.0 '@oxlint/binding-win32-x64-msvc': 1.67.0 oxlint-tsgolint: 0.23.0 - vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): optionalDependencies: @@ -31509,6 +31594,30 @@ snapshots: oxlint-tsgolint: 0.23.0 vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 + oxlint-tsgolint: 0.23.0 + vite-plus: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.6.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.67.0 @@ -34716,14 +34825,14 @@ snapshots: - vite - yaml - vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): + vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3): dependencies: '@oxc-project/types': 0.133.0 '@oxlint/plugins': 1.61.0 '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) - '@voidzero-dev/vite-plus-test': 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) - oxfmt: 0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) - oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + '@voidzero-dev/vite-plus-test': 0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/ui@4.1.0(@voidzero-dev/vite-plus-test@0.1.24))(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt: 0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)) oxlint-tsgolint: 0.23.0 optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 @@ -34866,6 +34975,56 @@ snapshots: - vite - yaml + vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3): + dependencies: + '@oxc-project/types': 0.133.0 + '@oxlint/plugins': 1.61.0 + '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt: 0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)) + oxlint-tsgolint: 0.23.0 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.24 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.24 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.24 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.24 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.24 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.24 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.24 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + vite-plus@0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.6.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@oxc-project/types': 0.133.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba601b4f8..91a255087 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,9 @@ packages: - apps/* - packages/* + - packages/platform/* - libraries/* - examples/* - - selfhost/* # just-bash's optional native compression accelerators (node-only; we run # just-bash in the browser, so their build scripts are intentionally skipped). ignoredBuiltDependencies: diff --git a/scripts/check-selfhost-runtime-boundary.mjs b/scripts/check-node-runtime-boundary.mjs similarity index 96% rename from scripts/check-selfhost-runtime-boundary.mjs rename to scripts/check-node-runtime-boundary.mjs index 2ddb06482..a98ba2583 100644 --- a/scripts/check-selfhost-runtime-boundary.mjs +++ b/scripts/check-node-runtime-boundary.mjs @@ -116,21 +116,19 @@ const inspectDeployment = (deploymentRoot) => .sort((left, right) => left.name.localeCompare(right.name)); if (forbidden.length > 0) { return yield* new ForbiddenPackagesError({ - message: `Self-host deployment contains cloud-only packages:\n${forbidden + message: `Node runtime contains Cloudflare-only packages:\n${forbidden .map((pkg) => `- ${pkg.name}@${pkg.version}`) .join("\n")}`, }); } - yield* Console.log( - `Self-host runtime boundary OK — ${installed.size} installed packages checked.`, - ); + yield* Console.log(`Node runtime boundary OK — ${installed.size} installed packages checked.`); }); const deployAndInspect = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const deploymentRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "voidhash-selfhost-boundary-", + prefix: "voidhash-node-runtime-boundary-", }); const exitCode = yield* spawner.exitCode( ChildProcess.make( diff --git a/scripts/check-platform-seam.mjs b/scripts/check-platform-seam.mjs index 7c74d7e50..bd81a929a 100644 --- a/scripts/check-platform-seam.mjs +++ b/scripts/check-platform-seam.mjs @@ -3,11 +3,10 @@ // // Service packages under `packages/**` are portable: they depend on the // provider-neutral contracts in `@voidhash/platform` and never on a concrete -// adapter. `@voidhash/platform-selfhost` is one such adapter, so importing it -// from a package would pin portable code to the Node/PostgreSQL deployment and -// break the Cloud composition that binds different implementations. +// adapter. Importing either concrete adapter from a package would pin portable +// code to a deployment and break the other composition. // -// Compositions choose adapters, so `apps/**` and `selfhost/**` may import it +// Compositions choose adapters, so `apps/**` may import them // freely. Tests are also allowed to bind a concrete adapter — that is how a // port is exercised against something real — but only as a devDependency, so // the adapter never reaches a package's runtime dependency graph. @@ -16,14 +15,17 @@ import { Cause, Console, Data, Effect, FileSystem, Path, Schema, Stream } from " import { ChildProcess } from "effect/unstable/process"; import { fileURLToPath } from "node:url"; -const adapter = "@voidhash/platform-selfhost"; +const adapters = ["@voidhash/platform-cloudflare", "@voidhash/platform-node"]; const sourceLike = /\.[cm]?[jt]sx?$/; const isTestFile = (path) => path.split("/").includes("tests") || /\.test\.[cm]?[jt]sx?$/.test(path) || /(^|\/)vitest\./.test(path); -const importsAdapter = new RegExp(`["']${adapter}(?:/[^"']*)?["']`); +const adapterImports = adapters.map((adapter) => ({ + adapter, + pattern: new RegExp(`["']${adapter}(?:/[^"']*)?["']`), +})); // Only the runtime dependency map matters here; every other manifest field is // irrelevant to the seam and is discarded by the decoder. @@ -82,8 +84,10 @@ const collectFailures = Effect.gen(function* () { !entry.includes("/node_modules/"), )) { const source = yield* fs.readFileString(path.join(repoRoot, candidate), "utf8"); - if (importsAdapter.test(source)) { - failures.push(`${candidate} imports ${adapter}; depend on @voidhash/platform instead`); + for (const { adapter, pattern } of adapterImports) { + if (pattern.test(source)) { + failures.push(`${candidate} imports ${adapter}; depend on @voidhash/platform instead`); + } } } @@ -93,10 +97,12 @@ const collectFailures = Effect.gen(function* () { const manifest = yield* decodeManifest( yield* fs.readFileString(path.join(repoRoot, candidate), "utf8"), ); - if (manifest.dependencies?.[adapter]) { - failures.push( - `${candidate} lists ${adapter} as a runtime dependency; tests may use it as a devDependency`, - ); + for (const adapter of adapters) { + if (manifest.dependencies?.[adapter]) { + failures.push( + `${candidate} lists ${adapter} as a runtime dependency; tests may use it as a devDependency`, + ); + } } } @@ -110,7 +116,7 @@ const main = Effect.gen(function* () { if (failures.length === 0) { return yield* Console.log( - `Platform seam OK — no package outside apps/ and selfhost/ binds ${adapter}.`, + "Platform seam OK — portable packages do not bind a concrete platform adapter.", ); } diff --git a/scripts/check-test-tiers.mjs b/scripts/check-test-tiers.mjs index fb1bff0f8..087cf0821 100644 --- a/scripts/check-test-tiers.mjs +++ b/scripts/check-test-tiers.mjs @@ -1,6 +1,6 @@ // Enforces the repository's test-tier layout. // -// *.integration.test.ts needs the self-host stack; runs under `pnpm test:integration` +// *.integration.test.ts needs the integration fixture; runs under `pnpm test:integration` // *.test.ts needs nothing; runs under `pnpm test` // // The rule this exists to protect: a test's tier follows from its filename, and diff --git a/scripts/integration-suites.mjs b/scripts/integration-suites.mjs index b25aee011..823c841c8 100644 --- a/scripts/integration-suites.mjs +++ b/scripts/integration-suites.mjs @@ -8,7 +8,7 @@ // `*.integration.test.ts` and nothing else. Unit files stay with `pnpm test`; // no suite appears in both. export const integrationSuites = [ - { name: "platform", directory: "selfhost/platform" }, + { name: "platform", directory: "packages/platform/node" }, { name: "backend", directory: "apps/backend" }, { name: "core", directory: "packages/core" }, { name: "backend-smoke", directory: "packages/backend" }, diff --git a/scripts/run-local-integration.mjs b/scripts/run-local-integration.mjs index 42bee6bb6..ac232e2e8 100644 --- a/scripts/run-local-integration.mjs +++ b/scripts/run-local-integration.mjs @@ -1,4 +1,4 @@ -// Runs every integration-capable suite against the local self-host stack. +// Runs every integration-capable suite against the local Node test fixture. // // node scripts/run-local-integration.mjs [suite ...] // @@ -12,7 +12,7 @@ // they build get an isolated database of their own — see `resetPlatformDatabase` // below. // -// The stack is the only prerequisite, and the runner starts it if it is down. +// The fixture is the only prerequisite, and the runner starts it if it is down. import { NodeRuntime, NodeServices } from "@effect/platform-node"; import { Config, Console, Effect, Exit, FileSystem, Path, Runtime, Stdio, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -71,7 +71,7 @@ const defaultChromiumPath = () => { */ const chromiumOverride = (executablePath) => { if (!executablePath) return {}; - return { PLATFORM_SELFHOST_CHROMIUM_EXECUTABLE_PATH: executablePath }; + return { PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH: executablePath }; }; /** @@ -203,7 +203,7 @@ const program = Effect.gen(function* () { const mailpitUiPort = yield* value("MAILPIT_UI_PORT", "8025"); const compilerPort = yield* value("COMPILER_HOST_PORT", "5002"); const chromiumExecutablePath = yield* value( - "PLATFORM_SELFHOST_CHROMIUM_EXECUTABLE_PATH", + "PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH", defaultChromiumPath(), ); @@ -245,21 +245,21 @@ const program = Effect.gen(function* () { // The platform adapter suites and the cluster entity hosts the backend and // agent suites build all run over this connection, which is the isolated // platform database rather than the deployment's. - PLATFORM_SELFHOST_PG_HOST: "127.0.0.1", - PLATFORM_SELFHOST_PG_PORT: databasePort, - PLATFORM_SELFHOST_PG_DATABASE: platformDatabaseName, - PLATFORM_SELFHOST_PG_USERNAME: databaseUsername, - PLATFORM_SELFHOST_PG_PASSWORD: databasePassword, - - PLATFORM_SELFHOST_S3_ENDPOINT: `http://127.0.0.1:${minioPort}`, - PLATFORM_SELFHOST_S3_BUCKET: yield* value("S3_PUBLIC_BUCKET", "voidhash-public"), - PLATFORM_SELFHOST_S3_REGION: yield* value("S3_REGION", "us-east-1"), - PLATFORM_SELFHOST_S3_ACCESS_KEY_ID: yield* value("S3_ACCESS_KEY_ID", "voidhash"), - PLATFORM_SELFHOST_S3_SECRET_ACCESS_KEY: yield* value("S3_SECRET_ACCESS_KEY", "password"), - - PLATFORM_SELFHOST_SMTP_HOST: "127.0.0.1", - PLATFORM_SELFHOST_SMTP_PORT: mailpitSmtpPort, - PLATFORM_SELFHOST_MAILPIT_API: `http://127.0.0.1:${mailpitUiPort}`, + PLATFORM_NODE_PG_HOST: "127.0.0.1", + PLATFORM_NODE_PG_PORT: databasePort, + PLATFORM_NODE_PG_DATABASE: platformDatabaseName, + PLATFORM_NODE_PG_USERNAME: databaseUsername, + PLATFORM_NODE_PG_PASSWORD: databasePassword, + + PLATFORM_NODE_S3_ENDPOINT: `http://127.0.0.1:${minioPort}`, + PLATFORM_NODE_S3_BUCKET: yield* value("S3_PUBLIC_BUCKET", "voidhash-public"), + PLATFORM_NODE_S3_REGION: yield* value("S3_REGION", "us-east-1"), + PLATFORM_NODE_S3_ACCESS_KEY_ID: yield* value("S3_ACCESS_KEY_ID", "voidhash"), + PLATFORM_NODE_S3_SECRET_ACCESS_KEY: yield* value("S3_SECRET_ACCESS_KEY", "password"), + + PLATFORM_NODE_SMTP_HOST: "127.0.0.1", + PLATFORM_NODE_SMTP_PORT: mailpitSmtpPort, + PLATFORM_NODE_MAILPIT_API: `http://127.0.0.1:${mailpitUiPort}`, ...chromiumOverride(chromiumExecutablePath), }; @@ -279,14 +279,14 @@ const program = Effect.gen(function* () { const composeArgs = [ "compose", "-f", - "selfhost/docker-compose.yml", + "test/integration/docker-compose.yml", "-f", - "selfhost/docker-compose.dev.yml", - // `--project-directory selfhost` keeps the image build context correct, which - // also moves Compose's implicit env-file lookup away from the repo root. + "test/integration/docker-compose.dev.yml", + // The explicit project directory keeps image build contexts and Compose's + // implicit env-file lookup stable. ...envFileArgs(yield* fileSystem.exists(envFile)), "--project-directory", - "selfhost", + "test/integration", ]; // `.env` is the deployment template, so it selects production mode. The stack @@ -314,7 +314,7 @@ const program = Effect.gen(function* () { } // The suites need the stack, so the runner provisions it rather than failing on - // a forgotten `pnpm stack:up`. `compose up` is idempotent: already-healthy + // a forgotten `pnpm test:infra:up`. `compose up` is idempotent: already-healthy // services are left alone. A missing prerequisite must never look like a pass, // so anything unrecoverable here exits non-zero with the command to run. const probe = yield* runCaptured( @@ -329,7 +329,7 @@ const program = Effect.gen(function* () { const running = probe.stdout.split("\n").filter((line) => line.trim().endsWith("running")).length; if (running === 0) { yield* Console.log( - "Self-host stack is not running — starting it (first run builds images)…", + "Integration fixture is not running — starting it (first run builds images)…", ); const up = yield* runInherit( compose(["up", "-d", "--build", "--wait"], { @@ -340,7 +340,7 @@ const program = Effect.gen(function* () { ); if (up !== 0) { yield* Console.error( - "\nFailed to start the self-host stack. Run `pnpm stack:up` to debug.", + "\nFailed to start the integration fixture. Run `pnpm test:infra:up` to debug.", ); return 1; } diff --git a/selfhost/LICENSE.md b/selfhost/LICENSE.md deleted file mode 100644 index be3f7b28e..000000000 --- a/selfhost/LICENSE.md +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/selfhost/README.md b/selfhost/README.md deleted file mode 100644 index 0020d05fc..000000000 --- a/selfhost/README.md +++ /dev/null @@ -1,269 +0,0 @@ -# Voidhash self-host - -The Compose composition runs the backend API and persistent Mimic RPC/WebSocket -host in one Node process. PostgreSQL backs application data, entity state, -queues, workflows, key-value storage, and cron leases; MinIO provides the two -S3-compatible object stores. A private-network Node sidecar safely contains -component compilation and manifest extraction. The application image includes -headless Chromium, so an edited paywall is rendered to a persistent public PNG -after its Mimic WebSocket session becomes idle. The stack uses the same -application services and platform contracts as the Cloudflare composition. -Community analytics capture and revenue insights are stored in PostgreSQL. - -## Local development - -The stack doubles as the default development environment. The dev overlay -publishes Postgres and the compiler to the host so tests and tooling reach the -same services the app uses: - -```sh -pnpm verify # everything CI checks — run before pushing -pnpm verify:quick # typecheck + unit tier, for tight loops -``` - -`pnpm verify` is the whole contract: boundary checks, typecheck, and the three -test tiers. Nothing else needs remembering, and CI runs the same scripts — -Repository CI runs `verify:quick`, and the Self-host Compose workflow runs the -two stack-backed tiers. - -| Tier | Command | Selects | Needs the stack | -| --- | --- | --- | --- | -| Unit | `pnpm test` | `*.test.ts` | no | -| Integration | `pnpm test:integration` | `*.integration.test.ts` | yes | -| End-to-end | `pnpm test:e2e` | `selfhost/smoke.mts` | yes | - -A test's tier is decided by its filename alone — no environment flag gates a -test, so a test either runs or its tier fails loudly. `pnpm test:integration` -starts the stack if it is down and creates `.env` from `.env.example` on a -first checkout, so there is no separate setup step to forget. It then reads -`.env`, derives host-side connection settings (container hostnames become -`127.0.0.1` plus the published port), and runs the suites one after another — -they share one PostgreSQL database, so parallel runs would race on schema -setup. Pass suite names to narrow the run, for example -`pnpm test:integration platform backend`. - -Several suites build a durable cluster of their own to exercise it — the -platform adapters, the backend workflow and queue compositions, and the agent -session host. A single-node cluster claims *every* shard in the database it is -built over, so a suite sharing the running app's database would steal the -messages addressed to it and vice versa. `pnpm test:integration` therefore -recreates a `_platform_test` database before the suites start and -points `DATABASE_PLATFORM_NAME` and the `PLATFORM_SELFHOST_PG_*` test -connection at it. Application tables are untouched: a suite that asserts on -app-visible state still reads and writes the deployment's database, over a -second connection. Recreating rather than reusing the database also guarantees -no run inherits the undeliverable messages described under -[renaming or removing a cron job](#renaming-or-removing-a-cron-job). - -`pnpm stack:up` and `pnpm stack:down` manage the stack directly when you want -it running outside a test run. `pnpm test:e2e:release` is the heavier -release-grade smoke described below; it is gated on releases rather than -included in `verify`. - -In the production compose file PostgreSQL stays unpublished and the compiler -is reachable only on its internal network; only the dev overlay -(`docker-compose.dev.yml`) exposes them. - -## Start - -The whole stack, including the dashboard and its sign-in flow, runs with no -configuration at all: - -```sh -docker compose -f selfhost/docker-compose.yml up --build --wait -``` - -Open `http://localhost:5001` and sign in as `root` with the password -`voidhash`. - -## Authentication - -Voidhash self-host is **single-player**: it has exactly one account, the root -user, whose credentials come from the environment. There is no sign-up, no -invitation, and no way to create a second user. - -| Variable | Purpose | -| --- | --- | -| `VOIDHASH_ROOT_USERNAME` | Root login name. Evaluation default `root`. | -| `VOIDHASH_ROOT_PASSWORD` | Root password. Evaluation default `voidhash`. | -| `VOIDHASH_ROOT_EMAIL` | Root address. Defaults to `root@voidhash.local`. | -| `VOIDHASH_AUTH_SECRET` | Signs session tokens. | - -Sign-in verifies the credentials in constant time and mints a signed token, -carried in a `vh-session` cookie and accepted as an `Authorization: Bearer` -token by the API. The user row is created on first use. Organizations, -projects, paywalls, the designer, API keys, and the agent all work; single-user -means one operator, not one organization. There is no SSO, no self-service -password reset, and no external identity service to configure — rotating the -password is an edit to your environment and a restart. - -Unlike the earlier development-only provider, this one is meant for real -deployments. What production mode refuses is running it on the *documented -evaluation defaults*: the no-env quick start selects -`SELFHOST_MODE=local-evaluation` inside Compose and is safe only on loopback, -because its root password and signing secret are public knowledge. -`.env.example` selects `SELFHOST_MODE=production`; in that mode the migration -and application refuse to start unless the root credentials, signing secret, -database, object-store, and Mimic credentials are all real, -and they require HTTPS public, file, and Mimic URLs. Keep production mode -enabled for every network-accessible deployment. - -The dashboard and API share `http://localhost:5001`; both `GET /health` and -`GET /api/health` report readiness, and the OpenAPI document is available at -`/api/docs/openapi.json`. MinIO exposes its S3 API at `http://localhost:9000` -and its console at `http://localhost:9001`. Local email is captured by Mailpit, -whose inbox is at `http://localhost:8025`; configure the SMTP variables in -`.env` to use an external delivery service. The Node runtime verifies -the configured SMTP transport at startup in Compose. PostgreSQL stays on the -private Compose network and is not published to the host. -When running the Node entry outside its image, set `CHROMIUM_EXECUTABLE_PATH` to -a compatible Chromium executable to enable paywall thumbnails; the remaining -runtime stays available when it is unset. - -### Agent models - -Durable designer-agent sessions use your own OpenAI or Anthropic credentials. -Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in `.env`; OpenAI is selected -when both are present. The default models are `gpt-5.4` and -`claude-sonnet-4-6`, respectively. Override text and vision routing with -`VOIDHASH_AGENT_MODEL_PROVIDER`, `VOIDHASH_AGENT_MODEL_ID`, -`VOIDHASH_AGENT_VISION_MODEL_PROVIDER`, and -`VOIDHASH_AGENT_VISION_MODEL_ID`. An OpenAI-compatible deployment can also set -`OPENAI_BASE_URL` while keeping the provider id `openai`. - -### Platform composition - -`@voidhash/platform-selfhost` backs every platform primitive, and there is -nothing to select: a self-host deployment has exactly one composition. - -Durable execution runs on Effect Cluster over Postgres, as a single-node -cluster: queues become persisted queues, workflows become cluster workflow -entities, cron slots become persisted cluster singletons, and durable entities -run on the cluster entity host. Those tables are created on first boot and need -no extra service. - -Platform state lives beside application data by default, which is the shape a -deployment wants. `DATABASE_PLATFORM_HOST`, `DATABASE_PLATFORM_PORT`, -`DATABASE_PLATFORM_NAME`, `DATABASE_PLATFORM_USERNAME`, -`DATABASE_PLATFORM_PASSWORD`, and `DATABASE_PLATFORM_SSL` move it elsewhere; -each falls back to its `DATABASE_*` counterpart, so setting only the name is -enough to put it in another database on the same server. The reason it is -separable at all is shard ownership: a single-node cluster claims every shard in -its database, so two processes over one database steal each other's messages. -Nothing about a deployment needs that, but a test process running beside a live -deployment does — see the integration tier above. - -The remaining primitives are plain clients against the services the stack -already runs: the typed key-value store, the mailer, the object store, and the -screenshot renderer. - -#### Durable entity sessions - -Collaborative documents and agent sessions attach live WebSockets to a durable -entity. A socket is held open by one process and cannot be serialized into a -cluster message, so entity sessions are only reachable on the runner that owns -the entity's shard. The single-runner topology this deployment ships owns every -shard, which makes that condition true by construction; attaching a session to a -shard this runner does not own fails loudly rather than silently dropping the -socket from later broadcasts. Running several runners would need a socket -gateway that fans broadcasts out to the process holding each connection — that -is deliberately not built. - -Entity alarms live in `platform_entity_alarms`, indexed by scheduled time, and -the entry process polls it to fire due alarms. - -**Upgrading from an earlier self-host build:** durable entity values moved off -the `platform_entity_kv` table onto the shared Effect persistence key-value -store. Existing rows in `platform_entity_kv` are *not* migrated and are no -longer read; alarms in `platform_entity_alarms` carry over unchanged. In -practice the affected state is per-document idle-notification bookkeeping and -agent session transcripts written by the old Postgres entity host. Drop -`platform_entity_kv` once you no longer need it for reference. - -#### Renaming or removing a cron job - -Every scheduled job is a cluster singleton addressed by its job name, and its -due slots are durable messages in the `cluster_messages` table. A -message addressed to a job name that no process registers has nowhere to go, and -it is never discarded: the storage read loop retries it forever, logging - -``` -Could not find entity manager for address, retrying -``` - -in a tight loop. That loop does not just spin — it starves the rest of the -cluster runner, so unrelated workflows and queue consumers stall until they time -out. It is a stale-address problem, not a load problem: the symptom appears -after a process exits with pending slots and the next process no longer -registers those exact names. - -Renaming or deleting a scheduled job therefore needs a deliberate cleanup step. -There is no automatic reconciliation — expiring messages by address would mean -deleting rows the cluster runtime owns from outside it. Drain the undeliverable -messages instead: - -```sql -DELETE FROM cluster_messages WHERE entity_type = 'ClusterCron/'; -``` - -Each job is its own entity type, so `entity_type LIKE 'ClusterCron/%'` clears -every schedule at once when you do not know which name went stale. Do it while -no runner is live. Slots are re-armed from the persisted cron state on the next -boot, so nothing is lost beyond the missed occurrences. - -### Google Play RTDN - -Google Play Real-time developer notifications must use an authenticated Pub/Sub -push subscription. Configure its push authentication service account and token -audience, then set `GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT_EMAIL` and -`GOOGLE_PUBSUB_PUSH_AUDIENCE` to those exact values in `.env`. The push -endpoint is -`/api/v1/webhook-endpoints/google-play-rtdn/{paymentProviderConfigurationId}`. -The backend verifies Google's signature, issuer, expiration, audience, verified -email claim, and service-account identity before reading the Pub/Sub envelope; -missing authentication is rejected and missing server configuration fails -closed with a retryable response. - -## Smoke test - -From a workspace checkout with dependencies installed: - -```sh -pnpm test:e2e -``` - -The smoke test creates a database, collection, and document through the public -SDK, mints a document token, authenticates over WebSocket, verifies the initial -snapshot, submits a transaction, and removes its fixtures. It also verifies the -shared dashboard, the Community capability response, and app health, proving that -the backend route graph, PostgreSQL analytics capture, and durable workflow runner boot. - -To include an authenticated model-backed agent round-trip, set -`SELFHOST_AGENT_SMOKE_BEARER_TOKEN`, `SELFHOST_AGENT_SMOKE_ORGANIZATION_ID`, and -`SELFHOST_AGENT_SMOKE_PROJECT_ID` before running the smoke test. Optionally set -`SELFHOST_AGENT_SMOKE_PAYWALL_ID` to exercise a designer-scoped session. The -credentials must identify a user with access to that project, and the runtime -must have one of the agent provider keys configured. - -The release-grade smoke additionally requires Docker access from the checkout: - -```sh -docker compose -f selfhost/docker-compose.yml up --build --wait -pnpm test:e2e:release -``` - -It creates an isolated project and paywall in PostgreSQL, provisions and edits -the paywall document through the public Mimic SDK and WebSocket surface, waits -for Chromium to publish its PNG through the public file route, creates and -publishes an immutable visual release, resolves that release through the SDK -endpoint, and fetches the rendered HTML. It then submits an event through the -capture API and verifies that event in PostgreSQL. The release CI runs both -smoke levels from a clean Compose stack. - -## Stop - -```sh -docker compose -f selfhost/docker-compose.yml down -``` - -Add `-v` only when you also intend to delete the Postgres volume. diff --git a/selfhost/docker-compose.dev.yml b/test/integration/docker-compose.dev.yml similarity index 53% rename from selfhost/docker-compose.dev.yml rename to test/integration/docker-compose.dev.yml index c082c103c..b298cce9f 100644 --- a/selfhost/docker-compose.dev.yml +++ b/test/integration/docker-compose.dev.yml @@ -1,11 +1,12 @@ -# Development overlay for the self-host stack. +# Host-port overlay for the Node integration fixture. # -# The production compose keeps Postgres unpublished and the compiler on an -# internal-only network. Local development runs tests and tooling on the host -# against the same stack, so this overlay publishes both: +# The base fixture keeps Postgres unpublished and the compiler on an +# internal-only network. Integration tests run on the host, so this overlay +# publishes both: # -# docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml \ -# --env-file .env --profile analytics up -d --build +# docker compose -f test/integration/docker-compose.yml \ +# -f test/integration/docker-compose.dev.yml \ +# --env-file .env up -d --build # # Host ports come from the repo-root `.env` (see `.env.example`); every default # matches what diff --git a/selfhost/docker-compose.yml b/test/integration/docker-compose.yml similarity index 98% rename from selfhost/docker-compose.yml rename to test/integration/docker-compose.yml index 42de86d31..24c1acf97 100644 --- a/selfhost/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -1,9 +1,9 @@ -name: voidhash-selfhost +name: voidhash-integration x-backend-image: &backend-image - image: ${VOIDHASH_SELFHOST_IMAGE:-voidhash-selfhost:local} + image: ${VOIDHASH_INTEGRATION_IMAGE:-voidhash-integration:local} build: - context: .. + context: ../.. dockerfile: apps/backend/Dockerfile services: diff --git a/selfhost/release-smoke.mts b/test/integration/release-smoke.mts similarity index 98% rename from selfhost/release-smoke.mts rename to test/integration/release-smoke.mts index eca6aac1e..f51fff130 100644 --- a/selfhost/release-smoke.mts +++ b/test/integration/release-smoke.mts @@ -5,12 +5,12 @@ import { Clock, Config, Console, Crypto, Data, DateTime, Effect, Schema, Stream import { FetchHttpClient, HttpBody, HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { causeMessage } from "../packages/lib/src/lang/index.ts"; +import { causeMessage } from "../../packages/lib/src/lang/index.ts"; import { createInitialPaywallDocumentInput, PaywallDesignerDocument, -} from "../packages/mimic-schema/src/index.ts"; -import { MimicSDK } from "../packages/mimic-server/src/index.ts"; +} from "../../packages/mimic-schema/src/index.ts"; +import { MimicSDK } from "../../packages/mimic-server/src/index.ts"; import WebSocket from "ws"; const socketText = (data: Buffer | ArrayBuffer | Buffer[]): string => { @@ -51,7 +51,6 @@ const CaptureResult = Schema.Struct({ rejected: Schema.Number, }); - const SocketMessage = Schema.Struct({ type: Schema.optional(Schema.String), version: Schema.optional(Schema.Number), @@ -499,6 +498,4 @@ const program = Effect.gen(function* () { yield* smoke.pipe(Effect.ensuring(cleanup)); }); -NodeRuntime.runMain( - program.pipe(Effect.provide([NodeServices.layer, FetchHttpClient.layer])), -); +NodeRuntime.runMain(program.pipe(Effect.provide([NodeServices.layer, FetchHttpClient.layer]))); diff --git a/selfhost/smoke.mts b/test/integration/smoke.mts similarity index 93% rename from selfhost/smoke.mts rename to test/integration/smoke.mts index 1f3fd0979..560cbd499 100644 --- a/selfhost/smoke.mts +++ b/test/integration/smoke.mts @@ -2,9 +2,9 @@ import { NodeRuntime, NodeServices } from "@effect/platform-node"; import { Clock, Config, Console, Crypto, Data, Effect, Schema } from "effect"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; -import { causeMessage, stringOr } from "../packages/lib/src/lang/index.ts"; -import { Primitive } from "../packages/mimic-core/src/index.ts"; -import { MimicSDK } from "../packages/mimic-server/src/index.ts"; +import { causeMessage, stringOr } from "../../packages/lib/src/lang/index.ts"; +import { Primitive } from "../../packages/mimic-core/src/index.ts"; +import { MimicSDK } from "../../packages/mimic-server/src/index.ts"; import WebSocket from "ws"; class SmokeError extends Data.TaggedError("SmokeError")<{ @@ -21,9 +21,7 @@ const SocketMessage = Schema.Struct({ const AgentFrame = Schema.Struct({ type: Schema.optional(Schema.Unknown), message: Schema.optional(Schema.Unknown), - event: Schema.optional( - Schema.NullOr(Schema.Struct({ type: Schema.optional(Schema.Unknown) })), - ), + event: Schema.optional(Schema.NullOr(Schema.Struct({ type: Schema.optional(Schema.Unknown) }))), }); /** JSON text for WebSocket frames that were previously `JSON.stringify`d. */ @@ -139,9 +137,7 @@ const authenticateSocket = ( Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => - Effect.fail( - new SmokeError({ message: "Timed out waiting for the WebSocket transaction" }), - ), + Effect.fail(new SmokeError({ message: "Timed out waiting for the WebSocket transaction" })), }), ); @@ -153,9 +149,7 @@ const smokeAgentSession = (url: string) => Effect.gen(function* () { const platformCrypto = yield* Crypto.Crypto; const token = (yield* envString("SELFHOST_AGENT_SMOKE_BEARER_TOKEN", "")).trim(); - const organizationId = ( - yield* envString("SELFHOST_AGENT_SMOKE_ORGANIZATION_ID", "") - ).trim(); + const organizationId = (yield* envString("SELFHOST_AGENT_SMOKE_ORGANIZATION_ID", "")).trim(); const projectId = (yield* envString("SELFHOST_AGENT_SMOKE_PROJECT_ID", "")).trim(); if (!token && !organizationId && !projectId) return; if (!token || !organizationId || !projectId) { @@ -232,7 +226,7 @@ const program = Effect.gen(function* () { const database = yield* attempt("Create database", () => sdk.createDatabase({ name: `compose-smoke-${databaseSuffix}`, - description: "self-host compose smoke", + description: "Node integration smoke", }), ); const documentShape = () => Primitive.Struct({ title: Primitive.String().required() }); @@ -284,9 +278,7 @@ const program = Effect.gen(function* () { if (createdDocumentId) { yield* attempt("Delete document", () => collection.delete(createdDocumentId)); } - yield* attempt("Delete collection", () => - database.deleteCollection(createdCollectionId), - ); + yield* attempt("Delete collection", () => database.deleteCollection(createdCollectionId)); } yield* attempt("Delete database", () => sdk.deleteDatabase(database.id)); yield* attempt("Dispose the mimic SDK", () => sdk.dispose()); From a86eb1e6e505645c8f0862afc82fa39ad1473d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 10 Aug 2026 12:21:33 +0200 Subject: [PATCH 2/5] fix: update Node integration workflow paths --- .github/workflows/selfhost.yml | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml index 7bd1110ec..41d122f2d 100644 --- a/.github/workflows/selfhost.yml +++ b/.github/workflows/selfhost.yml @@ -1,4 +1,4 @@ -name: Self-host Compose +name: Node Integration Fixture on: pull_request: @@ -12,11 +12,11 @@ permissions: contents: read concurrency: - group: selfhost-compose-${{ github.head_ref || github.ref }} + group: node-integration-${{ github.head_ref || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: - COMPOSE_PROJECT_NAME: voidhash-selfhost-ci-${{ github.run_id }}-${{ github.run_attempt }} + COMPOSE_PROJECT_NAME: voidhash-node-ci-${{ github.run_id }}-${{ github.run_attempt }} jobs: smoke: @@ -54,24 +54,20 @@ jobs: - name: Prepare the stack environment run: | cp .env.example .env - # `.env.example` is a deployment template, so it selects production - # mode, which refuses its own placeholder secrets. This is a loopback - # CI stack: `pnpm stack:up` forces the same mode locally. - sed -i 's|^SELFHOST_MODE=.*|SELFHOST_MODE=local-evaluation|' .env # The thumbnail assertions wait on the idle debounce. sed -i 's|^MIMIC_DOCUMENT_IDLE_NOTIFY_DEBOUNCE_MS=.*|MIMIC_DOCUMENT_IDLE_NOTIFY_DEBOUNCE_MS=250|' .env grep -E '^[A-Z][A-Z0-9_]*=' .env >> "$GITHUB_ENV" - name: Start stateful stores - run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env up -d minio --wait --wait-timeout 180 + run: docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --env-file .env --project-directory test/integration up -d minio --wait --wait-timeout 180 - name: Initialize object store - run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env run --rm minio-init + run: docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --env-file .env --project-directory test/integration run --rm minio-init # The dev overlay publishes PostgreSQL and the compiler, which the # host-side integration tier connects to. - name: Build and start Community Compose - run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env up --build --wait --wait-timeout 180 + run: docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --env-file .env --project-directory test/integration up --build --wait --wait-timeout 180 - name: Reclaim image build cache run: docker builder prune --all --force @@ -95,9 +91,9 @@ jobs: - name: Show Compose diagnostics if: always() run: | - docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml ps || true - docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml logs --no-color || true + docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --project-directory test/integration ps || true + docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --project-directory test/integration logs --no-color || true - name: Stop Compose if: always() - run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml down --volumes --remove-orphans + run: docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --project-directory test/integration down --volumes --remove-orphans From c4416442a0ea266a44d7109b04a32850d22e6836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 10 Aug 2026 12:50:55 +0200 Subject: [PATCH 3/5] fix: preserve Node deployment boundaries --- apps/backend/package.json | 4 ++-- apps/backend/workers/BackendWorker.ts | 2 +- docs/cloudflare-deployment.md | 2 +- package.json | 2 -- pnpm-lock.yaml | 12 ++++++------ 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index f528a10f1..7e5b6c78f 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,9 +38,7 @@ "@voidhash/paywall-renderer-web-core": "workspace:*", "@voidhash/paywalls": "workspace:*", "@voidhash/platform": "workspace:*", - "@voidhash/platform-cloudflare": "workspace:*", "@voidhash/platform-node": "workspace:*", - "alchemy": "catalog:", "effect": "catalog:", "esbuild": "^0.25.10", "jose": "catalog:", @@ -55,7 +53,9 @@ "@types/node": "^24.0.12", "@types/ws": "^8.18.1", "@voidhash/mimic-core": "workspace:*", + "@voidhash/platform-cloudflare": "workspace:*", "@voidhash/tsconfig": "workspace:*", + "alchemy": "catalog:", "typescript": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/backend/workers/BackendWorker.ts b/apps/backend/workers/BackendWorker.ts index 92b553d44..651eb76cb 100644 --- a/apps/backend/workers/BackendWorker.ts +++ b/apps/backend/workers/BackendWorker.ts @@ -122,7 +122,7 @@ export default Cloudflare.Worker( dev: { host: "0.0.0.0", port: 8787, strictPort: true }, env: workerEnvironment(publicBaseUrl), }; - }), + }).pipe(Effect.orDie), Effect.gen(function* () { const planContext = Option.getOrUndefined(yield* Effect.serviceOption(Alchemy.AlchemyContext)); const environment = Option.getOrUndefined( diff --git a/docs/cloudflare-deployment.md b/docs/cloudflare-deployment.md index 65dfae629..7a70be4fd 100644 --- a/docs/cloudflare-deployment.md +++ b/docs/cloudflare-deployment.md @@ -37,7 +37,7 @@ Run migrations against the configured origin, then deploy: ```sh pnpm db:migrate -pnpm deploy -- --stage production +pnpm alchemy deploy --stage production ``` `PAYWALL_PUBLIC_BASE_URL` defaults to `https://`. Set diff --git a/package.json b/package.json index 7b31caac2..693bee60f 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,6 @@ "scripts": { "build": "turbo build", "dev": "pnpm alchemy dev", - "deploy": "pnpm alchemy deploy", - "destroy": "pnpm alchemy destroy", "dev:doctor": "portless doctor", "dev:status": "portless list", "clean": "turbo clean && rm -rf node_modules", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2c7adc45..22d7b03e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,15 +338,9 @@ importers: '@voidhash/platform': specifier: workspace:* version: link:../../packages/platform - '@voidhash/platform-cloudflare': - specifier: workspace:* - version: link:../../packages/platform/cloudflare '@voidhash/platform-node': specifier: workspace:* version: link:../../packages/platform/node - alchemy: - specifier: 'catalog:' - version: 2.0.0-beta.66(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@types/react@19.1.17)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(effect@4.0.0-beta.100)(react-devtools-core@6.1.5)(rollup@4.59.0)(typescript@6.0.3)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1)(ws@8.21.0) effect: specifier: 4.0.0-beta.100 version: 4.0.0-beta.100 @@ -381,9 +375,15 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + '@voidhash/platform-cloudflare': + specifier: workspace:* + version: link:../../packages/platform/cloudflare '@voidhash/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig + alchemy: + specifier: 'catalog:' + version: 2.0.0-beta.66(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@types/react@19.1.17)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20260731.1)(@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100))(@effect/sql-pg@4.0.0-beta.100(effect@4.0.0-beta.100))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.0)(@sinclair/typebox@0.34.41)(@types/pg@8.20.0)(bun-types@1.3.14)(effect@4.0.0-beta.100)(mysql2@3.16.0)(pg@8.22.0)(sql.js@1.14.1)(typebox@1.1.38)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3))(effect@4.0.0-beta.100)(react-devtools-core@6.1.5)(rollup@4.59.0)(typescript@6.0.3)(vitest@3.2.7(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260722.1)(ws@8.21.0) typescript: specifier: 'catalog:' version: 6.0.3 From 6f53326da7be1e178d96f1066f9ac8a126d8c805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 10 Aug 2026 13:01:26 +0200 Subject: [PATCH 4/5] fix: use portable Worker inputs --- apps/backend/workers/BackendWorker.ts | 70 ++++++++++++--------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/apps/backend/workers/BackendWorker.ts b/apps/backend/workers/BackendWorker.ts index 651eb76cb..bbedb6f7f 100644 --- a/apps/backend/workers/BackendWorker.ts +++ b/apps/backend/workers/BackendWorker.ts @@ -56,15 +56,26 @@ import { ProjectSchemaCacheLive } from "../infrastructure/ProjectSchemaCache.ts" import { PaywallArtifactsBucket } from "../r2/PaywallArtifactsBucket.ts"; import { PublicFileStorageBucket } from "../r2/PublicFileStorageBucket.ts"; -const paywallPublicBaseUrl = (fallback: string | undefined): Config.Config => { - const configured = Config.string("PAYWALL_PUBLIC_BASE_URL"); - return Option.match(Option.fromNullishOr(fallback), { - onNone: () => configured, - onSome: (value) => configured.pipe(Config.withDefault(value)), - }); -}; +const backendDeployment = Effect.gen(function* () { + const planContext = Option.getOrUndefined(yield* Effect.serviceOption(Alchemy.AlchemyContext)); + const dev = planContext?.dev === true; + const configuredDomain = yield* CommunityBackendDomain; + const domain: string | undefined = dev ? undefined : configuredDomain; -const workerEnvironment = (publicBaseUrl: string | undefined) => ({ + return { + domain, + publicBaseUrl: + domain === undefined ? (dev ? "http://localhost:8787" : undefined) : `https://${domain}`, + }; +}).pipe(Effect.orDie); + +const paywallPublicBaseUrl = (fallback: Effect.Effect) => + Effect.flatMap(fallback, (value) => { + const configured = Config.string("PAYWALL_PUBLIC_BASE_URL"); + return value === undefined ? configured : configured.pipe(Config.withDefault(value)); + }).pipe(Effect.orDie); + +const workerEnvironment = (publicBaseUrl: Effect.Effect) => ({ APNS_DELIVERY_ENABLED: Config.string("APNS_DELIVERY_ENABLED").pipe(Config.withDefault("false")), ENCRYPTION_KEY: Config.redacted("ENCRYPTION_KEY").pipe(Config.withDefault(Redacted.make(""))), EXCHANGE_RATE_API_KEY: Config.redacted("EXCHANGE_RATE_API_KEY").pipe( @@ -93,36 +104,19 @@ const workerEnvironment = (publicBaseUrl: string | undefined) => ({ */ export default Cloudflare.Worker( "CommunityBackend", - Effect.gen(function* () { - const planContext = Option.getOrUndefined(yield* Effect.serviceOption(Alchemy.AlchemyContext)); - const dev = planContext?.dev === true; - const configuredDomain = yield* CommunityBackendDomain; - const domain: string | undefined = Match.value(dev).pipe( - Match.when(true, () => undefined), - Match.orElse(() => configuredDomain), - ); - const domainOption: Option.Option = Option.fromNullishOr(domain); - const publicBaseUrl = Option.match(domainOption, { - onNone: () => - Match.value(dev).pipe( - Match.when(true, () => "http://localhost:8787"), - Match.orElse(() => undefined), - ), - onSome: (value) => `https://${value}`, - }); - - return { - main: import.meta.filename, - domain, - workersDev: { - enabled: yield* CommunityWorkersDevEnabled, - previewsEnabled: false, - }, - compatibility: { date: "2026-03-17", flags: ["nodejs_compat"] }, - dev: { host: "0.0.0.0", port: 8787, strictPort: true }, - env: workerEnvironment(publicBaseUrl), - }; - }).pipe(Effect.orDie), + { + main: import.meta.filename, + domain: backendDeployment.pipe(Effect.map(({ domain }) => domain)), + workersDev: { + enabled: CommunityWorkersDevEnabled, + previewsEnabled: false, + }, + compatibility: { date: "2026-03-17", flags: ["nodejs_compat"] }, + dev: { host: "0.0.0.0", port: 8787, strictPort: true }, + env: workerEnvironment( + backendDeployment.pipe(Effect.map(({ publicBaseUrl }) => publicBaseUrl)), + ), + }, Effect.gen(function* () { const planContext = Option.getOrUndefined(yield* Effect.serviceOption(Alchemy.AlchemyContext)); const environment = Option.getOrUndefined( From d4f9f022cc607f654a0de48b807e07c0095c4135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 10 Aug 2026 13:13:18 +0200 Subject: [PATCH 5/5] fix: follow Effect matching conventions --- apps/backend/workers/BackendWorker.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/backend/workers/BackendWorker.ts b/apps/backend/workers/BackendWorker.ts index bbedb6f7f..30325cba3 100644 --- a/apps/backend/workers/BackendWorker.ts +++ b/apps/backend/workers/BackendWorker.ts @@ -60,19 +60,31 @@ const backendDeployment = Effect.gen(function* () { const planContext = Option.getOrUndefined(yield* Effect.serviceOption(Alchemy.AlchemyContext)); const dev = planContext?.dev === true; const configuredDomain = yield* CommunityBackendDomain; - const domain: string | undefined = dev ? undefined : configuredDomain; + const domain: string | undefined = Match.value(dev).pipe( + Match.when(true, () => undefined), + Match.orElse(() => configuredDomain), + ); return { domain, - publicBaseUrl: - domain === undefined ? (dev ? "http://localhost:8787" : undefined) : `https://${domain}`, + publicBaseUrl: Option.match(Option.fromNullishOr(domain), { + onNone: () => + Match.value(dev).pipe( + Match.when(true, () => "http://localhost:8787"), + Match.orElse(() => undefined), + ), + onSome: (value) => `https://${value}`, + }), }; }).pipe(Effect.orDie); const paywallPublicBaseUrl = (fallback: Effect.Effect) => Effect.flatMap(fallback, (value) => { const configured = Config.string("PAYWALL_PUBLIC_BASE_URL"); - return value === undefined ? configured : configured.pipe(Config.withDefault(value)); + return Option.match(Option.fromNullishOr(value), { + onNone: () => configured, + onSome: (fallbackValue) => configured.pipe(Config.withDefault(fallbackValue)), + }); }).pipe(Effect.orDie); const workerEnvironment = (publicBaseUrl: Effect.Effect) => ({