diff --git a/.env.example b/.env.example index 62f16c557..51fc45f67 100644 --- a/.env.example +++ b/.env.example @@ -1,70 +1,78 @@ -# 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 +# `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 DATABASE_USERNAME=voidhash -DATABASE_PASSWORD=password +DATABASE_PASSWORD=replace-with-a-random-password DATABASE_NAME=voidhash DATABASE_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 +# 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 MIMIC_ROOT_USERNAME=root -MIMIC_ROOT_PASSWORD=password -MIMIC_PORT=5001 +MIMIC_ROOT_PASSWORD=replace-with-a-random-password PUBLIC_BASE_URL=http://localhost:5001 PUBLIC_FILES_BASE_URL=http://localhost:5001 -MIMIC_CORS_ORIGINS=http://localhost:3000 +MIMIC_CORS_ORIGINS=https://voidhash.localhost,https://mimic-admin.voidhash.localhost,http://localhost:3000,http://localhost:3003 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=password +S3_SECRET_ACCESS_KEY=replace-with-a-random-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 @@ -78,4 +86,27 @@ SMTP_VERIFY_ON_START=true MAILPIT_SMTP_PORT=1025 MAILPIT_UI_PORT=8025 -# PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome +# ── 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. diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml index 41d122f2d..7bd1110ec 100644 --- a/.github/workflows/selfhost.yml +++ b/.github/workflows/selfhost.yml @@ -1,4 +1,4 @@ -name: Node Integration Fixture +name: Self-host Compose on: pull_request: @@ -12,11 +12,11 @@ permissions: contents: read concurrency: - group: node-integration-${{ github.head_ref || github.ref }} + group: selfhost-compose-${{ github.head_ref || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: - COMPOSE_PROJECT_NAME: voidhash-node-ci-${{ github.run_id }}-${{ github.run_attempt }} + COMPOSE_PROJECT_NAME: voidhash-selfhost-ci-${{ github.run_id }}-${{ github.run_attempt }} jobs: smoke: @@ -54,20 +54,24 @@ 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 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 + run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env up -d minio --wait --wait-timeout 180 - name: Initialize object store - 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 + run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env 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 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 + run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env up --build --wait --wait-timeout 180 - name: Reclaim image build cache run: docker builder prune --all --force @@ -91,9 +95,9 @@ jobs: - name: Show Compose diagnostics if: always() run: | - 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 + 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 - name: Stop Compose if: always() - run: docker compose -f test/integration/docker-compose.yml -f test/integration/docker-compose.dev.yml --project-directory test/integration down --volumes --remove-orphans + run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml down --volumes --remove-orphans diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f15521ce3..4994ef990 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,33 +38,50 @@ pnpm typecheck pnpm test ``` -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. +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. Linting and formatting go through vite-plus: `pnpm lint` (`vp check`) and `pnpm format` (`vp check --fix`). -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. +`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. ## Testing Run the smallest relevant package tests while iterating, then run the repository -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. +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). ## License zones diff --git a/LICENSE.md b/LICENSE.md index 0cb3ceceb..fcd140313 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -17,14 +17,15 @@ The full MIT License is in [LICENSES/MIT.txt](LICENSES/MIT.txt). ## AGPL service code -The backend, dashboard, service packages, and deployment adapters that declare +The backend, dashboard, service packages, and self-hosting code 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 -Commercial code is not included in this repository. The +Enterprise code is not included in this repository and remains in Voidhash's +private cloud 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 91e6464c0..f648f7c98 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 Cloudflare composition. The repository remains +> including the backend and self-hosting 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,12 +56,10 @@ voidhash-cli init ## 📚 Documentation -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 +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 [licensing and self-hosting FAQ](docs/licensing-and-self-hosting-faq.md) covers AGPL and the self-hosting model. @@ -74,9 +72,10 @@ 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 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. +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. ## 🔗 Links diff --git a/alchemy.run.ts b/alchemy.run.ts deleted file mode 100644 index 2c4024cc6..000000000 --- a/alchemy.run.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./apps/backend/stack.ts"; diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index fa9ccec82..48e5fc9c1 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_NODE_BUNDLE=true corepack pnpm@11.1.3 exec turbo build --filter @voidhash/www +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 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-node-runtime-boundary.mjs /out +RUN node scripts/check-selfhost-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-node-runtime-boundary.mjs /www +RUN node scripts/check-selfhost-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 deleted file mode 100644 index f4248fe16..000000000 --- a/apps/backend/infrastructure/DeploymentConfig.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 03cf7bf15..000000000 --- a/apps/backend/infrastructure/Hyperdrive.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index d36d12b22..000000000 --- a/apps/backend/infrastructure/PaywallArtifactStore.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index ea92fbe3c..000000000 --- a/apps/backend/infrastructure/ProjectSchemaCache.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 00873cbd3..000000000 --- a/apps/backend/infrastructure/PublicFileStore.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 7e5b6c78f..c3faec8b8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,7 +38,7 @@ "@voidhash/paywall-renderer-web-core": "workspace:*", "@voidhash/paywalls": "workspace:*", "@voidhash/platform": "workspace:*", - "@voidhash/platform-node": "workspace:*", + "@voidhash/platform-selfhost": "workspace:*", "effect": "catalog:", "esbuild": "^0.25.10", "jose": "catalog:", @@ -53,9 +53,7 @@ "@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/r2/PaywallArtifactsBucket.ts b/apps/backend/r2/PaywallArtifactsBucket.ts deleted file mode 100644 index 52b2a9625..000000000 --- a/apps/backend/r2/PaywallArtifactsBucket.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index f4d98bd51..000000000 --- a/apps/backend/r2/PublicFileStorageBucket.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 da58ee969..34579690c 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-node/NodeDurableEntitySession"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/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 7f990cf47..0501f6d55 100644 --- a/apps/backend/src/backend/Backend.ts +++ b/apps/backend/src/backend/Backend.ts @@ -17,13 +17,16 @@ 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 { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; +import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/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"; /** @@ -58,7 +61,7 @@ export const makeBackendInfrastructureLive = ( const publicFileStore = makePublicFileStoreLive( config.publicObjectStore, config.publicFilesBaseUrl, - ).pipe(Layer.provide(NodePlatformRuntimeLive)); + ).pipe(Layer.provide(SelfhostPlatformRuntimeLive)); const db = Db.layer(config.database); return Layer.mergeAll( @@ -69,7 +72,7 @@ export const makeBackendInfrastructureLive = ( publicBaseUrl: config.publicBaseUrl, }), makePaywallArtifactStoreLive(config.artifactObjectStore).pipe( - Layer.provide(NodePlatformRuntimeLive), + Layer.provide(SelfhostPlatformRuntimeLive), ), publicFileStore, BackendPaymentProviderStubsLive, diff --git a/apps/backend/src/backend/ObjectStores.ts b/apps/backend/src/backend/ObjectStores.ts index 47f381c07..27560ee3f 100644 --- a/apps/backend/src/backend/ObjectStores.ts +++ b/apps/backend/src/backend/ObjectStores.ts @@ -8,7 +8,10 @@ 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-node/ObjectStore"; +import { + S3ObjectStoreLive, + type S3ObjectStoreConfig, +} from "@voidhash/platform-selfhost/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 fe3beb84a..730789e7d 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-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"; +} 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"; 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: NodePlatformRuntimeLive, + runtime: SelfhostPlatformRuntimeLive, }; }; diff --git a/apps/backend/src/backend/Thumbnails.ts b/apps/backend/src/backend/Thumbnails.ts index a93fadbd1..380a8776d 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-node/Screenshot"; -import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; +} from "@voidhash/platform-selfhost/Screenshot"; +import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; import { Cause, Effect, Layer } from "effect"; import { mimicDocumentIdleQueueName } from "../mimic/MimicDocumentIdleQueue.ts"; @@ -155,7 +155,10 @@ export const makeSelfhostSnapshotImageRendererLive = ( Layer.provide( SelfhostHtmlScreenshotLive.pipe( Layer.provide( - Layer.merge(ChromiumScreenshotLive(screenshotConfig), NodePlatformRuntimeLive), + Layer.merge( + ChromiumScreenshotLive(screenshotConfig), + SelfhostPlatformRuntimeLive, + ), ), ), ), @@ -165,11 +168,8 @@ export const makeSelfhostSnapshotImageRendererLive = ( /** Builds the Chromium-backed thumbnail service for the self-host runtime. */ export const makeSelfhostPaywallThumbnailServiceLive = ( screenshotConfig: ChromiumScreenshotConfig, - renderer: Layer.Layer< - SnapshotImageRenderer, - never, - PublicFileStore - > = makeSelfhostSnapshotImageRendererLive(screenshotConfig), + renderer: Layer.Layer = + makeSelfhostSnapshotImageRendererLive(screenshotConfig), ) => { return PaywallThumbnailService.layer.pipe( Layer.provide(renderer), @@ -198,11 +198,14 @@ 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 c7884111d..6c5a444ed 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-node/Mailer"; -import type { S3ObjectStoreConfig } from "@voidhash/platform-node/ObjectStore"; +import type { SmtpMailerConfig } from "@voidhash/platform-selfhost/Mailer"; +import type { S3ObjectStoreConfig } from "@voidhash/platform-selfhost/ObjectStore"; import { isPlaceholderSecret, resolveStandaloneAuthConfig, diff --git a/apps/backend/src/migrations.ts b/apps/backend/src/migrations.ts index b78922371..14c120885 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-node/ClusterDurableEntity"; +import { PgClusterDurableEntityLive } from "@voidhash/platform-selfhost/ClusterDurableEntity"; import { Effect, Layer } from "effect"; import { selfhostPlatformPostgres } from "./backend/PlatformProfile.ts"; @@ -41,6 +41,8 @@ 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 10cf39f3b..c4467c91d 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-node/Postgres"; +import type { PgPlatformConfig } from "@voidhash/platform-selfhost/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 b6b2f7b47..c668f25a1 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-node/NodeDurableEntitySession"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/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 ea716c994..01eca2929 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-node/Postgres"; +import type { PgPlatformConfig } from "@voidhash/platform-selfhost/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 843b1da94..97e4e23d2 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-node/Postgres"; +import type { PgPlatformConfig } from "@voidhash/platform-selfhost/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 267fcf1e2..cfdc38b9e 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-node/Mailer"; +import { SmtpMailerLive } from "@voidhash/platform-selfhost/Mailer"; import { causeMessage } from "@voidhash/lib/lang"; import { Config, Context, Data, Effect, Layer, Option } from "effect"; import { HttpRouter } from "effect/unstable/http"; @@ -250,7 +250,9 @@ 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 deleted file mode 100644 index 9b7ee2b72..000000000 --- a/apps/backend/stack.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 89f3e7fee..817cbbab3 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-node/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/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 e33f68866..f18464e26 100644 --- a/apps/backend/tests/MimicDocumentIdle.test.ts +++ b/apps/backend/tests/MimicDocumentIdle.test.ts @@ -7,13 +7,16 @@ import { type DurableEntityAlarmControlShape, makeDurableEntityAddress, } from "@voidhash/platform/DurableEntity"; -import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/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 }]), @@ -87,9 +90,13 @@ 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(); }), )); @@ -121,9 +128,13 @@ 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 0f8784f4e..1da954aae 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-node/ClusterDurableEntity"; -import type { PgPlatformConfig } from "@voidhash/platform-node/Postgres"; +import { PgClusterDurableEntityLive } from "@voidhash/platform-selfhost/ClusterDurableEntity"; +import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; import { Config, Data, Effect, Layer, ManagedRuntime, Redacted, Schema } from "effect"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; @@ -203,7 +203,9 @@ 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 344d34bab..e5079e6de 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-node/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; import { Data, Effect, Option, Schema } from "effect"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; @@ -158,7 +158,10 @@ 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 0ac9d24bf..f040f8ac8 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@voidhash/tsconfig/alchemy-base.json", + "extends": "@voidhash/tsconfig/typescript-6.json", "compilerOptions": { "types": ["node"], "noEmit": true, @@ -7,6 +7,6 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true }, - "include": ["."], - "exclude": ["**/node_modules/**", ".alchemy", "dist"] + "include": ["src", "tests", "vitest.mts"], + "exclude": ["**/node_modules/**"] } diff --git a/apps/backend/vitest.integration.mts b/apps/backend/vitest.integration.mts index a7eecb6ef..0353a4ffb 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 Node test fixture via +// Integration tier: runs against the provisioned self-host stack 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 deleted file mode 100644 index 30325cba3..000000000 --- a/apps/backend/workers/BackendWorker.ts +++ /dev/null @@ -1,284 +0,0 @@ -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 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 = Match.value(dev).pipe( - Match.when(true, () => undefined), - Match.orElse(() => configuredDomain), - ); - - return { - 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 Option.match(Option.fromNullishOr(value), { - onNone: () => configured, - onSome: (fallbackValue) => configured.pipe(Config.withDefault(fallbackValue)), - }); - }).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( - 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", - { - 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( - 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 deleted file mode 100644 index afde9b817..000000000 --- a/apps/backend/workers/WwwWorker.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 ef13bd21b..4eb39af82 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` used by the Alchemy stack has to be loaded here too. Real +// repo-root `.env` the self-host stack uses 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 deleted file mode 100644 index 6681aa705..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,19 +0,0 @@ -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 ec25f95ee..0277beba5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,24 +1,25 @@ # Voidhash architecture -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. +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. ```mermaid flowchart TD Community["voidhash Community codebase
MIT SDKs + AGPL services"] Platform["@voidhash/platform
provider-neutral contracts"] - Cloud["@voidhash/platform-cloudflare
Alchemy + Workers primitives"] - Deploy["apps/backend/stack.ts
Community composition"] - Node["@voidhash/platform-node
retained optional adapters"] + Node["Community self-host
Node + PostgreSQL + MinIO"] + Cloud["Managed Cloud
Cloudflare + PlanetScale adapters"] + Private["Private composition
Enterprise + Overwatch + deployment graph"] Community --> Platform + Platform --> Node Platform --> Cloud - Platform -.-> Node - Cloud --> Deploy - Community --> Deploy + Community --> Private + Cloud --> Private ``` ## Community packages @@ -35,35 +36,54 @@ 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-node` retains Node implementations of the same contracts - for portability and conformance testing. It is not a supported deployment - composition. +- `@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. 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 non-Community package scopes and -incomplete package license metadata from this repository. +The publication-boundary check rejects private package scopes, infrastructure +directories, Enterprise code, operations-plane code, and incomplete package +license metadata from this repository. -## Deployment composition +## Self-host composition -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. +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. -## Repository boundary +See [the self-hosting guide](../selfhost/README.md) for the supported Compose +path and operational requirements. -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. +## 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. ## Security boundaries diff --git a/docs/cloudflare-deployment.md b/docs/cloudflare-deployment.md deleted file mode 100644 index 7a70be4fd..000000000 --- a/docs/cloudflare-deployment.md +++ /dev/null @@ -1,56 +0,0 @@ -# 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 alchemy 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 2054fc437..6ab4442a6 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 an Alchemy composition for -deployment to your own Cloudflare account. +analytics pipeline, Mimic collaboration engine, and a self-hosted Docker +composition. The SDK and integration surface is MIT licensed. The service platform and -deployment adapters are AGPL-3.0-only, which allows commercial self-hosting while +self-host runtime 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 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. +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. 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, Alchemy plan and local-worker checks, and a real cloud -deployment. The security policy and threat model are included +and dependency scanning, clean Compose builds, release-level self-host smokes, +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 -- Cloudflare deployment guide: `docs/cloudflare-deployment.md` +- Self-hosting guide: `selfhost/README.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 f586ee574..90327d55f 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 deployment adapters are AGPL-3.0-only. Operators may +build/render pipeline, and self-host runtime 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. Commercial implementation is not included in this repository. Any -separately distributed Enterprise Software is governed only by terms that -expressly identify it. +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. ## 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 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. +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. ## 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 5e28db584..135a6ecee 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-08-10
+Last updated: 2026-07-12
Scope: `packages/backend`, `apps/mimic-db`, `apps/www`, `packages/core`, and -`apps/backend` +`selfhost` 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 a deployed service against volumetric denial of service is an +Availability of the managed 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, deployment operators, and a malicious authenticated tenant +identity providers, self-host operators, and a malicious authenticated tenant submitting component source. ## Trust boundaries @@ -47,14 +47,16 @@ 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 an enabled component compiler boundary. +6. Backend to the component compiler container/sidecar. 7. Public, content-addressed artifact serving to browsers and SDKs. -8. Deployment configuration to Cloudflare resources and the PostgreSQL origin. +8. Self-host operator configuration to the Compose network and persistent + stores. -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. +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. ## Authentication and sessions @@ -117,8 +119,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 is reserved for the loopback-only Node -integration fixture. +`SELFHOST_MODE=local-evaluation`, which the self-hosting guide restricts to +loopback. ## API keys and credential storage @@ -233,7 +235,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 the Community deployment. +content type in both cloud and self-host deployments. ## Analytics ingest @@ -269,14 +271,15 @@ 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 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, +- 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, scale at 4, and the rendered output at 16,777,216 pixels. Browser operations - have a 15-second timeout and Node thumbnail consumption is serialized + have a 15-second timeout and self-host thumbnail consumption is serialized with a bounded retry count. Budget unit tests cover normal and oversized inputs. The real-Chromium test @@ -297,13 +300,12 @@ 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. -- 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. +- 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. Residual work: independently test container escape resistance, host-object VM escape attempts, memory bombs, asynchronous work, file reads, internal-service @@ -311,33 +313,39 @@ 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. -## Deployment operator boundary +## Self-host operator boundary -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. +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. Before any non-local deployment, the operator must replace every example -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. +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. ## 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 | 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. | +| 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. | 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/libraries/paywalls/src/panel/primitives.tsx b/libraries/paywalls/src/panel/primitives.tsx index 43b443e70..305b72a41 100644 --- a/libraries/paywalls/src/panel/primitives.tsx +++ b/libraries/paywalls/src/panel/primitives.tsx @@ -95,11 +95,19 @@ export interface PanelPopoverTriggerProps extends WithChildren {} export interface PanelPopoverContentProps extends WithChildren { align?: "start" | "center" | "end"; side?: "top" | "right" | "bottom" | "left"; + /** Renders a header row with this title; pair it with `onClose` for the ✕. */ + title?: string; + onClose?: () => void; } export interface PanelMenuProps { items?: ReadonlyArray; value?: string; align?: "start" | "center" | "end"; + /** Trigger glyph; defaults to a chevron when neither `icon` nor `label` is set. */ + icon?: IconName; + label?: string; + variant?: ButtonVariant; + size?: ButtonSize; onSelect?: (value: string) => void; } @@ -126,6 +134,7 @@ export interface PanelSelectFieldProps { placeholder?: string; mixed?: boolean; disabled?: boolean; + icon?: IconName; onChange?: (value: string) => void; } export interface PanelToggleGroupProps { @@ -133,6 +142,8 @@ export interface PanelToggleGroupProps { options?: ReadonlyArray; mixed?: boolean; disabled?: boolean; + /** `"full"` stretches the segmented control; the default hugs its content. */ + width?: WidthToken; onChange?: (value: string) => void; } export interface PanelSwitchFieldProps { @@ -144,9 +155,13 @@ export interface PanelSwitchFieldProps { } export interface PanelButtonProps { label?: string; + /** Muted secondary text, pushed to the trailing edge of a `width="full"` button. */ + hint?: string; icon?: IconName; variant?: ButtonVariant; size?: ButtonSize; + /** `"full"` stretches the button across its container (list-row triggers). */ + width?: WidthToken; disabled?: boolean; onClick?: () => void; } diff --git a/libraries/paywalls/src/panel/reconciler.tsx b/libraries/paywalls/src/panel/reconciler.tsx index f07be7ed4..5ecc4033c 100644 --- a/libraries/paywalls/src/panel/reconciler.tsx +++ b/libraries/paywalls/src/panel/reconciler.tsx @@ -1,8 +1,9 @@ /** - * The long-lived panel reconciler: a `react-reconciler` mutation-mode host that - * keeps ONE container per session (unlike the one-shot preview renderer in - * `tree-renderer/render-to-node-tree`). It assigns each host instance a stable - * monotonic numeric id and registers it in a `Map` so: + * The long-lived panel reconciler: ONE process-wide `react-reconciler` + * mutation-mode host (see {@link reconciler} for why it must be a singleton) + * with one container per session, unlike the one-shot preview renderer in + * `tree-renderer/render-to-node-tree`. It assigns each host instance a stable + * monotonic numeric id and registers it in a per-container `Map` so: * * - re-renders reuse instances (mutation mode) → ids are stable while a node is * mounted (the host gets stable React keys for free), and @@ -40,8 +41,23 @@ interface TextInstance { interface PanelContainer { children: PanelChild[]; + /** This session's live-instance registry, keyed by the stable numeric id. */ + instances: Map; + /** This session's monotonic instance-id counter. */ + nextId: number; + /** Filled by `attach`; the container commit hook forwards to it. */ + onCommit: () => void; } +/** + * Back-reference from an instance to the container that created it. React's + * `detachDeletedInstance` hook receives only the instance, so a reconciler + * shared across sessions cannot otherwise tell which registry to evict from. + */ +const OWNER = Symbol("panel.owner"); + +type Owned = T & { [OWNER]?: PanelContainer }; + const noop = (): void => { // intentionally empty }; @@ -60,152 +76,187 @@ const insertInto = (list: PanelChild[], child: PanelChild, before: PanelChild): // React 19 asserts host context is non-null; a shared sentinel stands in. const HOST_CONTEXT: Record = {}; +// A text instance never becomes a panel node; keep its text as a raw-string +// child so the serializer can warn+drop it. (Panel authors use props, not +// JSX text.) +const toChild = (child: PanelChild | TextInstance): PanelChild => + typeof child === "object" && "tag" in child && child.tag === "text" + ? (child as TextInstance).text + : (child as PanelChild); + +/** + * The ONE panel renderer for the whole process. + * + * `createReconciler` mints a distinct React *renderer*, and React stores a + * context's live value in just two slots — `_currentValue` for the primary + * renderer and `_currentValue2` for the secondary one. A renderer per session + * meant every extra panel fought react-dom and its siblings over the same slot + * ("Detected multiple renderers concurrently rendering the same context + * provider"), so a definition could read another session's — or a popped, + * default — context value. Hence: ONE reconciler (all sessions are the same + * renderer, each with its own container) and `isPrimaryRenderer: false` (the + * secondary slot, leaving `_currentValue` to react-dom). + * + * Because the panel renderer reads `_currentValue2`, contexts provided by the + * host's react-dom tree are NOT visible inside a session — which is exactly + * what `wrap` is for: it re-provides the store, host services, and selection + * INSIDE the reconciler root. + * + * Per-session state (instance registry, id counter, commit hook) lives on the + * container rather than in a closure, since the host config is now shared. + */ +let currentUpdatePriority = 0; + +const reconciler = createReconciler({ + supportsMutation: true, + supportsPersistence: false, + supportsHydration: false, + isPrimaryRenderer: false, + warnsIfNotActing: false, + noTimeout: -1, + scheduleTimeout: setTimeout, + cancelTimeout: clearTimeout, + supportsMicrotasks: true, + scheduleMicrotask: queueMicrotask, + + createInstance: ( + type: string, + props: Record, + rootContainer: PanelContainer, + ): PanelInstance => { + const instance: Owned = { + tag: "instance", + id: rootContainer.nextId++, + type, + props, + children: [], + }; + instance[OWNER] = rootContainer; + rootContainer.instances.set(instance.id, instance); + return instance; + }, + createTextInstance: (text: string, rootContainer: PanelContainer): TextInstance => { + const instance: Owned = { + tag: "text", + id: rootContainer.nextId++, + text, + }; + instance[OWNER] = rootContainer; + return instance; + }, + shouldSetTextContent: () => false, + finalizeInitialChildren: () => false, + getRootHostContext: () => HOST_CONTEXT, + getChildHostContext: (parentContext: unknown) => parentContext, + getPublicInstance: (instance: unknown) => instance, + + appendInitialChild: (parent: PanelInstance, child: PanelChild | TextInstance) => { + parent.children.push(toChild(child)); + }, + appendChild: (parent: PanelInstance, child: PanelChild | TextInstance) => { + parent.children.push(toChild(child)); + }, + appendChildToContainer: (c: PanelContainer, child: PanelChild | TextInstance) => { + c.children.push(toChild(child)); + }, + insertBefore: ( + parent: PanelInstance, + child: PanelChild | TextInstance, + before: PanelChild | TextInstance, + ) => { + insertInto(parent.children, toChild(child), toChild(before)); + }, + insertInContainerBefore: ( + c: PanelContainer, + child: PanelChild | TextInstance, + before: PanelChild | TextInstance, + ) => { + insertInto(c.children, toChild(child), toChild(before)); + }, + removeChild: (parent: PanelInstance, child: PanelChild | TextInstance) => { + removeFrom(parent.children, toChild(child)); + }, + removeChildFromContainer: (c: PanelContainer, child: PanelChild | TextInstance) => { + removeFrom(c.children, toChild(child)); + }, + clearContainer: (c: PanelContainer) => { + c.children = []; + }, + commitUpdate: ( + instance: PanelInstance, + _type: string, + _prev: Record, + next: Record, + ) => { + instance.props = next; + }, + commitTextUpdate: (instance: TextInstance, _old: string, next: string) => { + instance.text = next; + }, + resetTextContent: noop, + + prepareForCommit: () => null, + resetAfterCommit: (c: PanelContainer) => { + c.onCommit(); + }, + preparePortalMount: noop, + hideInstance: noop, + unhideInstance: noop, + hideTextInstance: noop, + unhideTextInstance: noop, + // Drop detached instances from the registry so a stale event can never + // resolve to an unmounted node (its id is also freed for reuse-safety: the + // monotonic counter never reissues it while mounted). + detachDeletedInstance: (instance: Owned) => { + instance[OWNER]?.instances.delete(instance.id); + }, + commitMount: noop, + getInstanceFromNode: () => null, + getInstanceFromScope: () => null, + prepareScopeUpdate: noop, + beforeActiveInstanceBlur: noop, + afterActiveInstanceBlur: noop, + + setCurrentUpdatePriority: (priority: number) => { + currentUpdatePriority = priority; + }, + getCurrentUpdatePriority: () => currentUpdatePriority, + resolveUpdatePriority: () => + currentUpdatePriority !== 0 ? currentUpdatePriority : DefaultEventPriority, + getCurrentEventPriority: () => DefaultEventPriority, + shouldAttemptEagerTransition: () => false, + requestPostPaintCallback: noop, + trackSchedulerEvent: noop, + resolveEventType: () => null, + resolveEventTimeStamp: () => -1.1, + + maySuspendCommit: () => false, + preloadInstance: () => true, + startSuspendingCommit: noop, + suspendInstance: noop, + waitForCommitToBeReady: () => null, + NotPendingTransition: null, + resetFormInstance: noop, +}); + /** - * Builds a fresh reconciler + container with its own instance registry and id - * counter. Each session gets its own so concurrent sessions never share ids or - * priority state. + * Builds a fresh container with its own instance registry and id counter, bound + * to the shared {@link reconciler}. Each session gets its own so concurrent + * sessions never share ids or trees. */ export const createPanelReconciler = () => { - const container: PanelContainer = { children: [] }; - const instances = new Map(); - let nextId = 0; - let currentUpdatePriority = 0; - - // A text instance never becomes a panel node; keep its text as a raw-string - // child so the serializer can warn+drop it. (Panel authors use props, not - // JSX text.) - const toChild = (child: PanelChild | TextInstance): PanelChild => - typeof child === "object" && "tag" in child && child.tag === "text" - ? (child as TextInstance).text - : (child as PanelChild); - - // Filled by `attach`; the container commit hook forwards to it. - let onCommit: () => void = noop; - - const reconciler = createReconciler({ - supportsMutation: true, - supportsPersistence: false, - supportsHydration: false, - isPrimaryRenderer: true, - warnsIfNotActing: false, - noTimeout: -1, - scheduleTimeout: setTimeout, - cancelTimeout: clearTimeout, - supportsMicrotasks: true, - scheduleMicrotask: queueMicrotask, - - createInstance: (type: string, props: Record): PanelInstance => { - const instance: PanelInstance = { - tag: "instance", - id: nextId++, - type, - props, - children: [], - }; - instances.set(instance.id, instance); - return instance; - }, - createTextInstance: (text: string): TextInstance => ({ tag: "text", id: nextId++, text }), - shouldSetTextContent: () => false, - finalizeInitialChildren: () => false, - getRootHostContext: () => HOST_CONTEXT, - getChildHostContext: (parentContext: unknown) => parentContext, - getPublicInstance: (instance: unknown) => instance, - - appendInitialChild: (parent: PanelInstance, child: PanelChild | TextInstance) => { - parent.children.push(toChild(child)); - }, - appendChild: (parent: PanelInstance, child: PanelChild | TextInstance) => { - parent.children.push(toChild(child)); - }, - appendChildToContainer: (c: PanelContainer, child: PanelChild | TextInstance) => { - c.children.push(toChild(child)); - }, - insertBefore: ( - parent: PanelInstance, - child: PanelChild | TextInstance, - before: PanelChild | TextInstance, - ) => { - insertInto(parent.children, toChild(child), toChild(before)); - }, - insertInContainerBefore: ( - c: PanelContainer, - child: PanelChild | TextInstance, - before: PanelChild | TextInstance, - ) => { - insertInto(c.children, toChild(child), toChild(before)); - }, - removeChild: (parent: PanelInstance, child: PanelChild | TextInstance) => { - removeFrom(parent.children, toChild(child)); - }, - removeChildFromContainer: (c: PanelContainer, child: PanelChild | TextInstance) => { - removeFrom(c.children, toChild(child)); - }, - clearContainer: (c: PanelContainer) => { - c.children = []; - }, - commitUpdate: ( - instance: PanelInstance, - _type: string, - _prev: Record, - next: Record, - ) => { - instance.props = next; - }, - commitTextUpdate: (instance: TextInstance, _old: string, next: string) => { - instance.text = next; - }, - resetTextContent: noop, - - prepareForCommit: () => null, - resetAfterCommit: () => { - onCommit(); - }, - preparePortalMount: noop, - hideInstance: noop, - unhideInstance: noop, - hideTextInstance: noop, - unhideTextInstance: noop, - // Drop detached instances from the registry so a stale event can never - // resolve to an unmounted node (its id is also freed for reuse-safety: the - // monotonic counter never reissues it while mounted). - detachDeletedInstance: (instance: PanelInstance | TextInstance) => { - instances.delete(instance.id); - }, - commitMount: noop, - getInstanceFromNode: () => null, - getInstanceFromScope: () => null, - prepareScopeUpdate: noop, - beforeActiveInstanceBlur: noop, - afterActiveInstanceBlur: noop, - - setCurrentUpdatePriority: (priority: number) => { - currentUpdatePriority = priority; - }, - getCurrentUpdatePriority: () => currentUpdatePriority, - resolveUpdatePriority: () => - currentUpdatePriority !== 0 ? currentUpdatePriority : DefaultEventPriority, - getCurrentEventPriority: () => DefaultEventPriority, - shouldAttemptEagerTransition: () => false, - requestPostPaintCallback: noop, - trackSchedulerEvent: noop, - resolveEventType: () => null, - resolveEventTimeStamp: () => -1.1, - - maySuspendCommit: () => false, - preloadInstance: () => true, - startSuspendingCommit: noop, - suspendInstance: noop, - waitForCommitToBeReady: () => null, - NotPendingTransition: null, - resetFormInstance: noop, - }); + const container: PanelContainer = { + children: [], + instances: new Map(), + nextId: 0, + onCommit: noop, + }; const attach = (handlers: { onCommit: () => void }): void => { - onCommit = handlers.onCommit; + container.onCommit = handlers.onCommit; }; - return { reconciler, container, instances, attach, ConcurrentRoot }; + return { reconciler, container, instances: container.instances, attach, ConcurrentRoot }; }; /** Renders `element` into the session container. */ diff --git a/libraries/paywalls/src/schema/panel-tree.ts b/libraries/paywalls/src/schema/panel-tree.ts index 34c7796d0..5c60964f3 100644 --- a/libraries/paywalls/src/schema/panel-tree.ts +++ b/libraries/paywalls/src/schema/panel-tree.ts @@ -120,7 +120,10 @@ export type IconName = | "user" | "users" | "externalLink" - | "mousePointer"; + | "mousePointer" + | "alignLeft" + | "alignCenter" + | "alignRight"; /** Every icon token as a runtime list; the validator derives its `Set` from it. */ export const PANEL_ICON_NAME_LIST = [ @@ -182,6 +185,9 @@ export const PANEL_ICON_NAME_LIST = [ "users", "externalLink", "mousePointer", + "alignLeft", + "alignCenter", + "alignRight", ] as const satisfies ReadonlyArray; /** @@ -300,7 +306,11 @@ export interface SelectFieldNode extends PanelNodeBase { readonly type: "selectField"; } -/** A segmented control over `options` (`{value,label?,icon?}`); single value. */ +/** + * A segmented control over `options` (`{value,label?,icon?}`); single value. + * `width: "full"` stretches the control across its container (the default sizes + * it to its content). + */ export interface ToggleGroupNode extends PanelNodeBase { readonly type: "toggleGroup"; } @@ -543,12 +553,12 @@ export const PANEL_NODE_SPECS = { children: true, }, popoverContent: { - props: ["align", "side"], - events: [], + props: ["align", "side", "title"], + events: ["onClose"], children: true, }, menu: { - props: ["items", "value", "align"], + props: ["items", "value", "align", "icon", "label", "variant", "size"], events: ["onSelect"], children: false, }, @@ -571,12 +581,12 @@ export const PANEL_NODE_SPECS = { children: false, }, selectField: { - props: ["value", "options", "placeholder", "mixed", "disabled"], + props: ["value", "options", "placeholder", "mixed", "disabled", "icon"], events: ["onChange"], children: false, }, toggleGroup: { - props: ["value", "options", "mixed", "disabled"], + props: ["value", "options", "mixed", "disabled", "width"], events: ["onChange"], children: false, }, @@ -586,7 +596,7 @@ export const PANEL_NODE_SPECS = { children: false, }, button: { - props: ["label", "icon", "variant", "size", "disabled"], + props: ["label", "hint", "icon", "variant", "size", "width", "disabled"], events: ["onClick"], children: false, }, diff --git a/package.json b/package.json index 693bee60f..0fbdd2661 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,10 @@ "workspaces": { "packages": [ "packages/*", - "packages/platform/*", "apps/*", "libraries/*", - "examples/*" + "examples/*", + "selfhost/*" ], "catalogs": { "react18": { @@ -36,7 +36,7 @@ "type": "module", "scripts": { "build": "turbo build", - "dev": "pnpm alchemy dev", + "dev": "node scripts/check-dev-runtime.mjs && portless prune && node scripts/check-dev-ports.mjs && turbo dev dev:server", "dev:doctor": "portless doctor", "dev:status": "portless list", "clean": "turbo clean && rm -rf node_modules", @@ -52,15 +52,16 @@ "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", - "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", + "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", "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:test-tiers && pnpm lint && pnpm typecheck && pnpm test", + "verify:quick": "pnpm check:publication && pnpm check:platform-seam && pnpm check:selfhost-runtime && pnpm check:test-tiers && pnpm lint && pnpm typecheck && pnpm test", "test": "turbo test", "test:integration": "node ./scripts/run-local-integration.mjs", - "test:e2e": "tsx test/integration/smoke.mts", - "test:e2e:release": "tsx test/integration/release-smoke.mts", + "test:e2e": "tsx selfhost/smoke.mts", + "test:e2e:release": "tsx selfhost/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", @@ -83,7 +84,6 @@ }, "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,7 +92,6 @@ "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 786b11c3a..bfeb838cd 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -38,7 +38,7 @@ }, "devDependencies": { "@effect/platform-node": "catalog:", - "@voidhash/platform-node": "workspace:*", + "@voidhash/platform-selfhost": "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 aa3eafb27..c48b4c710 100644 --- a/packages/agent/tests/AgentSessionCluster.integration.test.ts +++ b/packages/agent/tests/AgentSessionCluster.integration.test.ts @@ -5,10 +5,13 @@ 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-node/ClusterDurableEntity"; -import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; -import type { PgPlatformConfig } from "@voidhash/platform-node/Postgres"; +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 { Clock, Config, Crypto, Effect, ManagedRuntime, Redacted, Schema } from "effect"; import { describe, expect, it } from "vitest"; @@ -24,15 +27,15 @@ const encodeClientMessage = Schema.encodeSync(Schema.fromJsonString(AgentClientM const loadConfig: Effect.Effect = Effect.gen(function* () { return { - 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( + 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( Config.withDefault("voidhash"), ), - username: yield* Config.string("PLATFORM_NODE_PG_USERNAME").pipe( + username: yield* Config.string("PLATFORM_SELFHOST_PG_USERNAME").pipe( Config.withDefault("voidhash"), ), - password: yield* Config.redacted("PLATFORM_NODE_PG_PASSWORD").pipe( + password: yield* Config.redacted("PLATFORM_SELFHOST_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 7d70ccbe3..443e0b508 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-node/MemoryDurableEntity"; -import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/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 7b0f55494..e07e87135 100644 --- a/packages/agent/tests/SessionLog.test.ts +++ b/packages/agent/tests/SessionLog.test.ts @@ -1,4 +1,4 @@ -import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/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 3a87d4886..b2228511b 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 Node test fixture via +// Integration tier: runs against the provisioned self-host stack 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 df93c97a9..be239ff1a 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 -// Node test fixture supplies it via the shared core globalSetup; downstream +// self-host stack 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 bded40a66..98731b00f 100644 --- a/packages/core/test/_testing/CoreIntegrationTestHarness.ts +++ b/packages/core/test/_testing/CoreIntegrationTestHarness.ts @@ -126,7 +126,11 @@ 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)); @@ -174,8 +178,8 @@ export const CoreIntegrationTestHarness = { * ``` * * The environment is provisioned once per run by the active composition's - * `globalSetup` (locally: `test/_testing/globalSetup.ts` over the Node test - * fixture) and shared through vitest's `provide`/`inject` channel. + * `globalSetup` (locally: `test/_testing/globalSetup.ts` over the self-host + * stack) 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 dbfbb77d1..8ffaf83fa 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 `globalSetup` derives these values from the local Node - * test fixture, while downstream compositions provision their own - * infrastructure and inject the same shape. Tests + * 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 * 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 suite only relies - * on this shape. + * file. Compositions may inject a structural superset (the managed cloud adds + * deploy artifacts such as URLs); 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 - * Node test fixture (repo-root `.env`) and `scripts/run-local-integration.mjs` - * already use. Defaults target the local Compose fixture. + * self-host stack (repo-root `.env`) and `scripts/run-local-integration.mjs` + * already use. Defaults target the local docker-compose dev stack. */ 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 20aa51835..94d6b2a80 100644 --- a/packages/core/test/_testing/globalSetup.ts +++ b/packages/core/test/_testing/globalSetup.ts @@ -1,12 +1,15 @@ 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 - * Node test fixture. Connections are derived from the environment (see + * self-host stack. 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`. @@ -31,8 +34,8 @@ export default function setup({ Effect.catchCause((cause) => Effect.die( new Error( - "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.", + "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.", { cause }, ), ), diff --git a/packages/core/vitest.integration.mts b/packages/core/vitest.integration.mts index 16a0e69cc..331d18572 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 -// Node test fixture (`pnpm test:integration`), downstream whatever the +// self-host stack (`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 3a87d4886..b2228511b 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 Node test fixture via +// Integration tier: runs against the provisioned self-host stack 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 deleted file mode 100644 index 42ba36eb1..000000000 --- a/packages/platform/cloudflare/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index 518471173..000000000 --- a/packages/platform/cloudflare/src/DurableEntity.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index a43aa8568..000000000 --- a/packages/platform/cloudflare/src/HyperdriveDb.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index cc904a78d..000000000 --- a/packages/platform/cloudflare/src/PlatformRuntime.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index af84e08fa..000000000 --- a/packages/platform/cloudflare/src/Queue.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 0d5ecfab8..000000000 --- a/packages/platform/cloudflare/src/QueueConsumer.ts +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index 4de34c97d..000000000 --- a/packages/platform/cloudflare/src/WorkflowRunner.ts +++ /dev/null @@ -1,246 +0,0 @@ -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 deleted file mode 100644 index b87bee3f1..000000000 --- a/packages/platform/cloudflare/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index ef2d33e63..000000000 --- a/packages/platform/cloudflare/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "@voidhash/tsconfig/alchemy-base.json", - "include": ["src"] -} diff --git a/packages/web-app/src/features/studio/paywalls/designer/ai-panel/ai-panel.tsx b/packages/web-app/src/features/studio/paywalls/designer/ai-panel/ai-panel.tsx index 97590ffe7..48870106e 100644 --- a/packages/web-app/src/features/studio/paywalls/designer/ai-panel/ai-panel.tsx +++ b/packages/web-app/src/features/studio/paywalls/designer/ai-panel/ai-panel.tsx @@ -81,7 +81,7 @@ export function AiPanel() { return (