diff --git a/.env.example b/.env.example index 68f785e35..23e0fdaa5 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,7 @@ MIMIC_ROOT_USERNAME=root 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,http://localhost:3003 +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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6215afed0..84623d771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,11 @@ on: permissions: contents: read +# Superseded PR pushes cancel their stale runs; main never cancels, so every +# main commit keeps a complete CI verdict. concurrency: group: repository-ci-${{ github.head_ref || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: # Keeps turbo within the runner's 4 vCPUs; the scripts stay flag-free so the @@ -35,17 +37,11 @@ jobs: version: 11.1.3 run_install: false - - name: Check publication boundary - run: node scripts/check-publication-boundary.mjs - - - name: Check platform seam - run: node scripts/check-platform-seam.mjs - - name: Setup Node.js if: github.event_name != 'pull_request' || github.event.pull_request.draft == false uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml @@ -62,6 +58,14 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.draft == false run: pnpm install --frozen-lockfile + - name: Check publication boundary + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + run: node scripts/check-publication-boundary.mjs + + - name: Check platform seam + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + run: node scripts/check-platform-seam.mjs + - name: Validate purchase and restore contracts if: github.event_name != 'pull_request' || github.event.pull_request.draft == false run: pnpm test:purchase-restore @@ -73,6 +77,6 @@ jobs: # The same command developers run locally. The stack-backed tiers # (`test:integration`, `test:e2e`) run in the Self-host Compose workflow, # which owns the Compose lifecycle; together they cover `pnpm verify`. - - name: Verify (typecheck + unit tier) + - name: Verify (lint + typecheck + unit tier) if: github.event_name != 'pull_request' || github.event.pull_request.draft == false run: pnpm verify:quick diff --git a/.github/workflows/notify-mono.yml b/.github/workflows/notify-mono.yml new file mode 100644 index 000000000..faa1430f1 --- /dev/null +++ b/.github/workflows/notify-mono.yml @@ -0,0 +1,31 @@ +# Tells the monorepo that main moved, so its Bump voidhash workflow can open a +# submodule bump PR immediately (its daily cron is the fallback). MONO_DISPATCH_TOKEN +# must be a PAT that can send repository_dispatch to voidhashcom/voidhash-mono; +# when it is missing the step warns and succeeds, because the cron still covers +# detection. +name: Notify mono + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + dispatch: + name: Dispatch bump event + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + steps: + - name: Send repository_dispatch to voidhash-mono + env: + GH_TOKEN: ${{ secrets.MONO_DISPATCH_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::warning title=MONO_DISPATCH_TOKEN not set::Skipping the dispatch; voidhash-mono's daily bump cron remains the only drift detection." + exit 0 + fi + gh api repos/voidhashcom/voidhash-mono/dispatches \ + -f event_type=voidhash-main-push \ + -f 'client_payload[sha]='"${GITHUB_SHA}" diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index fadb0cf41..114a19006 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -6,6 +6,7 @@ on: - package.json - pnpm-lock.yaml - pnpm-workspace.yaml + - osv-scanner.toml - apps/**/package.json - examples/**/package.json - libraries/**/package.json @@ -18,6 +19,7 @@ on: - package.json - pnpm-lock.yaml - pnpm-workspace.yaml + - osv-scanner.toml - apps/**/package.json - examples/**/package.json - libraries/**/package.json diff --git a/.github/workflows/pr-packages.yml b/.github/workflows/pr-packages.yml index 7ed7c938b..84e35a601 100644 --- a/.github/workflows/pr-packages.yml +++ b/.github/workflows/pr-packages.yml @@ -70,7 +70,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 36812bfae..b7f52ecbd 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -54,4 +54,7 @@ jobs: run_install: false - name: Reject Critical and High advisories - run: pnpm audit --prod --audit-level high + run: >- + pnpm audit --prod --audit-level high + --ignore GHSA-5p2g-fcmc-qvqq + --ignore GHSA-w3rx-r6r6-pgpr diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml index 077c8f6aa..0275e740b 100644 --- a/.github/workflows/selfhost.yml +++ b/.github/workflows/selfhost.yml @@ -13,7 +13,7 @@ permissions: concurrency: group: selfhost-compose-${{ github.head_ref || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: COMPOSE_PROJECT_NAME: voidhash-selfhost-ci-${{ github.run_id }}-${{ github.run_attempt }} @@ -33,7 +33,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml diff --git a/.github/workflows/storekit.yml b/.github/workflows/storekit.yml index c47b219fd..aa147c3a4 100644 --- a/.github/workflows/storekit.yml +++ b/.github/workflows/storekit.yml @@ -40,7 +40,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 - name: Test StoreKit transaction retention run: swift test --package-path libraries/react-native diff --git a/.nvmrc b/.nvmrc index c94711948..60ade1ae0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.23.2 +24.19.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 603b98e49..4994ef990 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ for reporting guidance. ## Development -Voidhash uses Node.js 22 and pnpm 11. From the repository root: +Voidhash uses Node.js 24 and pnpm 11. From the repository root: ```sh corepack enable @@ -45,6 +45,29 @@ local Compose environment and its smoke tests. Linting and formatting go through vite-plus: `pnpm lint` (`vp check`) and `pnpm format` (`vp check --fix`). +`pnpm dev` starts every browser-facing development surface and the services +used by the Mimic example through Portless. The first run creates and trusts a +local certificate authority for the named HTTPS routes: + +Run `pnpm dev` as your normal user, never through `sudo`. Portless elevates only +its HTTPS proxy when necessary, while the application processes remain owned by +your user. Startup also prunes orphaned Portless children left by crashed dev +sessions before checking the fixed ports. Use `pnpm dev:status` to inspect active +routes and `pnpm dev:doctor` to diagnose the proxy, certificate, or DNS setup. + +| Surface | URL | App port | +| ------------------ | ---------------------------------------------- | -------- | +| Dashboard and docs | `https://voidhash.localhost` | `3000` | +| Mimic example API | `https://mimic-example-api.voidhash.localhost` | `3001` | +| Mimic admin | `https://mimic-admin.voidhash.localhost` | `3003` | +| Email previews | `https://emails.voidhash.localhost` | `3010` | +| Studio | `https://studio.voidhash.localhost` | `4830` | +| Mimic database | `https://mimic.voidhash.localhost` | `5001` | +| Mimic example | `https://mimic-example.voidhash.localhost` | `5173` | + +The ports are strict: if another process is using one, startup fails instead of +silently moving an app and breaking its local links. + The steps above describe a **standalone clone** of this repository, which installs its own `node_modules` from this repository's lockfile. This repository is also consumed as a nested workspace by Voidhash's private monorepo. In that mode the diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 6deaceca5..48e5fc9c1 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-bookworm-slim AS build +FROM node:24-bookworm-slim AS build ENV PNPM_HOME=/pnpm ENV PATH=$PNPM_HOME:$PATH @@ -16,7 +16,7 @@ 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-selfhost-runtime-boundary.mjs /www -FROM node:22-bookworm-slim AS runtime +FROM node:24-bookworm-slim AS runtime ENV NODE_ENV=production ENV CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-headless-shell diff --git a/apps/backend/package.json b/apps/backend/package.json index 40ed75aba..ca46325b8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -31,6 +31,7 @@ "@voidhash/clickhouse-db": "workspace:*", "@voidhash/core": "workspace:*", "@voidhash/db": "workspace:*", + "@voidhash/lib": "workspace:*", "@voidhash/mimic-core": "workspace:*", "@voidhash/mimic-db": "workspace:*", "@voidhash/mimic-schema": "workspace:*", diff --git a/apps/backend/src/DurableEntityAlarms.ts b/apps/backend/src/DurableEntityAlarms.ts index 55160eb9d..c1298c6f5 100644 --- a/apps/backend/src/DurableEntityAlarms.ts +++ b/apps/backend/src/DurableEntityAlarms.ts @@ -2,7 +2,7 @@ import type { DurableEntityAddress, DurableEntityAlarmControlShape, } from "@voidhash/platform/DurableEntity"; -import { Effect } from "effect"; +import { Clock, Effect } from "effect"; /** Handler for one durable-entity alarm type. */ export type DurableEntityAlarmHandler = ( @@ -19,18 +19,12 @@ export const dispatchDurableEntityAlarms = ( handlers: Readonly>, now?: number, ): Effect.Effect => - Effect.suspend(() => { - const dispatchTime = now ?? Date.now(); - return control - .listDueAlarms(dispatchTime, 100) - .pipe( - Effect.flatMap((due) => - Effect.forEach( - due, - ({ address }) => - handlers[address.type]?.(address, dispatchTime) ?? Effect.void, - { discard: true }, - ), - ), - ); + Effect.gen(function* () { + const dispatchTime = now ?? (yield* Clock.currentTimeMillis); + const due = yield* control.listDueAlarms(dispatchTime, 100); + yield* Effect.forEach( + due, + ({ address }) => handlers[address.type]?.(address, dispatchTime) ?? Effect.void, + { discard: true }, + ); }); diff --git a/apps/backend/src/agent/AgentNodeWebSocket.ts b/apps/backend/src/agent/AgentNodeWebSocket.ts index a2e8a01d4..34579690c 100644 --- a/apps/backend/src/agent/AgentNodeWebSocket.ts +++ b/apps/backend/src/agent/AgentNodeWebSocket.ts @@ -1,4 +1,6 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- Node platform adapter: it upgrades connections on the real http.Server created by the standalone entrypoint, so it needs that module's own types. import type { IncomingMessage, Server } from "node:http"; +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the WebSocket upgrade handler receives a node:stream Duplex from the Node HTTP server; Stream/Channel cannot type that handshake argument. import type { Duplex } from "node:stream"; import { @@ -64,6 +66,20 @@ export type AgentNodeRouteResult = const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +/** + * Percent-decodes one path segment, yielding `undefined` for malformed input. + * + * `decodeURIComponent` throws on broken escape sequences, so the throw is + * captured by `Effect.try` and run synchronously to keep this parser pure. + */ +const decodeSegment = (segment: string): string | undefined => + Effect.runSync( + Effect.try({ + try: () => decodeURIComponent(segment), + catch: (cause) => cause, + }).pipe(Effect.catch(() => Effect.succeed(undefined))), + ); + /** Parses and validates the self-host agent upgrade target. */ export const parseAgentNodeRoute = ( request: Pick, @@ -75,12 +91,8 @@ export const parseAgentNodeRoute = ( const organizationId = url.searchParams.get("organizationId")?.trim() ?? ""; const projectId = url.searchParams.get("projectId")?.trim() ?? ""; const surface = url.searchParams.get("surface")?.trim() ?? ""; - let sessionId: string; - try { - sessionId = decodeURIComponent(match[1]); - } catch { - return { _tag: "Invalid" }; - } + const sessionId = decodeSegment(match[1]); + if (sessionId === undefined) return { _tag: "Invalid" }; if ( !organizationId || !projectId || @@ -90,16 +102,9 @@ export const parseAgentNodeRoute = ( return { _tag: "Invalid" }; } const paywallId = url.searchParams.get("paywallId")?.trim() || undefined; - return { - _tag: "Route", - route: { - sessionId, - organizationId, - projectId, - surface, - ...(paywallId === undefined ? {} : { paywallId }), - }, - }; + const route: AgentRoute = { sessionId, organizationId, projectId, surface }; + if (paywallId === undefined) return { _tag: "Route", route }; + return { _tag: "Route", route: { ...route, paywallId } }; }; const rejectUpgrade = (socket: Duplex, status: number, reason: string): void => { @@ -107,34 +112,58 @@ const rejectUpgrade = (socket: Duplex, status: number, reason: string): void => socket.destroy(); }; +const headerEntries = (request: IncomingMessage): Array => { + const entries: Array = []; + for (const [name, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + entries.push([name, value.join(", ")]); + continue; + } + entries.push([name, value]); + } + return entries; +}; + const headersOf = (request: IncomingMessage): HttpHeaders.Headers => - HttpHeaders.fromInput( - Object.fromEntries( - Object.entries(request.headers).flatMap(([name, value]) => - value === undefined - ? [] - : [[name, Array.isArray(value) ? value.join(", ") : value] as const], - ), - ), - ); + HttpHeaders.fromInput(Object.fromEntries(headerEntries(request))); const frameOf = (data: RawData, isBinary: boolean): string | Uint8Array => { - if (!isBinary) return data.toString(); + // `ws` hands text frames over as a Buffer, an ArrayBuffer or a Buffer[] + // depending on `binaryType`; only the Buffer case decodes correctly on its + // own, so the other two are normalized before being read as text. + if (!isBinary) { + if (data instanceof ArrayBuffer) return Buffer.from(data).toString(); + if (Array.isArray(data)) return Buffer.concat(data).toString(); + return data.toString(); + } if (data instanceof ArrayBuffer) return new Uint8Array(data); if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)); return new Uint8Array(data); }; +const withOpenaiBaseUrl = ( + model: Model, + provider: string, + openaiBaseUrl: string | undefined, +): Model => { + if (provider === "openai" && openaiBaseUrl !== undefined) { + return { ...model, baseUrl: openaiBaseUrl }; + } + return model; +}; + const configuredModel = ( provider: string, modelId: string, openaiBaseUrl: string | undefined, ): Model => { const model = getCatalogModel(provider, modelId); - if (model === undefined) throw new Error(`Unknown agent model: ${provider}/${modelId}`); - return provider === "openai" && openaiBaseUrl !== undefined - ? { ...model, baseUrl: openaiBaseUrl } - : model; + // Startup misconfiguration: there is no usable server without a known model. + if (model === undefined) { + return Effect.runSync(Effect.die(new Error(`Unknown agent model: ${provider}/${modelId}`))); + } + return withOpenaiBaseUrl(model, provider, openaiBaseUrl); }; const resolveConfiguredModel = ( @@ -143,11 +172,8 @@ const resolveConfiguredModel = ( openaiBaseUrl: string | undefined, ): Model | undefined => { const model = getCatalogModel(provider, modelId); - return model === undefined - ? undefined - : provider === "openai" && openaiBaseUrl !== undefined - ? { ...model, baseUrl: openaiBaseUrl } - : model; + if (model === undefined) return undefined; + return withOpenaiBaseUrl(model, provider, openaiBaseUrl); }; /** Installs authenticated durable Pi sessions on the self-host HTTP server. */ @@ -165,18 +191,19 @@ export const installAgentNodeWebSocketServer = ( config.visionModelId, config.openaiBaseUrl, ); - const contextFor = (data: AgentConnectionData) => - Context.add(services, AuthSession, data.authSession) as Context.Context< - WorkspaceAgentDeps | AgentSessionIndexService | AuthSession - >; + const contextFor = ( + data: AgentConnectionData, + ): Context.Context => + Context.add(services, AuthSession, data.authSession); + const runOptions = (signal: AbortSignal | undefined) => { + if (signal === undefined) return undefined; + return { signal }; + }; const runAgentEffect: EffectRunner< AgentConnectionData, WorkspaceAgentDeps | AgentSessionIndexService | AuthSession > = (data, effect, signal) => - Effect.runPromise( - effect.pipe(Effect.provide(contextFor(data))), - signal === undefined ? undefined : { signal }, - ); + Effect.runPromise(effect.pipe(Effect.provide(contextFor(data))), runOptions(signal)); const factory = makeWorkspaceAgentSessionFactory({ defaultModel, visionModel, @@ -254,11 +281,10 @@ export const installAgentNodeWebSocketServer = ( webSocket.on("message", (data, isBinary) => { run( Effect.promise(() => connected).pipe( - Effect.flatMap((authorized) => - authorized - ? core.handleMessage(connection, frameOf(data, isBinary)) - : Effect.sync(() => webSocket.close(1008, "Session access denied")), - ), + Effect.flatMap((authorized) => { + if (authorized) return core.handleMessage(connection, frameOf(data, isBinary)); + return Effect.sync(() => webSocket.close(1008, "Session access denied")); + }), ), ); }); diff --git a/apps/backend/src/backend/Analytics.ts b/apps/backend/src/backend/Analytics.ts index a12056356..10cfcb1d1 100644 --- a/apps/backend/src/backend/Analytics.ts +++ b/apps/backend/src/backend/Analytics.ts @@ -25,7 +25,7 @@ import { Db } from "@voidhash/db"; import { KeyValueStore } from "@voidhash/platform/KeyValueStore"; import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { QueueDriver } from "@voidhash/platform/Queue"; -import { Context, Effect, Layer } from "effect"; +import { Context, Effect, Layer, Schema } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; import { makeSelfhostPlatformLive } from "./PlatformProfile.ts"; @@ -36,11 +36,27 @@ const analyticsDeadLetterQueueName = "analytics-ingest-dlq"; const minuteBucket = (value: Date): string => value.toISOString().slice(0, 16); const dayBucket = (value: Date): string => value.toISOString().slice(0, 10); +const minuteMillis = 60_000; + +/** + * Milliseconds left until the next UTC minute boundary. UTC minutes are aligned + * to the epoch, so this is exact modular arithmetic over the instant. + */ const millisecondsUntilNextMinute = (value: Date): number => { - const nextMinute = new Date(value); - nextMinute.setUTCSeconds(0, 0); - nextMinute.setUTCMinutes(nextMinute.getUTCMinutes() + 1); - return Math.max(nextMinute.getTime() - value.getTime(), 0); + const remainder = value.getTime() % minuteMillis; + if (remainder === 0) return minuteMillis; + return minuteMillis - remainder; +}; + +/** JSON text of an ingest envelope as stored on the processed record. */ +const encodeEnvelopeJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +/** Best-effort human text for an unknown queue/driver error. */ +const errorCauseText = (error: unknown): string => { + if (typeof error === "object" && error !== null && "cause" in error) { + return String(error.cause); + } + return String(error); }; const makePolicyCounterStoreLive = Layer.effect( @@ -60,25 +76,23 @@ const makePolicyCounterStoreLive = Layer.effect( ), ); return PolicyCounterStore.of({ - checkEventQuota: ({ now, projectId, quota }) => - typeof quota !== "number" || quota < 1 - ? Effect.succeed(true) - : increment(`events:${projectId}:${dayBucket(now)}`, 172_800_000).pipe( - Effect.map((count) => count <= quota), - ), - checkRequestLimit: ({ now, projectId, requestsPerMinute }) => - typeof requestsPerMinute !== "number" || requestsPerMinute < 1 - ? Effect.succeed({ allowed: true }) - : increment(`requests:${projectId}:${minuteBucket(now)}`, 120_000).pipe( - Effect.map((count) => - count <= requestsPerMinute - ? { allowed: true } - : { - allowed: false, - retryAfterMs: millisecondsUntilNextMinute(now), - }, - ), - ), + checkEventQuota: ({ now, projectId, quota }) => { + if (typeof quota !== "number" || quota < 1) return Effect.succeed(true); + return increment(`events:${projectId}:${dayBucket(now)}`, 172_800_000).pipe( + Effect.map((count) => count <= quota), + ); + }, + checkRequestLimit: ({ now, projectId, requestsPerMinute }) => { + if (typeof requestsPerMinute !== "number" || requestsPerMinute < 1) { + return Effect.succeed({ allowed: true }); + } + return increment(`requests:${projectId}:${minuteBucket(now)}`, 120_000).pipe( + Effect.map((count) => { + if (count <= requestsPerMinute) return { allowed: true }; + return { allowed: false, retryAfterMs: millisecondsUntilNextMinute(now) }; + }), + ); + }, }); }), ); @@ -125,10 +139,7 @@ const makeCaptureIngressLive = Layer.effect( Effect.mapError( (error) => new CaptureIngressError({ - cause: - typeof error === "object" && error !== null && "cause" in error - ? String(error.cause) - : String(error), + cause: errorCauseText(error), message: "failed to enqueue captured analytics events", }), ), @@ -186,20 +197,24 @@ export const runSelfhostAnalyticsConsumers = ( ), Layer.provide(database), ); - const writerContext = clickhouse - ? yield* Effect.gen(function* () { - const clickhouseContext = yield* Layer.build(clickhouse); - const client = Context.get( - clickhouseContext, - ClickhouseWebClient.ClickhouseWebClient, - ); - return yield* Layer.build( - AnalyticsWriterService.layerWithClickhouse(client).pipe( - Layer.provide(database), - ), - ); - }) - : yield* Layer.build(AnalyticsWriterService.layer.pipe(Layer.provide(database))); + const buildWriterContext = () => { + if (clickhouse === undefined) { + return Layer.build(AnalyticsWriterService.layer.pipe(Layer.provide(database))); + } + return Effect.gen(function* () { + const clickhouseContext = yield* Layer.build(clickhouse); + const client = Context.get( + clickhouseContext, + ClickhouseWebClient.ClickhouseWebClient, + ); + return yield* Layer.build( + AnalyticsWriterService.layerWithClickhouse(client).pipe( + Layer.provide(database), + ), + ); + }); + }; + const writerContext = yield* buildWriterContext(); const analyticsWriter = Context.get(writerContext, AnalyticsWriterService); const consumeAnalytics = queues.consumeBatch( @@ -214,7 +229,7 @@ export const runSelfhostAnalyticsConsumers = ( capturedEvent: message.envelope, headers: {}, lane: message.lane, - rawValue: JSON.stringify(message.envelope), + rawValue: encodeEnvelopeJson(message.envelope), sourceOffset: message.envelope.captureId, sourcePartition: 0, sourceTopic: message.envelope.routing.targetTopic, diff --git a/apps/backend/src/backend/Backend.ts b/apps/backend/src/backend/Backend.ts index 65f02ab8b..5b4ce15bc 100644 --- a/apps/backend/src/backend/Backend.ts +++ b/apps/backend/src/backend/Backend.ts @@ -19,7 +19,7 @@ import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/Pay import { Db } from "@voidhash/db"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; -import { Effect, Layer, Redacted } from "effect"; +import { Layer, Redacted } from "effect"; import type { SelfhostAuthConfig, SelfhostRuntimeConfig } from "../config.ts"; import { makeHttpComponentCompilerLive } from "../compiler/CompilerClient.ts"; diff --git a/apps/backend/src/backend/Background.ts b/apps/backend/src/backend/Background.ts index 786578d67..7922f3b9c 100644 --- a/apps/backend/src/backend/Background.ts +++ b/apps/backend/src/backend/Background.ts @@ -5,14 +5,17 @@ import { backendWorkflows } from "@voidhash/core/workflows/registry"; import { CronJob, CronScheduler } from "@voidhash/platform/CronScheduler"; import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import type { WorkflowRunner } from "@voidhash/platform/WorkflowRunner"; -import { Context, Effect, Layer } from "effect"; +import { Config, Context, Effect, Layer } from "effect"; /** Builds the persisted jobs enabled by the current self-host configuration. */ export const makeSelfhostCronJobs = ( clickhouse?: Layer.Layer, ) => Effect.gen(function* () { - const exchangeRateApiKey = process.env.EXCHANGE_RATE_API_KEY?.trim(); + const exchangeRateApiKey = (yield* Config.string("EXCHANGE_RATE_API_KEY").pipe( + Config.withDefault(""), + Effect.orDie, + )).trim(); const jobs: Array> = backendWorkflows.flatMap( (registration) => { if (registration.cron === undefined) return []; diff --git a/apps/backend/src/backend/Clickhouse.ts b/apps/backend/src/backend/Clickhouse.ts index 6d8fca13a..a01788842 100644 --- a/apps/backend/src/backend/Clickhouse.ts +++ b/apps/backend/src/backend/Clickhouse.ts @@ -8,32 +8,38 @@ import { CLICKHOUSE_PERSONS_TABLE, } from "@voidhash/clickhouse-db/analytics/schema"; import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { constant } from "@voidhash/lib/lang"; import { Effect, Layer, Schedule } from "effect"; import { SqlClient } from "effect/unstable/sql"; import type { SelfhostClickhouseConfig } from "../config.ts"; -const tenantTables = [ +const tenantTables = constant([ CLICKHOUSE_EVENTS_TABLE, CLICKHOUSE_PERSONS_TABLE, CLICKHOUSE_PERSON_IDENTITY_TABLE, CLICKHOUSE_PERSON_IDENTITY_OVERRIDES_TABLE, CLICKHOUSE_PERSON_IDENTITY_PENDING_OVERRIDES_V2_TABLE, -] as const; +]); -const queryTables = [ +const queryTables = constant([ CLICKHOUSE_EVENTS_TABLE, CLICKHOUSE_PERSONS_TABLE, CLICKHOUSE_PERSON_IDENTITY_PENDING_OVERRIDES_V2_TABLE, -] as const; +]); const identifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/; -const assertIdentifier = (name: string, value: string): string => { +/** + * Guards a configured ClickHouse identifier before it is interpolated into DDL. + * A bad identifier is a deployment misconfiguration, not a recoverable failure, + * so it is raised as a defect exactly as the previous `throw` was. + */ +const assertIdentifier = (name: string, value: string): Effect.Effect => { if (!identifierPattern.test(value)) { - throw new Error(`${name} must be a ClickHouse identifier`); + return Effect.die(new Error(`${name} must be a ClickHouse identifier`)); } - return value; + return Effect.succeed(value); }; const makeClientLive = (config: SelfhostClickhouseConfig["readWrite"]) => @@ -50,11 +56,20 @@ const provisionSelfhostClickhouseAccess = (config: SelfhostClickhouseConfig) => Effect.gen(function* () { const ch = yield* ClickhouseWebClient.ClickhouseWebClient; const sql = yield* SqlClient.SqlClient; - const database = assertIdentifier("CLICKHOUSE_DATABASE", config.admin.database); - const adminUser = assertIdentifier("CLICKHOUSE_ADMIN_USERNAME", config.admin.username); - const readWriteUser = assertIdentifier("CLICKHOUSE_USERNAME", config.readWrite.username); - const readOnlyUser = assertIdentifier("CLICKHOUSE_RO_USERNAME", config.readOnly.username); - const queryUser = assertIdentifier( + const database = yield* assertIdentifier("CLICKHOUSE_DATABASE", config.admin.database); + const adminUser = yield* assertIdentifier( + "CLICKHOUSE_ADMIN_USERNAME", + config.admin.username, + ); + const readWriteUser = yield* assertIdentifier( + "CLICKHOUSE_USERNAME", + config.readWrite.username, + ); + const readOnlyUser = yield* assertIdentifier( + "CLICKHOUSE_RO_USERNAME", + config.readOnly.username, + ); + const queryUser = yield* assertIdentifier( "CLICKHOUSE_ANALYTICS_QUERY_USERNAME", config.analyticsQuery.username, ); @@ -62,11 +77,11 @@ const provisionSelfhostClickhouseAccess = (config: SelfhostClickhouseConfig) => const readOnlyRole = `${database}_ro_role`; const queryRole = `${database}_query_role`; - for (const [user, password] of [ + for (const [user, password] of constant([ [readWriteUser, config.readWrite.password], [readOnlyUser, config.readOnly.password], [queryUser, config.analyticsQuery.password], - ] as const) { + ])) { yield* ch.asCommand(sql` CREATE USER IF NOT EXISTS ${sql(user)} IDENTIFIED WITH sha256_password BY ${password} `); diff --git a/apps/backend/src/backend/MimicHost.ts b/apps/backend/src/backend/MimicHost.ts index ff23db6bc..03d79b4d3 100644 --- a/apps/backend/src/backend/MimicHost.ts +++ b/apps/backend/src/backend/MimicHost.ts @@ -3,6 +3,9 @@ import { MimicHostError, type MimicHostShape, } from "@voidhash/core/services/paywalls/MimicHost"; +import { generateId } from "@voidhash/core/utils/generate-id"; +import { causeMessage } from "@voidhash/lib/lang"; +import type { Value } from "@voidhash/mimic-core"; import { HostServiceTag, type HostService } from "@voidhash/mimic-db/app/hostService"; import { decodeTransactionEnvelope } from "@voidhash/mimic-db/document/transaction"; import { @@ -12,7 +15,7 @@ import { PaywallDesignerDocument, PresenceSchema, } from "@voidhash/mimic-schema"; -import { Effect, Layer, Semaphore } from "effect"; +import { Clock, DateTime, Effect, Layer, Semaphore } from "effect"; const editTokenTtlSeconds = 300; const agentConnectionLeaseMs = 5 * 60 * 1000; @@ -24,19 +27,36 @@ interface ProvisioningIds { const hostError = (message: string, cause: unknown) => new MimicHostError({ - cause: cause instanceof Error ? cause.message : String(cause), + cause: causeMessage(cause), message, }); -const errorTag = (cause: unknown): string | undefined => - typeof cause === "object" && cause !== null && "_tag" in cause ? String(cause._tag) : undefined; +/** Wraps `cause` unless it already is a {@link MimicHostError}. */ +const toHostError = + (message: string) => + (cause: unknown): MimicHostError => { + if (cause instanceof MimicHostError) return cause; + return hostError(message, cause); + }; + +const registryError = (message: string) => new MimicHostError({ cause: message, message }); + +const errorTag = (cause: unknown): string | undefined => { + if (typeof cause === "object" && cause !== null && "_tag" in cause) return String(cause._tag); + return undefined; +}; const isNotFound = (cause: unknown): boolean => errorTag(cause) === "NotFoundError"; const isConflict = (cause: unknown): boolean => errorTag(cause) === "ConflictError"; +const websocketProtocol = (protocol: string): string => { + if (protocol === "https:") return "wss:"; + return "ws:"; +}; + const connectionUrl = (publicBaseUrl: string, ids: ProvisioningIds, paywallId: string): string => { const base = new URL(publicBaseUrl); - base.protocol = base.protocol === "https:" ? "wss:" : "ws:"; + base.protocol = websocketProtocol(base.protocol); base.pathname = `/ws/v1/databases/${encodeURIComponent( ids.databaseId, )}/collections/${encodeURIComponent(ids.collectionId)}/documents/${encodeURIComponent( @@ -54,9 +74,8 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape const resolveDatabase = Effect.gen(function* () { const listed = yield* host.listDatabases(); const existing = listed.find((database) => database.name === MIMIC_DATABASE_NAME); - return existing - ? existing.id - : yield* Effect.fail(new Error(`Missing registry database ${MIMIC_DATABASE_NAME}`)); + if (existing) return existing.id; + return yield* registryError(`Missing registry database ${MIMIC_DATABASE_NAME}`); }); const resolveCollection = (databaseId: string) => @@ -65,11 +84,10 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape const existing = listed.find( (collection) => collection.name === MIMIC_PAYWALLS_COLLECTION_NAME, ); - return existing - ? existing.id - : yield* Effect.fail( - new Error(`Missing registry collection ${MIMIC_PAYWALLS_COLLECTION_NAME}`), - ); + if (existing) return existing.id; + return yield* registryError( + `Missing registry collection ${MIMIC_PAYWALLS_COLLECTION_NAME}`, + ); }); const provision = provisioningLock.withPermit( @@ -92,29 +110,25 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape Effect.flatMap(({ collectionId }) => host.getDocument(collectionId, paywallId).pipe( Effect.asVoid, - Effect.catch((cause) => - isNotFound(cause) - ? host - .createDocument( - collectionId, - paywallId, - PaywallDesignerDocument.encode(createInitialPaywallDocumentInput()), - ) - .pipe( - Effect.asVoid, - Effect.catch((createCause) => - isConflict(createCause) ? Effect.void : Effect.fail(createCause), - ), - ) - : Effect.fail(cause), - ), + Effect.catch((cause) => { + if (!isNotFound(cause)) return Effect.fail(cause); + return host + .createDocument( + collectionId, + paywallId, + PaywallDesignerDocument.encode(createInitialPaywallDocumentInput()), + ) + .pipe( + Effect.asVoid, + Effect.catch((createCause) => { + if (isConflict(createCause)) return Effect.void; + return Effect.fail(createCause); + }), + ); + }), ), ), - Effect.mapError((cause) => - cause instanceof MimicHostError - ? cause - : hostError(`Failed to ensure paywall document ${paywallId}`, cause), - ), + Effect.mapError(toHostError(`Failed to ensure paywall document ${paywallId}`)), ); const getDocument = (paywallId: string) => @@ -123,8 +137,8 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape Effect.mapError((cause) => hostError(`Failed to read paywall document ${paywallId}`, cause)), ); - const toPaywallDocument = (document: { readonly value: unknown; readonly version: number }) => { - const roots = PaywallDesignerDocument.decode(document.value as never); + const toPaywallDocument = (document: { readonly value: Value; readonly version: number }) => { + const roots = PaywallDesignerDocument.decode(document.value); return { root: roots?.[0], tree: document.value, @@ -139,11 +153,15 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape host .createDocumentAuthToken(ids.collectionId, paywallId, "write", [], editTokenTtlSeconds) .pipe( - Effect.map(({ token }) => ({ - expiresAt: new Date(Date.now() + editTokenTtlSeconds * 1000), - token, - url: connectionUrl(publicBaseUrl, ids, paywallId), - })), + Effect.flatMap(({ token }) => + Effect.map(Clock.currentTimeMillis, (now) => ({ + expiresAt: DateTime.toDateUtc( + DateTime.makeUnsafe(now + editTokenTtlSeconds * 1000), + ), + token, + url: connectionUrl(publicBaseUrl, ids, paywallId), + })), + ), ), ), Effect.mapError((cause) => hostError(`Failed to mint a token for ${paywallId}`, cause)), @@ -162,7 +180,7 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape decodeTransactionEnvelope({ baseVersion: input.baseVersion, commands: input.commands, - id: crypto.randomUUID(), + id: generateId("transaction"), }), catch: (cause) => hostError("Invalid mimic transaction", cause), }).pipe( @@ -172,11 +190,7 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape ), ), Effect.map((result) => ({ accepted: result.accepted, version: result.version })), - Effect.mapError((cause) => - cause instanceof MimicHostError - ? cause - : hostError(`Failed to update paywall document ${paywallId}`, cause), - ), + Effect.mapError(toHostError(`Failed to update paywall document ${paywallId}`)), ), openPaywallConnection: ({ paywallId, connectionId, presence }) => provision.pipe( @@ -240,7 +254,7 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape decodeTransactionEnvelope({ baseVersion: input.baseVersion, commands: input.commands, - id: crypto.randomUUID(), + id: generateId("transaction"), }), catch: (cause) => hostError("Invalid mimic transaction", cause), }).pipe( @@ -256,11 +270,7 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape ), ), Effect.map((result) => ({ accepted: result.accepted, version: result.version })), - Effect.mapError((cause) => - cause instanceof MimicHostError - ? cause - : hostError(`Failed to update connected paywall ${paywallId}`, cause), - ), + Effect.mapError(toHostError(`Failed to update connected paywall ${paywallId}`)), ), }; }; diff --git a/apps/backend/src/backend/ObjectStores.ts b/apps/backend/src/backend/ObjectStores.ts index bd4ee914b..27560ee3f 100644 --- a/apps/backend/src/backend/ObjectStores.ts +++ b/apps/backend/src/backend/ObjectStores.ts @@ -14,10 +14,12 @@ import { } from "@voidhash/platform-selfhost/ObjectStore"; import { Effect, Layer, Option } from "effect"; -const objectStoreCause = (cause: unknown): string => - cause instanceof ObjectStoreError - ? `${cause.operation} ${cause.bucketName}/${cause.key}: ${cause.cause}` - : String(cause); +const objectStoreCause = (cause: unknown): string => { + if (cause instanceof ObjectStoreError) { + return `${cause.operation} ${cause.bucketName}/${cause.key}: ${cause.cause}`; + } + return String(cause); +}; const artifactError = (operation: string, cause: unknown) => new PaywallArtifactStoreError({ diff --git a/apps/backend/src/backend/PlatformProfile.ts b/apps/backend/src/backend/PlatformProfile.ts index 4d097e858..730789e7d 100644 --- a/apps/backend/src/backend/PlatformProfile.ts +++ b/apps/backend/src/backend/PlatformProfile.ts @@ -21,6 +21,7 @@ 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 { KeyValueStore as PersistenceKeyValueStore, @@ -69,7 +70,7 @@ export const selfhostPlatformPostgres = (database: DbConfig): PgPlatformConfig = host: database.host, password: Redacted.make(database.password), port: database.port, - ...(database.ssl === undefined ? {} : { ssl: database.ssl }), + ...pick(database.ssl === undefined, {}, { ssl: database.ssl }), username: database.username, }); @@ -80,7 +81,7 @@ const platformLayers = (postgres: PgPlatformConfig): SelfhostPlatformLayers => { password: postgres.password, port: postgres.port, username: postgres.username, - ...(postgres.ssl === undefined ? {} : { ssl: postgres.ssl }), + ...pick(postgres.ssl === undefined, {}, { ssl: postgres.ssl }), }).pipe(Layer.orDie); // Every cluster-backed primitive shares this one topology value so a single diff --git a/apps/backend/src/backend/ProjectSchemaCache.ts b/apps/backend/src/backend/ProjectSchemaCache.ts index 61ef17768..56ff5c536 100644 --- a/apps/backend/src/backend/ProjectSchemaCache.ts +++ b/apps/backend/src/backend/ProjectSchemaCache.ts @@ -1,5 +1,5 @@ import { ProjectSchemaCache } from "@voidhash/core/services"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer } from "effect"; interface CacheEntry { readonly expiresAt: number; @@ -12,10 +12,11 @@ export const MemoryProjectSchemaCacheLive = Layer.sync(ProjectSchemaCache, () => return { getByName: (projectId: string) => ({ get: () => - Effect.sync(() => { + Effect.gen(function* () { const entry = entries.get(projectId); if (!entry) return undefined; - if (entry.expiresAt <= Date.now()) { + const now = yield* Clock.currentTimeMillis; + if (entry.expiresAt <= now) { entries.delete(projectId); return undefined; } @@ -23,8 +24,9 @@ export const MemoryProjectSchemaCacheLive = Layer.sync(ProjectSchemaCache, () => }), invalidate: () => Effect.sync(() => void entries.delete(projectId)), set: (schema: unknown, ttlMs: number) => - Effect.sync(() => { - entries.set(projectId, { expiresAt: Date.now() + ttlMs, schema }); + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + entries.set(projectId, { expiresAt: now + ttlMs, schema }); }), }), }; diff --git a/apps/backend/src/backend/Push.ts b/apps/backend/src/backend/Push.ts index 454b93ed2..e6dd40be7 100644 --- a/apps/backend/src/backend/Push.ts +++ b/apps/backend/src/backend/Push.ts @@ -15,7 +15,7 @@ import { PaymentConfigSecretCrypto } from "@voidhash/core/utils/crypto/PaymentCo import { Db } from "@voidhash/db"; import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { QueueDriver } from "@voidhash/platform/Queue"; -import { Context, Effect, Layer } from "effect"; +import { Config, Context, Effect, Layer } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; @@ -54,12 +54,16 @@ export const SelfhostPushDeliveryDispatchLive = Layer.effect( const makePushDeliveryServiceLive = (config: SelfhostRuntimeConfig) => { const database = Db.layer(config.database); const crypto = PaymentConfigSecretCrypto.layer({ - key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + key: Config.string("ENCRYPTION_KEY").pipe(Config.withDefault(""), Effect.orDie), }); const providers = Layer.mergeAll( FirebaseCloudMessagingServiceConfigLive, makeApplePushNotificationServiceConfigLive({ - deliveryEnabled: Effect.sync(() => process.env.APNS_DELIVERY_ENABLED === "true"), + deliveryEnabled: Config.string("APNS_DELIVERY_ENABLED").pipe( + Config.withDefault(""), + Effect.map((value) => value === "true"), + Effect.orDie, + ), }), ).pipe(Layer.provide(crypto)); const tokens = NotificationTokenService.layer.pipe( diff --git a/apps/backend/src/backend/Thumbnails.ts b/apps/backend/src/backend/Thumbnails.ts index 132e410ad..380a8776d 100644 --- a/apps/backend/src/backend/Thumbnails.ts +++ b/apps/backend/src/backend/Thumbnails.ts @@ -12,10 +12,7 @@ import { } from "@voidhash/core/services/paywallThumbnails/SnapshotImageRenderer"; import { PublicFileStore } from "@voidhash/core/services/storage/PublicFileStore"; import { ComponentManifestCacheService } from "@voidhash/core/services/paywallWorkspace/ComponentManifestCacheService"; -import type { - PreviewTree, - SnapshotNode, -} from "@voidhash/paywall-renderer-web-core"; +import { causeMessage } from "@voidhash/lib/lang"; import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { QueueDriver } from "@voidhash/platform/Queue"; import { Screenshot } from "@voidhash/platform/Screenshot"; @@ -28,6 +25,32 @@ import { Cause, Effect, Layer } from "effect"; import { mimicDocumentIdleQueueName } from "../mimic/MimicDocumentIdleQueue.ts"; +/** Lazily loads Preact so the React compatibility global can be primed first. */ +const loadPreact = () => import("preact"); + +/** Lazily loads the Preact paywall renderer once the React global is primed. */ +const loadPaywallRenderer = () => import("@voidhash/paywall-renderer-preact"); + +/** + * Structural view of the Preact renderer entry point. `renderPaywallToHtml` is + * declared as a *method* so its parameters are compared bivariantly, which lets + * the deliberately `unknown`-typed {@link SnapshotImageRenderInput} fields (core + * must not depend on the renderer packages) flow through without an assertion. + */ +interface PaywallHtmlRenderer { + renderPaywallToHtml( + this: void, + snapshot: unknown, + options?: { + readonly componentArtifacts?: { + readonly trees?: Record>; + readonly localTrees?: Record>; + }; + readonly hydrate?: boolean; + }, + ): { readonly html: string }; +} + /** Bridges the generic Node screenshot adapter to the thumbnail-domain port. */ export const SelfhostHtmlScreenshotLive = Layer.effect( HtmlScreenshot, @@ -61,17 +84,16 @@ export const SelfhostSnapshotImageRendererLive = Layer.effect( Effect.gen(function* () { const htmlScreenshot = yield* HtmlScreenshot; const publicFileStore = yield* PublicFileStore; - const { renderPaywallToHtml } = yield* Effect.promise(async () => { - const preact = await import("preact"); - const runtimeGlobals = globalThis as unknown as { - React?: typeof preact; - }; - // The production Node entry executes workspace TSX through `tsx`, whose - // classic transform references the React global. Point that compatibility - // hook at Preact before the renderer evaluates any JSX. - runtimeGlobals.React ??= preact; - return import("@voidhash/paywall-renderer-preact"); - }); + const preact = yield* Effect.promise(loadPreact); + // The production Node entry executes workspace TSX through `tsx`, whose + // classic transform references the React global. Point that compatibility + // hook at Preact before the renderer evaluates any JSX. + const existingReact = Reflect.get(globalThis, "React"); + if (existingReact === undefined || existingReact === null) { + Reflect.set(globalThis, "React", preact); + } + const renderer: PaywallHtmlRenderer = yield* Effect.promise(loadPaywallRenderer); + const { renderPaywallToHtml } = renderer; return { render: ({ @@ -85,22 +107,16 @@ export const SelfhostSnapshotImageRendererLive = Layer.effect( Effect.gen(function* () { const html = yield* Effect.try({ try: () => - renderPaywallToHtml(snapshot as SnapshotNode, { + renderPaywallToHtml(snapshot, { componentArtifacts: { - trees: componentTrees as Record< - string, - Record - >, - localTrees: localComponentTrees as Record< - string, - Record - >, + trees: componentTrees, + localTrees: localComponentTrees, }, hydrate: false, }).html, catch: (cause) => new SnapshotImageRenderError({ - cause: cause instanceof Error ? cause.message : String(cause), + cause: causeMessage(cause), message: "rendering the paywall snapshot to HTML failed", }), }); diff --git a/apps/backend/src/compiler/CompilerClient.ts b/apps/backend/src/compiler/CompilerClient.ts index e1d2025cd..c7cd8cb07 100644 --- a/apps/backend/src/compiler/CompilerClient.ts +++ b/apps/backend/src/compiler/CompilerClient.ts @@ -1,5 +1,6 @@ import { ComponentCompiler } from "@voidhash/core/services/paywallWorkspace/ComponentCompiler"; -import { Effect, Layer, Schema } from "effect"; +import { Data, Effect, Layer, Schema } from "effect"; +import { FetchHttpClient, HttpBody, HttpClient } from "effect/unstable/http"; import { CompileCheckResponse, @@ -9,6 +10,20 @@ import { const decodeCheck = Schema.decodeUnknownEffect(CompileCheckResponse); const decodeExtract = Schema.decodeUnknownEffect(CompileExtractResponse); +const encodeCompileBody = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + mode: Schema.Literals(["check", "extract"]), + source: Schema.String, + }), + ), +); + +/** Non-2xx response from the compiler sidecar; degraded into `unavailable`. */ +class CompilerResponseError extends Data.TaggedError("CompilerResponseError")<{ + readonly message: string; +}> {} + const callCompiler = ( baseUrl: string, mode: "check" | "extract", @@ -16,25 +31,25 @@ const callCompiler = ( decode: (input: unknown) => Effect.Effect, unavailable: A, ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const response = await fetch(`${baseUrl}/compile`, { - body: JSON.stringify({ mode, source }), - headers: { "content-type": "application/json" }, - method: "POST", - signal: AbortSignal.timeout(30_000), + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.post(`${baseUrl}/compile`, { + body: HttpBody.text(encodeCompileBody({ mode, source }), "application/json"), + }); + if (response.status < 200 || response.status >= 300) { + return yield* new CompilerResponseError({ + message: `compiler returned HTTP ${response.status}`, }); - if (!response.ok) throw new Error(`compiler returned HTTP ${response.status}`); - return response.json(); - }, - catch: (cause) => cause, + } + return yield* decode(yield* response.json); }).pipe( - Effect.flatMap(decode), + Effect.timeout("30 seconds"), Effect.catchCause((cause) => Effect.logWarning("Component compiler unavailable", { cause }).pipe( Effect.as(unavailable), ), ), + Effect.provide(FetchHttpClient.layer), ); /** HTTP adapter from the backend compiler port to the isolated Node sidecar. */ diff --git a/apps/backend/src/compiler/CompilerCore.ts b/apps/backend/src/compiler/CompilerCore.ts index e861fbff2..f8fc9cba7 100644 --- a/apps/backend/src/compiler/CompilerCore.ts +++ b/apps/backend/src/compiler/CompilerCore.ts @@ -5,7 +5,8 @@ import { type CompileCheckResult, type CompileExtractResult, } from "@voidhash/core/services/paywallWorkspace/ComponentCompiler"; -import { Effect } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Data, Effect } from "effect"; import { createContext, Script } from "node:vm"; const manifestEvaluationTimeoutMs = 500; @@ -24,9 +25,44 @@ interface SandboxSurface { readonly renderComponentToTree: (typeof import("@voidhash/paywalls/sandbox"))["renderComponentToTree"]; } +type ComponentDefinitionInput = Parameters[0]; + +/** + * A broken toolchain: esbuild could not be loaded or the transform itself blew + * up. `thrown` keeps the original value so esbuild's structured diagnostics can + * still be read off it. + */ +class CompilerToolchainError extends Data.TaggedError("CompilerToolchainError")<{ + readonly message: string; + readonly thrown: unknown; +}> {} + +/** A failure raised by the user's component while it is evaluated or rendered. */ +class ComponentEvaluationError extends Data.TaggedError("ComponentEvaluationError")<{ + readonly message: string; +}> {} + +const toToolchainError = (thrown: unknown): CompilerToolchainError => + new CompilerToolchainError({ message: causeMessage(thrown), thrown }); + +const toEvaluationError = (thrown: unknown): ComponentEvaluationError => + new ComponentEvaluationError({ message: causeMessage(thrown) }); + +const isEsbuildFailure = (error: unknown): error is EsbuildFailure => { + if (typeof error !== "object" || error === null) return false; + return "errors" in error; +}; + +const isComponentDefinition = (value: unknown): value is ComponentDefinitionInput => { + if (value === null || value === undefined) return false; + if (typeof value !== "object" && typeof value !== "function") return false; + if (!("render" in value)) return false; + return typeof value.render === "function"; +}; + const toCompileDiagnostics = (error: unknown): ComponentCompileDiagnostic[] => { - if (typeof error !== "object" || error === null || !("errors" in error)) return []; - const errors = (error as EsbuildFailure).errors; + if (!isEsbuildFailure(error)) return []; + const errors = error.errors; if (!Array.isArray(errors) || errors.length === 0) return []; return errors.map((message) => ({ column: message.location?.column, @@ -36,124 +72,184 @@ const toCompileDiagnostics = (error: unknown): ComponentCompileDiagnostic[] => { })); }; -const nodeTransform = async (source: string): Promise => { - const esbuild = await import("esbuild"); - const result = await esbuild.transform(source, { - format: "cjs", - jsx: "automatic", - jsxImportSource: "@voidhash/paywalls", - loader: "tsx", - target: "es2022", +const compileErrorResult = ( + diagnostics: ReadonlyArray, +): CompileExtractResult => ({ diagnostics, phase: "compile", status: "error" }); + +const runtimeErrorResult = (message: string): CompileExtractResult => ({ + diagnostics: [{ message }], + phase: "runtime", + status: "error", +}); + +/** Lazily loads esbuild so the toolchain is only pulled in when a compile runs. */ +const importEsbuildModule = () => import("esbuild"); + +/** Lazily loads the paywall sandbox surface used to evaluate compiled components. */ +const importSandboxModule = () => import("@voidhash/paywalls/sandbox"); + +const loadEsbuild = Effect.tryPromise({ + try: importEsbuildModule, + catch: toToolchainError, +}); + +const loadSandbox = Effect.tryPromise({ + try: importSandboxModule, + catch: toToolchainError, +}); + +const nodeTransform = (source: string): Effect.Effect => + Effect.gen(function* () { + const esbuild = yield* loadEsbuild; + const result = yield* Effect.tryPromise({ + try: () => + esbuild.transform(source, { + format: "cjs", + jsx: "automatic", + jsxImportSource: "@voidhash/paywalls", + loader: "tsx", + target: "es2022", + }), + catch: toToolchainError, + }); + return result.code; }); - return result.code; -}; -const evaluateAndExtractManifest = (compiledCode: string, sandbox: SandboxSurface) => { - const requireShim = (specifier: string): unknown => { +/** + * The vm `require` hook is a synchronous V8 callback, so a missing module has to + * leave as a thrown value. Running an already-failed Effect keeps the tagged + * error model without a bare `throw` statement. + */ +const makeRequireShim = + (sandbox: SandboxSurface) => + (specifier: string): unknown => { const module = sandbox.modules[specifier]; - if (module === undefined) throw new Error(`Cannot find module '${specifier}'`); + if (module === undefined) { + return Effect.runSync( + Effect.fail( + new ComponentEvaluationError({ message: `Cannot find module '${specifier}'` }), + ), + ); + } return module; }; - const moduleObject: { exports: Record } = { exports: {} }; - const context = createContext( - { - exports: moduleObject.exports, - module: moduleObject, - require: requireShim, - }, - { - codeGeneration: { strings: false, wasm: false }, - microtaskMode: "afterEvaluate", - name: "voidhash-component-manifest", + +const evaluateModule = ( + compiledCode: string, + sandbox: SandboxSurface, +): Effect.Effect, ComponentEvaluationError> => + Effect.try({ + try: () => { + const moduleObject: { exports: Record } = { exports: {} }; + const context = createContext( + { + exports: moduleObject.exports, + module: moduleObject, + require: makeRequireShim(sandbox), + }, + { + codeGeneration: { strings: false, wasm: false }, + microtaskMode: "afterEvaluate", + name: "voidhash-component-manifest", + }, + ); + new Script(compiledCode, { filename: "component.cjs" }).runInContext(context, { + timeout: manifestEvaluationTimeoutMs, + }); + return moduleObject.exports; }, - ); - new Script(compiledCode, { filename: "component.cjs" }).runInContext(context, { - timeout: manifestEvaluationTimeoutMs, + catch: toEvaluationError, }); - const definition = moduleObject.exports.default ?? moduleObject.exports.definition; - if ( - definition === undefined || - definition === null || - typeof (definition as { render?: unknown }).render !== "function" - ) { - throw new Error("Component must export a default defineComponent({ ... })"); - } - const typedDefinition = definition as Parameters[0]; - return { - definition: typedDefinition, - manifest: sandbox.describeComponent(typedDefinition).manifest, - }; -}; -const compileAndExtract = async (source: string): Promise => { - let compiledCode: string; - try { - compiledCode = await nodeTransform(source); - } catch (error) { - const diagnostics = toCompileDiagnostics(error); - if (diagnostics.length === 0) throw error; - return { diagnostics, phase: "compile", status: "error" }; - } - - const sandbox = await import("@voidhash/paywalls/sandbox"); - try { - const { definition, manifest } = evaluateAndExtractManifest(compiledCode, sandbox); +const evaluateAndExtractManifest = (compiledCode: string, sandbox: SandboxSurface) => + Effect.gen(function* () { + const moduleExports = yield* evaluateModule(compiledCode, sandbox); + const definition = moduleExports.default ?? moduleExports.definition; + if (!isComponentDefinition(definition)) { + return yield* Effect.fail( + new ComponentEvaluationError({ + message: "Component must export a default defineComponent({ ... })", + }), + ); + } + const described = yield* Effect.try({ + try: () => sandbox.describeComponent(definition), + catch: toEvaluationError, + }); + return { definition, manifest: described.manifest }; + }); + +const renderPreviews = ( + compiledCode: string, + sandbox: SandboxSurface, +): Effect.Effect => + Effect.gen(function* () { + const { definition, manifest } = yield* evaluateAndExtractManifest(compiledCode, sandbox); const previewTrees: Record = {}; - const states = manifest.previewStates.length > 0 ? manifest.previewStates : ["default"]; + let states: ReadonlyArray = ["default"]; + if (manifest.previewStates.length > 0) states = manifest.previewStates; for (const state of states) { const fixture = definition.previews?.[state] ?? {}; - previewTrees[state] = await sandbox.renderComponentToTree(definition, { - state, - props: fixture.props, - hostData: { ...sandbox.defaultHostData(), ...fixture.data }, + previewTrees[state] = yield* Effect.tryPromise({ + try: () => + sandbox.renderComponentToTree(definition, { + state, + props: fixture.props, + hostData: { ...sandbox.defaultHostData(), ...fixture.data }, + }), + catch: toEvaluationError, }); } - return { - manifest, - previewTrees, - status: "ready", - }; - } catch (error) { - return { - diagnostics: [{ message: error instanceof Error ? error.message : String(error) }], - phase: "runtime", - status: "error", - }; - } -}; + const ready: CompileExtractResult = { manifest, previewTrees, status: "ready" }; + return ready; + }); + +const compileAndExtract = ( + source: string, +): Effect.Effect => + Effect.gen(function* () { + const compiled = yield* nodeTransform(source).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const diagnostics = toCompileDiagnostics(error.thrown); + if (diagnostics.length === 0) return yield* Effect.fail(error); + const failure: string | CompileExtractResult = compileErrorResult(diagnostics); + return failure; + }), + ), + ); + if (typeof compiled !== "string") return compiled; + + const sandbox = yield* loadSandbox; + return yield* renderPreviews(compiled, sandbox).pipe( + Effect.catch((error) => Effect.succeed(runtimeErrorResult(error.message))), + ); + }); /** Native compiler used exclusively inside the isolated self-host sidecar. */ /** Builds the self-hosted compiler that validates source and renders preview trees. */ export const makeNodeComponentCompiler = (): ComponentCompilerShape => ({ compileCheck: (source) => - Effect.tryPromise(async (): Promise => { - try { - await nodeTransform(source); - return { status: "ready" }; - } catch (error) { - const diagnostics = toCompileDiagnostics(error); - if (diagnostics.length === 0) throw error; - return { diagnostics, status: "error" }; - } - }).pipe( + nodeTransform(source).pipe( + Effect.map((): CompileCheckResult => ({ status: "ready" })), + Effect.catch((error) => + Effect.gen(function* () { + const diagnostics = toCompileDiagnostics(error.thrown); + if (diagnostics.length === 0) return yield* Effect.fail(error); + const failure: CompileCheckResult = { diagnostics, status: "error" }; + return failure; + }), + ), Effect.mapError( (error) => - new ComponentCompilerError({ - message: `esbuild transform failed: ${ - error instanceof Error ? error.message : String(error) - }`, - }), + new ComponentCompilerError({ message: `esbuild transform failed: ${error.message}` }), ), ), compileAndExtract: (source) => - Effect.tryPromise(() => compileAndExtract(source)).pipe( + compileAndExtract(source).pipe( Effect.mapError( (error) => - new ComponentCompilerError({ - message: `component extraction failed: ${ - error instanceof Error ? error.message : String(error) - }`, - }), + new ComponentCompilerError({ message: `component extraction failed: ${error.message}` }), ), ), }); diff --git a/apps/backend/src/compiler/main.ts b/apps/backend/src/compiler/main.ts index f5c4f59a8..0f7df6216 100644 --- a/apps/backend/src/compiler/main.ts +++ b/apps/backend/src/compiler/main.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the created server value is handed to the `@effect/platform-node` HTTP adapter, which requires a real `node:http` Server instance. import { createServer, type IncomingMessage, @@ -6,7 +7,8 @@ import { } from "node:http"; import { NodeRuntime } from "@effect/platform-node"; -import { Effect, Schema, Semaphore } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Cause, Config, Data, Effect, Schema, Semaphore } from "effect"; import { makeNodeComponentCompiler } from "./CompilerCore.ts"; import { CompilerRequest } from "./CompilerProtocol.ts"; @@ -15,87 +17,107 @@ const maximumBodyBytes = 1_048_576; const compiler = makeNodeComponentCompiler(); const compilerPermits = Semaphore.makeUnsafe(2); const decodeRequest = Schema.decodeUnknownEffect(CompilerRequest); +const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +class CompilerBodyError extends Data.TaggedError("CompilerBodyError")<{ + readonly message: string; +}> {} const sendJson = (response: ServerResponse, status: number, body: unknown): void => { response.writeHead(status, { "content-type": "application/json" }); - response.end(JSON.stringify(body)); + response.end(encodeJson(body)); }; -const readBody = (request: IncomingMessage): Promise => - new Promise((resolve, reject) => { +const readBody = (request: IncomingMessage): Effect.Effect => + Effect.callback((resume) => { const chunks: Buffer[] = []; let bytes = 0; request.on("data", (chunk: Buffer) => { bytes += chunk.byteLength; if (bytes > maximumBodyBytes) { - reject(new Error("compiler request exceeds 1 MiB")); + resume( + Effect.fail(new CompilerBodyError({ message: "compiler request exceeds 1 MiB" })), + ); request.destroy(); return; } chunks.push(chunk); }); request.on("end", () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); - } catch (error) { - reject(error); - } + resume( + decodeJson(Buffer.concat(chunks).toString("utf8")).pipe( + Effect.mapError((cause) => new CompilerBodyError({ message: causeMessage(cause) })), + ), + ); + }); + request.on("error", (error) => { + resume(Effect.fail(new CompilerBodyError({ message: causeMessage(error) }))); }); - request.on("error", reject); }); -const handleRequest = async ( +const compileRequest = (input: typeof CompilerRequest.Type) => { + if (input.mode === "check") return compiler.compileCheck(input.source); + return compiler.compileAndExtract(input.source); +}; + +const handleRequest = ( request: IncomingMessage, response: ServerResponse, -): Promise => { - if (request.method === "GET" && request.url === "/health") { - response.writeHead(200, { "content-type": "text/plain" }).end("OK"); - return; - } - if (request.method !== "POST" || request.url !== "/compile") { - sendJson(response, 404, { error: "Not found" }); - return; - } +): Effect.Effect => + Effect.gen(function* () { + if (request.method === "GET" && request.url === "/health") { + response.writeHead(200, { "content-type": "text/plain" }).end("OK"); + return; + } + if (request.method !== "POST" || request.url !== "/compile") { + sendJson(response, 404, { error: "Not found" }); + return; + } - try { - const input = await readBody(request).then((body) => - Effect.runPromise(decodeRequest(body)), - ); - const result = await Effect.runPromise( - compilerPermits.withPermit( - input.mode === "check" - ? compiler.compileCheck(input.source) - : compiler.compileAndExtract(input.source), - ), - ); + const body = yield* readBody(request); + const input = yield* decodeRequest(body); + const result = yield* compilerPermits.withPermit(compileRequest(input)); sendJson(response, 200, result); - } catch (error) { - sendJson(response, 500, { - error: error instanceof Error ? error.message : String(error), - }); - } -}; - -const port = Number(process.env.COMPILER_PORT ?? "5002"); -const host = process.env.COMPILER_HOST?.trim() || "0.0.0.0"; + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + sendJson(response, 500, { error: causeMessage(Cause.squash(cause)) }); + }), + ), + ); NodeRuntime.runMain( Effect.scoped( - Effect.acquireRelease( - Effect.callback((resume) => { - const server = createServer((request, response) => { - void handleRequest(request, response); - }); - server.once("error", (error) => resume(Effect.fail(error))); - server.listen(port, host, () => resume(Effect.succeed(server))); - }), - (server) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); + Effect.gen(function* () { + const port = yield* Config.port("COMPILER_PORT").pipe( + Config.withDefault(5002), + Effect.orDie, + ); + const configuredHost = yield* Config.string("COMPILER_HOST").pipe( + Config.withDefault("0.0.0.0"), + Effect.orDie, + ); + const host = configuredHost.trim() || "0.0.0.0"; + + return yield* Effect.acquireRelease( + Effect.callback((resume) => { + const server = createServer((request, response) => { + Effect.runFork(handleRequest(request, response)); + }); + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(port, host, () => resume(Effect.succeed(server))); }), - ).pipe( - Effect.tap(() => Effect.logInfo(`Component compiler listening on ${host}:${port}`)), - Effect.andThen(Effect.never), - ), - ) as never, + (server) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ).pipe( + Effect.tap(() => + Effect.logInfo(`Component compiler listening on ${host}:${port}`), + ), + Effect.andThen(Effect.never), + ); + }), + ), ); diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index 5196829bb..73ec61e25 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -1,3 +1,9 @@ +// This module is the self-host deployment's synchronous `process.env` adapter: every +// export is a plain function that reads environment variables and returns a concrete +// config record. Its results are consumed from synchronous call sites — including the +// pre-runtime bootstrap path that builds the layers — so there is no Effect runtime in +// scope in which a `Config` provider could be used. +// oxlint-disable effect/noGlobals -- synchronous process.env adapter; callers read these config records from synchronous positions before any Effect runtime exists. import type { DbConfig } from "@voidhash/db/db"; import type { SmtpMailerConfig } from "@voidhash/platform-selfhost/Mailer"; import type { S3ObjectStoreConfig } from "@voidhash/platform-selfhost/ObjectStore"; @@ -13,6 +19,7 @@ const positiveIntegerFromEnv = (name: string, fallback: number): number => { if (!value) return fallback; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { + // oxlint-disable-next-line effect/noThrowStatement, effect/noNewError -- synchronous env parser returning a plain `number`; making it fail in Effect would force every synchronous config call site in this module into an Effect. throw new Error(`${name} must be a positive integer`); } return parsed; @@ -23,24 +30,34 @@ const optionalBooleanFromEnv = (name: string): boolean | undefined => { if (!value) return undefined; if (value === "true") return true; if (value === "false") return false; + // oxlint-disable-next-line effect/noThrowStatement, effect/noNewError -- synchronous env parser returning `boolean | undefined`; making it fail in Effect would force every synchronous config call site in this module into an Effect. throw new Error(`${name} must be true or false`); }; +/** + * Reads an optional boolean override as a spreadable fragment. The key is + * omitted entirely when unset, because {@link DbConfig} consumers distinguish + * "no `ssl` key" (driver default) from an explicit `false`. + */ +const sslOverrideFromEnv = (name: string): { readonly ssl?: boolean } => { + const ssl = optionalBooleanFromEnv(name); + if (ssl === undefined) return {}; + return { ssl }; +}; + export type SelfhostMode = "local-evaluation" | "production"; const readSelfhostMode = (): SelfhostMode => { const mode = process.env.SELFHOST_MODE?.trim(); if (mode === "local-evaluation" || mode === "production") return mode; + // oxlint-disable-next-line effect/noThrowStatement, effect/noNewError -- synchronous env reader with a plain `SelfhostMode` return type; callers read it from synchronous positions on the pre-runtime bootstrap path, so a tagged Effect failure has nowhere to go. throw new Error("SELFHOST_MODE must be explicitly set to local-evaluation or production"); }; const isHttpsUrl = (value: string | undefined): boolean => { if (!value) return false; - try { - return new URL(value).protocol === "https:"; - } catch { - return false; - } + if (!URL.canParse(value)) return false; + return new URL(value).protocol === "https:"; }; /** @@ -76,6 +93,7 @@ export const validateSelfhostSecurityConfig = (): SelfhostMode => { } if (unsafeSettings.length > 0) { + // oxlint-disable-next-line effect/noThrowStatement, effect/noNewError -- synchronous self-host bootstrap guard: this runs from the process entrypoint before any Effect runtime exists, and a throw is the only way to abort the boot with a readable message. throw new Error( `Production self-host security validation failed for: ${unsafeSettings.join(", ")}`, ); @@ -192,17 +210,14 @@ export const getSelfhostClickhouseConfig = (): SelfhostClickhouseConfig | undefi }; /** Reads the shared application database connection from environment variables. */ -export const getSelfhostDatabaseConfig = (): DbConfig => { - const ssl = optionalBooleanFromEnv("DATABASE_SSL"); - return { - databaseName: process.env.DATABASE_NAME?.trim() || "voidhash", - host: process.env.DATABASE_HOST?.trim() || "127.0.0.1", - password: process.env.DATABASE_PASSWORD ?? "password", - port: positiveIntegerFromEnv("DATABASE_PORT", 5432), - ...(ssl === undefined ? {} : { ssl }), - username: process.env.DATABASE_USERNAME?.trim() || "voidhash", - }; -}; +export const getSelfhostDatabaseConfig = (): DbConfig => ({ + databaseName: process.env.DATABASE_NAME?.trim() || "voidhash", + host: process.env.DATABASE_HOST?.trim() || "127.0.0.1", + password: process.env.DATABASE_PASSWORD ?? "password", + port: positiveIntegerFromEnv("DATABASE_PORT", 5432), + ...sslOverrideFromEnv("DATABASE_SSL"), + username: process.env.DATABASE_USERNAME?.trim() || "voidhash", +}); /** * Reads the application database connection used by out-of-band tooling that @@ -218,14 +233,13 @@ export const getSelfhostDatabaseConfig = (): DbConfig => { */ export const getSelfhostMigrationDatabaseConfig = (): DbConfig => { const fallback = getSelfhostDatabaseConfig(); - const ssl = optionalBooleanFromEnv("DATABASE_DIRECT_SSL"); return { ...fallback, databaseName: process.env.DATABASE_DIRECT_NAME?.trim() || fallback.databaseName, host: process.env.DATABASE_DIRECT_HOST?.trim() || fallback.host, password: process.env.DATABASE_DIRECT_PASSWORD ?? fallback.password, port: positiveIntegerFromEnv("DATABASE_DIRECT_PORT", fallback.port), - ...(ssl === undefined ? {} : { ssl }), + ...sslOverrideFromEnv("DATABASE_DIRECT_SSL"), username: process.env.DATABASE_DIRECT_USERNAME?.trim() || fallback.username, }; }; @@ -247,23 +261,31 @@ export const getSelfhostMigrationDatabaseConfig = (): DbConfig => { */ export const getSelfhostPlatformDatabaseConfig = ( fallback: DbConfig = getSelfhostDatabaseConfig(), -): DbConfig => { - const ssl = optionalBooleanFromEnv("DATABASE_PLATFORM_SSL"); - return { - ...fallback, - databaseName: process.env.DATABASE_PLATFORM_NAME?.trim() || fallback.databaseName, - host: process.env.DATABASE_PLATFORM_HOST?.trim() || fallback.host, - password: process.env.DATABASE_PLATFORM_PASSWORD ?? fallback.password, - port: positiveIntegerFromEnv("DATABASE_PLATFORM_PORT", fallback.port), - ...(ssl === undefined ? {} : { ssl }), - username: process.env.DATABASE_PLATFORM_USERNAME?.trim() || fallback.username, - }; +): DbConfig => ({ + ...fallback, + databaseName: process.env.DATABASE_PLATFORM_NAME?.trim() || fallback.databaseName, + host: process.env.DATABASE_PLATFORM_HOST?.trim() || fallback.host, + password: process.env.DATABASE_PLATFORM_PASSWORD ?? fallback.password, + port: positiveIntegerFromEnv("DATABASE_PLATFORM_PORT", fallback.port), + ...sslOverrideFromEnv("DATABASE_PLATFORM_SSL"), + username: process.env.DATABASE_PLATFORM_USERNAME?.trim() || fallback.username, +}); + +/** Omits SMTP credentials entirely when the transport is unauthenticated. */ +const smtpCredentials = (): { + readonly username?: string; + readonly password?: Redacted.Redacted; +} => { + const credentials: { username?: string; password?: Redacted.Redacted } = {}; + const username = process.env.SMTP_USERNAME?.trim(); + if (username) credentials.username = username; + const password = process.env.SMTP_PASSWORD; + if (password) credentials.password = Redacted.make(password); + return credentials; }; /** Reads the SMTP transport and default sender configuration. */ export const getSelfhostSmtpConfig = (): SmtpMailerConfig => { - const username = process.env.SMTP_USERNAME?.trim() || undefined; - const password = process.env.SMTP_PASSWORD || undefined; return { defaultFrom: { address: process.env.SMTP_FROM_ADDRESS?.trim() || "noreply@voidhash.local", @@ -275,11 +297,50 @@ export const getSelfhostSmtpConfig = (): SmtpMailerConfig => { secure: optionalBooleanFromEnv("SMTP_SECURE") ?? false, tlsRejectUnauthorized: optionalBooleanFromEnv("SMTP_TLS_REJECT_UNAUTHORIZED") ?? true, verifyOnStart: optionalBooleanFromEnv("SMTP_VERIFY_ON_START") ?? false, - ...(username === undefined ? {} : { username }), - ...(password === undefined ? {} : { password: Redacted.make(password) }), + ...smtpCredentials(), }; }; +/** The provider and model used when the operator pins neither explicitly. */ +const defaultAgentModel = ( + openaiApiKey: string | undefined, +): { readonly provider: string; readonly modelId: string } => { + if (openaiApiKey) return { modelId: "gpt-5.4", provider: "openai" }; + return { modelId: "claude-sonnet-4-6", provider: "anthropic" }; +}; + +/** Omits BYO provider keys that were never configured. */ +const agentApiKeys = ( + openaiApiKey: string | undefined, + anthropicApiKey: string | undefined, +): { + readonly openaiApiKey?: Redacted.Redacted; + readonly anthropicApiKey?: Redacted.Redacted; +} => { + const keys: { + openaiApiKey?: Redacted.Redacted; + anthropicApiKey?: Redacted.Redacted; + } = {}; + if (openaiApiKey !== undefined) keys.openaiApiKey = Redacted.make(openaiApiKey); + if (anthropicApiKey !== undefined) keys.anthropicApiKey = Redacted.make(anthropicApiKey); + return keys; +}; + +/** Omits the OpenAI-compatible base URL unless an override is configured. */ +const agentOpenaiBaseUrl = (): { readonly openaiBaseUrl?: string } => { + const openaiBaseUrl = process.env.OPENAI_BASE_URL?.trim(); + if (!openaiBaseUrl) return {}; + return { openaiBaseUrl }; +}; + +/** Omits the ClickHouse block entirely when analytics is disabled. */ +const optionalClickhouse = ( + clickhouse: SelfhostClickhouseConfig | undefined, +): { readonly clickhouse?: SelfhostClickhouseConfig } => { + if (clickhouse === undefined) return {}; + return { clickhouse }; +}; + /** Reads and validates the complete single-process runtime configuration. */ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { validateSelfhostSecurityConfig(); @@ -298,8 +359,7 @@ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { const clickhouse = getSelfhostClickhouseConfig(); const openaiApiKey = process.env.OPENAI_API_KEY?.trim(); const anthropicApiKey = process.env.ANTHROPIC_API_KEY?.trim(); - const defaultProvider = openaiApiKey ? "openai" : "anthropic"; - const defaultModelId = openaiApiKey ? "gpt-5.4" : "claude-sonnet-4-6"; + const { modelId: defaultModelId, provider: defaultProvider } = defaultAgentModel(openaiApiKey); return { agent: { @@ -307,11 +367,8 @@ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { modelId: process.env.VOIDHASH_AGENT_MODEL_ID?.trim() || defaultModelId, visionProvider: process.env.VOIDHASH_AGENT_VISION_MODEL_PROVIDER?.trim() || defaultProvider, visionModelId: process.env.VOIDHASH_AGENT_VISION_MODEL_ID?.trim() || defaultModelId, - ...(openaiApiKey === undefined ? {} : { openaiApiKey: Redacted.make(openaiApiKey) }), - ...(anthropicApiKey === undefined ? {} : { anthropicApiKey: Redacted.make(anthropicApiKey) }), - ...(process.env.OPENAI_BASE_URL?.trim() - ? { openaiBaseUrl: process.env.OPENAI_BASE_URL.trim() } - : {}), + ...agentApiKeys(openaiApiKey, anthropicApiKey), + ...agentOpenaiBaseUrl(), }, artifactObjectStore: { ...objectStore, @@ -319,7 +376,7 @@ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { }, auth: getSelfhostAuthConfig(), database: getSelfhostDatabaseConfig(), - ...(clickhouse === undefined ? {} : { clickhouse }), + ...optionalClickhouse(clickhouse), componentCompilerUrl: process.env.COMPONENT_COMPILER_URL?.trim() || "http://127.0.0.1:5002", host: process.env.HOST?.trim() || "0.0.0.0", mailer: getSelfhostSmtpConfig(), diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 3dae94f6c..89ec36c65 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -7,6 +7,7 @@ import { import { runSelfhostServer } from "./server.ts"; NodeRuntime.runMain( + // oxlint-disable-next-line effect/noAs -- erases the self-host server Effect's requirement/error shape for `NodeRuntime.runMain` at the single process entrypoint; `satisfies` only checks a type, it cannot perform this erasure. runSelfhostServer({ edition: "Community Edition", features: NoBackendFeatures, diff --git a/apps/backend/src/migrate.ts b/apps/backend/src/migrate.ts index 0ac81c2fe..45e6fac50 100644 --- a/apps/backend/src/migrate.ts +++ b/apps/backend/src/migrate.ts @@ -3,4 +3,4 @@ import { Effect } from "effect"; import { runSelfhostMigrations } from "./migrations.ts"; -NodeRuntime.runMain(Effect.scoped(runSelfhostMigrations()) as never); +NodeRuntime.runMain(Effect.scoped(runSelfhostMigrations())); diff --git a/apps/backend/src/mimic/MimicNodeWebSocket.ts b/apps/backend/src/mimic/MimicNodeWebSocket.ts index ba8843dc1..c668f25a1 100644 --- a/apps/backend/src/mimic/MimicNodeWebSocket.ts +++ b/apps/backend/src/mimic/MimicNodeWebSocket.ts @@ -1,6 +1,10 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- Node platform adapter: it upgrades connections on the real http.Server created by the standalone entrypoint, so it needs that module's own types. import type { IncomingMessage, Server } from "node:http"; +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the WebSocket upgrade handler receives a node:stream Duplex from the Node HTTP server; Stream/Channel cannot type that handshake argument. import type { Duplex } from "node:stream"; +import { createIdGenerator } from "@voidhash/core/utils/generate-id"; +import { causeMessage, constant } from "@voidhash/lib/lang"; import type { HostService } from "@voidhash/mimic-db/app/hostService"; import { AUTH_DEADLINE_MS, @@ -23,7 +27,7 @@ import { makeDurableEntityAddress, } from "@voidhash/platform/DurableEntity"; import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/NodeDurableEntitySession"; -import { Duration, Effect, Fiber, Semaphore } from "effect"; +import { Clock, Duration, Effect, Fiber, Semaphore } from "effect"; import WebSocket, { WebSocketServer, type RawData } from "ws"; import { @@ -58,6 +62,17 @@ export interface MimicNodeIdleNotificationOptions { const mimicDocumentEntityType = "mimic-document"; +/** Ephemeral per-socket connection ids; opaque outside this adapter. */ +const generateConnectionId = createIdGenerator({ connection: "conn" }); + +/** Reads wall-clock millis through the ambient `Clock` from a sync callback. */ +const nowMillis = (): number => Effect.runSync(Clock.currentTimeMillis); + +const numberOrUndefined = (value: unknown): number | undefined => { + if (typeof value === "number") return value; + return undefined; +}; + const documentKey = (collectionId: string, documentId: string): string => `${collectionId}\u0000${documentId}`; @@ -87,13 +102,10 @@ const makeNodeIdleNotifier = ( collectionId, debounceMs: options.debounceMs, documentId, - now: Date.now, + now: nowMillis, publish: options.publish, storage: { - get: (key) => - entity.keyValue - .get(key) - .pipe(Effect.map((value) => (typeof value === "number" ? value : undefined))), + get: (key) => entity.keyValue.get(key).pipe(Effect.map(numberOrUndefined)), put: (key, value) => entity.keyValue.put(key, value), setAlarm: entity.alarm.set, }, @@ -150,7 +162,14 @@ const parseDocumentAddress = ( }; const toFrame = (data: RawData, isBinary: boolean): string | Uint8Array => { - if (!isBinary) return data.toString(); + // `ws` hands text frames over as a Buffer, an ArrayBuffer or a Buffer[] + // depending on `binaryType`; only the Buffer case decodes correctly on its + // own, so the other two are normalized before being read as text. + if (!isBinary) { + if (data instanceof ArrayBuffer) return Buffer.from(data).toString(); + if (Array.isArray(data)) return Buffer.concat(data).toString(); + return data.toString(); + } if (data instanceof ArrayBuffer) return new Uint8Array(data); if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)); return new Uint8Array(data); @@ -158,8 +177,7 @@ const toFrame = (data: RawData, isBinary: boolean): string | Uint8Array => { // HostService's legacy signatures retain `R = any`; the fully-built entry // layer has already discharged those requirements at this adapter boundary. -const withoutRequirements = (effect: Effect.Effect): Effect.Effect => - effect as Effect.Effect; +const withoutRequirements = (effect: Effect.Effect): Effect.Effect => effect; const run = (effect: Effect.Effect): void => { Effect.runFork( @@ -234,8 +252,8 @@ export const installMimicNodeWebSocketServer = ( Effect.runSync(socket.entitySession.setAttachment(attachment)); }, send: (socket, message) => - Effect.sync(() => void socket.webSocket.send(encodeServerMessage(message))), - close: (socket, code, reason) => Effect.sync(() => void 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( @@ -247,18 +265,16 @@ export const installMimicNodeWebSocketServer = ( ), loadDocument: () => withoutRequirements(host.getDocument(collectionId, documentId)).pipe( - Effect.mapError((error) => ({ - message: error instanceof Error ? error.message : String(error), - })), + Effect.mapError((error) => ({ message: causeMessage(error) })), ), submitTransaction: (transaction) => withoutRequirements(host.submitTransaction(collectionId, documentId, transaction)).pipe( Effect.catch((error) => Effect.succeed({ - accepted: false as const, + accepted: constant(false), version: 0, transactionId: transaction.id, - reason: error instanceof Error ? error.message : String(error), + reason: causeMessage(error), }), ), ), @@ -284,11 +300,11 @@ export const installMimicNodeWebSocketServer = ( webSockets.handleUpgrade(request, socket, head, (webSocket) => { const runtime = runtimeFor(address.collectionId, address.documentId); const attachment: SessionAttachment = { - connectionId: crypto.randomUUID(), + connectionId: generateConnectionId("connection"), collectionId: address.collectionId, documentId: address.documentId, origin: request.headers.origin ?? null, - connectedAt: Date.now(), + connectedAt: nowMillis(), authenticated: false, }; const entitySession = makeNodeDurableEntitySession( diff --git a/apps/backend/src/mimic/PgControlStore.ts b/apps/backend/src/mimic/PgControlStore.ts index 4c1efcf24..01eca2929 100644 --- a/apps/backend/src/mimic/PgControlStore.ts +++ b/apps/backend/src/mimic/PgControlStore.ts @@ -11,7 +11,7 @@ import type { } from "@voidhash/mimic-db/core/store"; import { ControlStore } from "@voidhash/mimic-db/core/store"; import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Predicate, Schema } from "effect"; import { SqlClient } from "effect/unstable/sql"; interface ControlState { @@ -28,6 +28,8 @@ interface ControlStateRow { readonly state: unknown; } +const encodeStateJson = Schema.encodeSync(Schema.UnknownFromJsonString); + const emptyState = (): ControlState => ({ databases: [], collections: [], @@ -38,23 +40,36 @@ const emptyState = (): ControlState => ({ documents: [], }); +/** + * Reads one array-valued field off the persisted control-state blob. + * + * `Array.isArray` narrows `unknown` to `Array`, which lets the caller name + * the row type without an assertion; anything else degrades to an empty list. + */ +const readRows = (state: { readonly [key: PropertyKey]: unknown }, key: string): A[] => { + const rows = state[key]; + if (Array.isArray(rows)) return rows; + return []; +}; + +const migrationVersionOf = (value: number | null | undefined): number | null => { + if (typeof value === "number") return value; + return null; +}; + const decodeState = (value: unknown): ControlState => { - if (typeof value !== "object" || value === null) return emptyState(); - const state = value as Partial; + if (!Predicate.isObject(value)) return emptyState(); return { - databases: Array.isArray(state.databases) ? state.databases : [], - collections: Array.isArray(state.collections) - ? state.collections.map((collection) => ({ - ...collection, - migrationVersion: - typeof collection.migrationVersion === "number" ? collection.migrationVersion : null, - })) - : [], - schemaVersions: Array.isArray(state.schemaVersions) ? state.schemaVersions : [], - users: Array.isArray(state.users) ? state.users : [], - grants: Array.isArray(state.grants) ? state.grants : [], - tokens: Array.isArray(state.tokens) ? state.tokens : [], - documents: Array.isArray(state.documents) ? state.documents : [], + databases: readRows(value, "databases"), + collections: readRows(value, "collections").map((collection) => ({ + ...collection, + migrationVersion: migrationVersionOf(collection.migrationVersion), + })), + schemaVersions: readRows(value, "schemaVersions"), + users: readRows(value, "users"), + grants: readRows(value, "grants"), + tokens: readRows(value, "tokens"), + documents: readRows(value, "documents"), }; }; @@ -87,12 +102,16 @@ export const makePgControlStore = (sql: SqlClient.SqlClient): ControlStoreApi => const load = sql` SELECT state_json AS "state" FROM mimic_control_state WHERE id = 'default' `.pipe( - Effect.map((rows) => (rows[0] ? decodeState(rows[0].state) : emptyState())), + Effect.map((rows) => { + const row = rows[0]; + if (!row) return emptyState(); + return decodeState(row.state); + }), Effect.orDie, ); const save = (state: ControlState) => { - const json = JSON.stringify(state); + const json = encodeStateJson(state); return sql` INSERT INTO mimic_control_state (id, state_json) VALUES ('default', ${json}::jsonb) diff --git a/apps/backend/src/mimic/main.ts b/apps/backend/src/mimic/main.ts index 19792c84b..3dad5c420 100644 --- a/apps/backend/src/mimic/main.ts +++ b/apps/backend/src/mimic/main.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the created server is handed to NodeHttpServer.layer and to the WebSocket upgrade installer, both of which require a real http.Server instance. import { createServer } from "node:http"; import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; @@ -8,7 +9,7 @@ import { DurableEntityAlarmControl, DurableEntityHost, } from "@voidhash/platform/DurableEntity"; -import { Context, Effect, Layer } from "effect"; +import { Config, Context, Effect, Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; import { makeSelfhostPlatformLayers } from "../backend/PlatformProfile.ts"; @@ -18,7 +19,6 @@ import { makeSelfhostMimicDocumentIdlePublisher } from "./MimicDocumentIdleQueue import { makeMimicNodeHostLive } from "./MimicNode.ts"; import { installMimicNodeWebSocketServer } from "./MimicNodeWebSocket.ts"; -const port = Number(process.env.PORT ?? "5001"); const config = getMimicNodeConfig(); // Idle-document notifications are produced here and consumed by the backend, so // this process has to publish onto the same queue the backend installs there. @@ -30,8 +30,10 @@ const platform = makeSelfhostPlatformLayers({ const hostLayer = makeMimicNodeHostLive(config, platform.durableEntities); NodeRuntime.runMain( + // oxlint-disable-next-line effect/noAs -- see the comment at the closing `as never`: HttpRouter.toHttpEffect leaks HttpServerRequest into the program requirements even though makeHandler supplies it per request; the assertion is the upstream typing escape hatch. Effect.scoped( Effect.gen(function* () { + const port = yield* Config.port("PORT").pipe(Config.withDefault(5001), Effect.orDie); const hostContext = yield* Layer.build(hostLayer); const host = Context.get(hostContext, HostServiceTag); const entities = Context.get(hostContext, DurableEntityHost); @@ -80,5 +82,8 @@ NodeRuntime.runMain( yield* Effect.logInfo(`Listening on http://0.0.0.0:${port}`); yield* Effect.never; }), + // `HttpRouter.toHttpEffect` leaks `HttpServerRequest` into the program's + // requirements even though `makeHandler` supplies it per request; the + // assertion is the upstream typing escape hatch. ) as never, ); diff --git a/apps/backend/src/release-smoke.ts b/apps/backend/src/release-smoke.ts index 1fa6368c4..40c3de647 100644 --- a/apps/backend/src/release-smoke.ts +++ b/apps/backend/src/release-smoke.ts @@ -7,7 +7,7 @@ import { PaywallReleaseService, } from "@voidhash/core/services"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; -import { Context, Effect, Layer } from "effect"; +import { Config, Console, Context, DateTime, Effect, Layer, Schema } from "effect"; import { makeBackendInfrastructureLive, @@ -20,14 +20,22 @@ import { getMimicNodeConfig } from "./mimic/config.ts"; const resultPrefix = "SELFHOST_RELEASE_RESULT "; -const requiredEnv = (name: string): string => { - const value = process.env[name]?.trim(); - if (!value) throw new Error(`${name} is required`); - return value; -}; +/** JSON text of the smoke result line consumed by the release pipeline. */ +const encodeResultJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +/** + * Reads a required smoke-run environment variable. A missing value is a harness + * misconfiguration, so it is raised as a defect exactly as the previous `throw`. + */ +const requiredEnv = (name: string): Effect.Effect => + Effect.gen(function* () { + const raw = yield* Config.string(name).pipe(Config.withDefault(""), Effect.orDie); + const value = raw.trim(); + if (!value) return yield* Effect.die(new Error(`${name} is required`)); + return value; + }); -const makeSession = (projectId: string, userId: string): AnyAuthSession => { - const now = new Date(); +const makeSession = (projectId: string, userId: string, now: Date): AnyAuthSession => { return { cookie: null, method: "user", @@ -61,9 +69,10 @@ const makeSession = (projectId: string, userId: string): AnyAuthSession => { NodeRuntime.runMain( Effect.scoped( Effect.gen(function* () { - const paywallId = requiredEnv("SELFHOST_RELEASE_PAYWALL_ID"); - const projectId = requiredEnv("SELFHOST_RELEASE_PROJECT_ID"); - const userId = requiredEnv("SELFHOST_RELEASE_USER_ID"); + const paywallId = yield* requiredEnv("SELFHOST_RELEASE_PAYWALL_ID"); + const projectId = yield* requiredEnv("SELFHOST_RELEASE_PROJECT_ID"); + const userId = yield* requiredEnv("SELFHOST_RELEASE_USER_ID"); + const now = yield* DateTime.nowAsDate; const config = getSelfhostRuntimeConfig(); const hostContext = yield* Layer.build( makeMimicNodeHostLive( @@ -99,12 +108,10 @@ NodeRuntime.runMain( return { draft, published }; }).pipe( Effect.provide(releaseLayer), - Effect.provideService(AuthSession, makeSession(projectId, userId)), + Effect.provideService(AuthSession, makeSession(projectId, userId, now)), ); - yield* Effect.sync(() => { - process.stdout.write(`${resultPrefix}${JSON.stringify(result)}\n`); - }); + yield* Console.log(`${resultPrefix}${encodeResultJson(result)}`); }), - ) as never, + ), ); diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index ad6cd70af..32e156a70 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the created server value is handed to the `@effect/platform-node` HTTP adapter, which requires a real `node:http` Server instance. import { createServer } from "node:http"; import { NodeHttpServer } from "@effect/platform-node"; @@ -23,7 +24,8 @@ import { getConfig as getMimicConfig } from "@voidhash/mimic-db/config"; import { makeRoutesLive } from "@voidhash/mimic-db/http/rpc-app"; import { DurableEntityAlarmControl, DurableEntityHost } from "@voidhash/platform/DurableEntity"; import { SmtpMailerLive } from "@voidhash/platform-selfhost/Mailer"; -import { Context, Effect, Layer } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Config, Context, Data, Effect, Layer, Option } from "effect"; import { HttpRouter } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; import type * as Rpc from "effect/unstable/rpc/Rpc"; @@ -64,6 +66,52 @@ const isCaptureRequest = (url: string | undefined): boolean => { return pathname === "/i" || pathname.startsWith("/i/"); }; +/** Boot-time misconfiguration of the self-host process; never crosses a wire. */ +class SelfhostServerBootError extends Data.TaggedError("SelfhostServerBootError")<{ + readonly message: string; +}> {} + +/** Reads an optional environment variable, mirroring `process.env.X`. */ +const optionalEnv = (name: string): Effect.Effect => + Config.string(name).pipe(Config.option, Effect.map(Option.getOrUndefined), Effect.orDie); + +/** Reads an optional environment variable, trimmed, mirroring `process.env.X?.trim()`. */ +const optionalTrimmedEnv = (name: string): Effect.Effect => + optionalEnv(name).pipe(Effect.map((value) => value?.trim())); + +const makeClickhouseLayers = (config: SelfhostRuntimeConfig) => { + if (!config.clickhouse) return undefined; + return makeSelfhostClickhouseLayers(config.clickhouse); +}; + +const makeChromiumConfig = ( + executablePath: string | undefined, + disableSandbox: boolean, +): { readonly disableSandbox: boolean; readonly executablePath: string } | undefined => { + if (!executablePath) return undefined; + return { disableSandbox, executablePath }; +}; + +const makeSnapshotImageRenderer = ( + chromiumConfig: { readonly disableSandbox: boolean; readonly executablePath: string } | undefined, +) => { + if (chromiumConfig === undefined) return undefined; + return makeSelfhostSnapshotImageRendererLive(chromiumConfig); +}; + +/** Loads the WWW handler when both of its environment variables are configured. */ +const loadWwwHandler = (serverEntry: string | undefined, clientDirectory: string | undefined) => + Effect.gen(function* () { + if (!serverEntry || !clientDirectory) return undefined; + return yield* Effect.tryPromise({ + try: () => loadWwwRequestHandler(serverEntry, clientDirectory), + catch: (cause) => + new SelfhostServerBootError({ + message: `Failed to load the WWW server bundle: ${causeMessage(cause)}`, + }), + }); + }); + /** * The runtime values a composition root can only obtain from inside the server * boot sequence, handed to the option factories that need them. @@ -142,24 +190,19 @@ export const runSelfhostServer = < yield* Effect.logInfo( `Identity provider: standalone (root user ${config.auth.rootUsername})`, ); - const clickhouse = config.clickhouse - ? makeSelfhostClickhouseLayers(config.clickhouse) - : undefined; - const chromiumExecutablePath = process.env.CHROMIUM_EXECUTABLE_PATH?.trim(); - const chromiumConfig = chromiumExecutablePath - ? { - disableSandbox: process.env.CHROMIUM_DISABLE_SANDBOX === "true", - executablePath: chromiumExecutablePath, - } - : undefined; + const clickhouse = makeClickhouseLayers(config); + const chromiumExecutablePath = yield* optionalTrimmedEnv("CHROMIUM_EXECUTABLE_PATH"); + const chromiumDisableSandbox = yield* optionalEnv("CHROMIUM_DISABLE_SANDBOX"); + const chromiumConfig = makeChromiumConfig( + chromiumExecutablePath, + chromiumDisableSandbox === "true", + ); const infrastructure = Layer.mergeAll( makeBackendInfrastructureLive( config, authLayers.identity, clickhouse?.readOnly, - chromiumConfig === undefined - ? undefined - : makeSelfhostSnapshotImageRendererLive(chromiumConfig), + makeSnapshotImageRenderer(chromiumConfig), ), options.identityDirectory ?? Layer.empty, ).pipe(Layer.provide(hostLayer)); @@ -192,7 +235,7 @@ export const runSelfhostServer = < features: options.features, infrastructure, pushDeliveryDispatch, - ...(options.mcpOAuth === undefined ? {} : { mcpOAuth: options.mcpOAuth }), + mcpOAuth: options.mcpOAuth, }), infrastructure, ), @@ -242,10 +285,10 @@ export const runSelfhostServer = < features: options.features, rpcExtension, infrastructure, - ...(clickhouse === undefined ? {} : { analyticsQueryClient: clickhouse.analyticsQuery }), + analyticsQueryClient: clickhouse?.analyticsQuery, pushDeliveryDispatch, - ...(options.routeExtension === undefined ? {} : { routeExtension: options.routeExtension }), - ...(options.mcpOAuth === undefined ? {} : { mcpOAuth: options.mcpOAuth }), + routeExtension: options.routeExtension, + mcpOAuth: options.mcpOAuth, }).pipe(Effect.provide(runtimeContext)); const mimicEffect = yield* makeRoutesLive(hostLayer).pipe( Layer.provide(NodeHttpServer.layerHttpServices), @@ -271,20 +314,14 @@ export const runSelfhostServer = < captureEffect.pipe(Effect.provide(runtimeContext)), { scope }, ); - const wwwServerEntry = process.env.WWW_SERVER_ENTRY?.trim(); - const wwwClientDirectory = process.env.WWW_CLIENT_DIRECTORY?.trim(); + const wwwServerEntry = yield* optionalTrimmedEnv("WWW_SERVER_ENTRY"); + const wwwClientDirectory = yield* optionalTrimmedEnv("WWW_CLIENT_DIRECTORY"); if ((wwwServerEntry === undefined) !== (wwwClientDirectory === undefined)) { - return yield* Effect.fail( - new Error("WWW_SERVER_ENTRY and WWW_CLIENT_DIRECTORY must be configured together"), - ); + return yield* new SelfhostServerBootError({ + message: "WWW_SERVER_ENTRY and WWW_CLIENT_DIRECTORY must be configured together", + }); } - const wwwHandler = - wwwServerEntry && wwwClientDirectory - ? yield* Effect.tryPromise({ - try: () => loadWwwRequestHandler(wwwServerEntry, wwwClientDirectory), - catch: (cause) => new Error("Failed to load the WWW server bundle", { cause }), - }) - : undefined; + const wwwHandler = yield* loadWwwHandler(wwwServerEntry, wwwClientDirectory); const server = createServer((request, response) => { if (isMimicRequest(request.url)) { mimicHandler(request, response); @@ -295,8 +332,8 @@ export const runSelfhostServer = < return; } if (wwwHandler !== undefined && isWwwRequest(request.url)) { - wwwHandler(request, response).catch((error) => { - console.error("WWW request failed", error); + wwwHandler(request, response).catch((error: unknown) => { + Effect.runFork(Effect.logError(`WWW request failed: ${causeMessage(error)}`)); if (!response.headersSent) { response.statusCode = 500; } diff --git a/apps/backend/src/www/Www.ts b/apps/backend/src/www/Www.ts index 339efee9e..2f9d208ca 100644 --- a/apps/backend/src/www/Www.ts +++ b/apps/backend/src/www/Www.ts @@ -1,6 +1,17 @@ +// This module is the Node HTTP platform adapter for the self-hosted WWW surface: it is handed a +// raw `http.IncomingMessage`/`ServerResponse` pair by a Node server and must speak Node's callback +// and promise APIs directly. Every handler here is an `async` function because that is the shape +// the Node server (and srvx's `sendNodeResponse`) requires; wrapping them in Effect would mean +// running a runtime per request and would leak an Effect dependency into the selfhost entrypoint. +// oxlint-disable effect/noAsyncFunction -- Node HTTP adapter: the exported handlers must BE async functions with the exact `(req, res) => Promise` signature Node's `http.Server` and srvx call; Effect.gen cannot be handed to `server.on("request", ...)`. + +// oxlint-disable-next-line effect/noNodeBuiltinImport -- platform adapter: the byte stream is piped straight into a Node `ServerResponse`, which only accepts a Node stream, not an Effect FileSystem Stream. import { createReadStream } from "node:fs"; +// oxlint-disable-next-line effect/noNodeBuiltinImport -- platform adapter: `stat` is called from an async Node request handler that has no Effect runtime to provide FileSystem from. import { stat } from "node:fs/promises"; +// oxlint-disable-next-line effect/noNodeBuiltinImport -- type-only import of Node's real `IncomingMessage`/`ServerResponse`; these are the concrete values Node hands the handler, so no Effect HttpServer type can stand in. import type { IncomingMessage, ServerResponse } from "node:http"; +// oxlint-disable-next-line effect/noNodeBuiltinImport -- platform adapter: path resolution happens synchronously inside the Node request handler, outside any Effect runtime that could supply Path. import { extname, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; @@ -49,6 +60,7 @@ const serveStaticFile = async ( } let pathname: string; + // oxlint-disable-next-line effect/noTryCatch -- guards the synchronous `decodeURIComponent`/`URL` throw on a malformed request path; this runs in a plain Node handler with no Effect runtime, and a malformed URL must simply fall through to the app fetch handler. try { pathname = decodeURIComponent(new URL(request.url ?? "/", "http://selfhost.local").pathname); } catch { @@ -62,12 +74,15 @@ const serveStaticFile = async ( } let metadata; + // oxlint-disable-next-line effect/noTryCatch -- distinguishes ENOENT (fall through to the app handler) from real IO failures inside a plain Node handler; there is no Effect runtime here to carry a typed error. try { metadata = await stat(candidate); } catch (error) { + // oxlint-disable-next-line effect/noAs -- Node rejects `stat` with a bare `Error`; narrowing to `ErrnoException` to read `.code` is the only way to detect ENOENT, and `satisfies` cannot narrow an `unknown` catch binding. if ((error as NodeJS.ErrnoException).code === "ENOENT") { return false; } + // oxlint-disable-next-line effect/noThrowStatement -- rethrows the original Node IO error to the Node server's own error handling; converting it to an Effect failure would require an Effect runtime this adapter deliberately does not have. throw error; } @@ -78,6 +93,7 @@ const serveStaticFile = async ( response.statusCode = 200; response.setHeader( "Cache-Control", + // oxlint-disable-next-line effect/noTernary -- inline header-value selection in a Node adapter; Match.value would pull an Effect import into this deliberately Effect-free module for a two-branch string choice. pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "public, max-age=3600", @@ -94,6 +110,7 @@ const serveStaticFile = async ( return true; } + // oxlint-disable-next-line effect/noNewPromise -- bridges Node's stream/response event callbacks ("error"/"finish") into the async handler contract; the caller is Node, not an Effect runtime, so Effect.async has nothing to run in here. await new Promise((resolveStream, rejectStream) => { const stream = createReadStream(candidate); stream.once("error", rejectStream); @@ -127,11 +144,13 @@ export const loadWwwRequestHandler = async ( serverEntry: string, clientDirectory: string, ): Promise => { + // oxlint-disable-next-line effect/noAs -- the built TanStack Start server entry is loaded by URL at runtime, so its module shape is `any` to the compiler; `satisfies` cannot type an untyped dynamic import, and the shape is checked at runtime just below. const loaded = (await import(pathToFileURL(resolve(serverEntry)).href)) as { readonly default?: { readonly fetch?: WwwFetch }; }; const fetch = loaded.default?.fetch; if (fetch === undefined) { + // oxlint-disable-next-line effect/noThrowStatement, effect/noNewError -- this loader returns a plain Promise to the selfhost bootstrap, which runs before any Effect runtime exists; a missing WWW build is a fatal startup misconfiguration and a rejected promise is the only failure channel the caller has. throw new Error(`WWW server entry does not export a default fetch handler: ${serverEntry}`); } diff --git a/apps/backend/tests/AgentNodeWebSocket.integration.test.ts b/apps/backend/tests/AgentNodeWebSocket.integration.test.ts index 6ae5feb6d..817cbbab3 100644 --- a/apps/backend/tests/AgentNodeWebSocket.integration.test.ts +++ b/apps/backend/tests/AgentNodeWebSocket.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- integration test boots the real Node HTTP server the agent WebSocket adapter attaches to. import { createServer, type Server } from "node:http"; import { @@ -8,52 +9,83 @@ import { PaywallWorkspaceService, } from "@voidhash/core/services"; import { Db } from "@voidhash/db"; +import { causeMessage, constant } from "@voidhash/lib/lang"; import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; -import { Context, Effect, Redacted } from "effect"; +import { Context, Data, DateTime, Effect, Latch, Redacted, Schema } from "effect"; import { WebSocket } from "ws"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { installAgentNodeWebSocketServer } from "../src/agent/AgentNodeWebSocket.ts"; -const servers: Server[] = []; +class AgentNodeTestError extends Data.TaggedError("AgentNodeTestError")<{ + readonly message: string; +}> {} -afterEach(async () => { - await Promise.all( - servers.splice(0).map( - (server) => - new Promise((resolve) => { - server.close(() => resolve()); - }), - ), +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); + +/** Decodes a server frame, keeping the loose shape the assertions below read. */ +const decodeFrame = (raw: string): Record => { + const frame: any = decodeJson(raw); + return frame; +}; + +/** + * Builds a partial service stub. Members that are not listed read as + * `undefined`, exactly like the object literals this replaces, but the value + * types as the full service so no call site needs an assertion. + */ +const serviceStub = (members: object): A => { + const stub: any = { ...members }; + return stub; +}; + +/** + * Listens on an ephemeral loopback port and reports it, closing the server when + * the surrounding scope ends. + */ +const listen = (server: Server) => + Effect.acquireRelease( + Effect.callback((resume) => { + const onError = (error: Error) => + resume(Effect.fail(new AgentNodeTestError({ message: causeMessage(error) }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (address === null || typeof address === "string") { + resume( + Effect.fail( + new AgentNodeTestError({ message: "HTTP server did not expose a TCP port" }), + ), + ); + return; + } + resume(Effect.succeed(address.port)); + }); + }), + () => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), ); -}); -const listen = (server: Server): Promise => - new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - const address = server.address(); - if (address === null || typeof address === "string") { - reject(new Error("HTTP server did not expose a TCP port")); - return; - } - servers.push(server); - resolve(address.port); - }); +const waitFor = (predicate: () => boolean) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 150; attempt += 1) { + if (predicate()) return; + yield* Effect.sleep("20 millis"); + } + return yield* Effect.fail( + new AgentNodeTestError({ message: "Timed out waiting for the Node agent WebSocket" }), + ); }); -const waitFor = async (predicate: () => boolean): Promise => { - for (let attempt = 0; attempt < 150; attempt += 1) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error("Timed out waiting for the Node agent WebSocket"); -}; +const epoch = DateTime.toDateUtc(DateTime.makeUnsafe(0)); const authSession = { cookie: null, - method: "user" as const, + method: constant("user"), name: "Probe user", person: null, organizations: [ @@ -78,8 +110,8 @@ const authSession = { ], user: { id: "user_1", - createdAt: new Date(0), - updatedAt: new Date(0), + createdAt: epoch, + updatedAt: epoch, email: "user@example.com", emailVerified: true, image: null, @@ -100,191 +132,215 @@ const identity = { }; const makeServices = () => { - let context = Context.empty() as Context.Context; - context = Context.add(context, Db, {} as never); - context = Context.add(context, LocalUserSessionService, { - resolveLocalUser: () => Effect.succeed(authSession.user), - loadUserAccess: () => - Effect.succeed({ - organizations: authSession.organizations, - projects: authSession.projects, - }), - toUserSession: () => authSession, - } as unknown as LocalUserSessionService["Service"]); - context = Context.add(context, IdentityProvider, { - cookieName: "voidhash-session", - authenticateSessionCookie: () => Effect.succeed(null), - resolveIdentity: () => Effect.succeed(identity), - resolveIdentityById: () => Effect.succeed(identity), - linkExternalId: () => Effect.void, - } as IdentityProvider["Service"]); - context = Context.add(context, AgentSessionIndexService, { - touch: () => Effect.succeed(undefined), - } as unknown as AgentSessionIndexService["Service"]); - context = Context.add(context, PaywallService, { - getPaywalls: () => Effect.succeed([]), - } as unknown as PaywallService["Service"]); - context = Context.add(context, PaywallWorkspaceService, {} as PaywallWorkspaceService["Service"]); - return context as Context.Context; + const withDb = Context.make(Db, serviceStub({})); + const withLocalUserSession = Context.add( + withDb, + LocalUserSessionService, + serviceStub({ + resolveLocalUser: () => Effect.succeed(authSession.user), + loadUserAccess: () => + Effect.succeed({ + organizations: authSession.organizations, + projects: authSession.projects, + }), + toUserSession: () => authSession, + }), + ); + const withIdentityProvider = Context.add( + withLocalUserSession, + IdentityProvider, + serviceStub({ + cookieName: "voidhash-session", + authenticateSessionCookie: () => Effect.succeed(null), + resolveIdentity: () => Effect.succeed(identity), + resolveIdentityById: () => Effect.succeed(identity), + linkExternalId: () => Effect.void, + }), + ); + const withSessionIndex = Context.add( + withIdentityProvider, + AgentSessionIndexService, + serviceStub({ touch: () => Effect.succeed(undefined) }), + ); + const withPaywalls = Context.add( + withSessionIndex, + PaywallService, + serviceStub({ getPaywalls: () => Effect.succeed([]) }), + ); + const services: Context.Context = Context.add( + withPaywalls, + PaywallWorkspaceService, + serviceStub({}), + ); + return services; }; -describe("installAgentNodeWebSocketServer", () => { - it("authenticates, streams, and accepts steering through a real Node WebSocket", async () => { - let releaseFirstProvider!: () => void; - const firstProviderGate = new Promise((resolve) => { - releaseFirstProvider = resolve; +const collectUserText = (entries: unknown): string[] => { + if (!Array.isArray(entries)) return []; + return entries + .filter((entry) => entry.type === "message" && entry.message?.role === "user") + .flatMap((entry) => { + const content = entry.message.content; + if (typeof content === "string") return [content]; + if (!Array.isArray(content)) return []; + return content.filter((part) => part.type === "text").map((part) => part.text); }); - let providerRequests = 0; - const provider = createServer((request, response) => { - if (request.url !== "/v1/responses") { - response.writeHead(404).end(); - return; - } - providerRequests += 1; - response.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - }); - response.write( - `data: ${JSON.stringify({ - type: "response.created", - response: { id: `response_${providerRequests}`, status: "in_progress", output: [] }, - })}\n\n`, - ); - const finishResponse = () => { - const events = [ - { - type: "response.output_item.added", - output_index: 0, - item: { - id: "message_1", - type: "message", - role: "assistant", - status: "in_progress", - content: [], - }, - }, - { type: "response.output_text.delta", output_index: 0, delta: "node-host-ok" }, - { - type: "response.output_item.done", - output_index: 0, - item: { - id: "message_1", - type: "message", - role: "assistant", - status: "completed", - content: [{ type: "output_text", text: "node-host-ok", annotations: [] }], +}; + +describe("installAgentNodeWebSocketServer", () => { + it("authenticates, streams, and accepts steering through a real Node WebSocket", () => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const firstProviderGate = yield* Latch.make(false); + let providerRequests = 0; + const provider = createServer((request, response) => { + if (request.url !== "/v1/responses") { + response.writeHead(404).end(); + return; + } + providerRequests += 1; + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + response.write( + `data: ${encodeJson({ + type: "response.created", + response: { id: `response_${providerRequests}`, status: "in_progress", output: [] }, + })}\n\n`, + ); + const finishResponse = () => { + const events = [ + { + type: "response.output_item.added", + output_index: 0, + item: { + id: "message_1", + type: "message", + role: "assistant", + status: "in_progress", + content: [], + }, + }, + { type: "response.output_text.delta", output_index: 0, delta: "node-host-ok" }, + { + type: "response.output_item.done", + output_index: 0, + item: { + id: "message_1", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "node-host-ok", annotations: [] }], + }, + }, + { + type: "response.completed", + response: { + id: "response_1", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]; + for (const event of events) response.write(`data: ${encodeJson(event)}\n\n`); + response.end("data: [DONE]\n\n"); + }; + if (providerRequests === 1) { + Effect.runFork( + firstProviderGate.await.pipe(Effect.flatMap(() => Effect.sync(finishResponse))), + ); + } else { + finishResponse(); + } + }); + const providerPort = yield* listen(provider); + + const server = createServer((_request, response) => response.writeHead(404).end()); + const host = installAgentNodeWebSocketServer( + server, + makeMemoryDurableEntityHost(), + makeServices(), + { + validateToken: () => + Effect.succeed({ + payload: { sub: "workos_user_1", email: "user@example.com" }, + provider: constant("workos"), + }), }, - }, - { - type: "response.completed", - response: { - id: "response_1", - status: "completed", - output: [], - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + { + provider: "openai", + modelId: "gpt-5.4", + visionProvider: "openai", + visionModelId: "gpt-5.4", + openaiApiKey: Redacted.make("probe-key"), + openaiBaseUrl: `http://127.0.0.1:${providerPort}/v1`, }, - }, - ]; - for (const event of events) response.write(`data: ${JSON.stringify(event)}\n\n`); - response.end("data: [DONE]\n\n"); - }; - if (providerRequests === 1) { - void firstProviderGate.then(finishResponse); - } else { - finishResponse(); - } - }); - const providerPort = await listen(provider); + ); + const port = yield* listen(server); + const frames: Array> = []; + const socket = new WebSocket( + `ws://127.0.0.1:${port}/api/agent/sessions/agent_1/ws?organizationId=org_1&projectId=project_1&surface=designer`, + { headers: { authorization: "Bearer probe-token" } }, + ); + // oxlint-disable-next-line typescript/no-base-to-string -- `data` is the ws RawData union (Buffer | ArrayBuffer | Buffer[]); every frame this server sends is UTF-8 JSON, and Buffer.toString() is the documented way to read it. + socket.on("message", (data) => frames.push(decodeFrame(data.toString()))); + yield* Effect.callback((resume) => { + socket.once("open", () => resume(Effect.void)); + socket.once("error", (error) => + resume(Effect.fail(new AgentNodeTestError({ message: causeMessage(error) }))), + ); + }); + socket.send(encodeJson({ v: 1, type: "prompt", requestId: "prompt_1", text: "hello" })); + yield* waitFor( + () => + providerRequests === 1 && + frames.some((frame) => frame.type === "event" && frame.event?.type === "agent_start"), + ); + socket.send(encodeJson({ v: 1, type: "get_state", requestId: "streaming_state" })); + yield* waitFor(() => + frames.some( + (frame) => + frame.type === "state" && + frame.requestId === "streaming_state" && + frame.state?.isStreaming === true, + ), + ); + socket.send( + encodeJson({ v: 1, type: "steer", requestId: "steer_1", text: "change direction" }), + ); + yield* waitFor(() => + frames.some( + (frame) => + frame.type === "ack" && frame.requestId === "steer_1" && frame.command === "steer", + ), + ); + yield* firstProviderGate.open; + yield* waitFor(() => + frames.some((frame) => frame.type === "event" && frame.event?.type === "agent_end"), + ); - const server = createServer((_request, response) => response.writeHead(404).end()); - const host = installAgentNodeWebSocketServer( - server, - makeMemoryDurableEntityHost(), - makeServices(), - { - validateToken: () => - Effect.succeed({ - payload: { sub: "workos_user_1", email: "user@example.com" }, - provider: "workos" as const, - }), - }, - { - provider: "openai", - modelId: "gpt-5.4", - visionProvider: "openai", - visionModelId: "gpt-5.4", - openaiApiKey: Redacted.make("probe-key"), - openaiBaseUrl: `http://127.0.0.1:${providerPort}/v1`, - }, - ); - const port = await listen(server); - const frames: Array> = []; - const socket = new WebSocket( - `ws://127.0.0.1:${port}/api/agent/sessions/agent_1/ws?organizationId=org_1&projectId=project_1&surface=designer`, - { headers: { authorization: "Bearer probe-token" } }, - ); - socket.on("message", (data) => frames.push(JSON.parse(data.toString()))); - await new Promise((resolve, reject) => { - socket.once("open", resolve); - socket.once("error", reject); - }); - socket.send(JSON.stringify({ v: 1, type: "prompt", requestId: "prompt_1", text: "hello" })); - await waitFor( - () => - providerRequests === 1 && - frames.some((frame) => frame.type === "event" && frame.event?.type === "agent_start"), - ); - socket.send(JSON.stringify({ v: 1, type: "get_state", requestId: "streaming_state" })); - await waitFor(() => - frames.some( - (frame) => - frame.type === "state" && - frame.requestId === "streaming_state" && - frame.state?.isStreaming === true, - ), - ); - socket.send( - JSON.stringify({ v: 1, type: "steer", requestId: "steer_1", text: "change direction" }), - ); - await waitFor(() => - frames.some( - (frame) => - frame.type === "ack" && frame.requestId === "steer_1" && frame.command === "steer", - ), - ); - releaseFirstProvider(); - await waitFor(() => - frames.some((frame) => frame.type === "event" && frame.event?.type === "agent_end"), - ); + const text = frames + .filter((frame) => frame.type === "event" && frame.event?.type === "message_end") + .flatMap((frame) => frame.event.message?.content ?? []) + .find((content) => content.type === "text")?.text; + expect(text).toBe("node-host-ok"); + expect(providerRequests).toBeGreaterThanOrEqual(2); - const text = frames - .filter((frame) => frame.type === "event" && frame.event?.type === "message_end") - .flatMap((frame) => frame.event.message?.content ?? []) - .find((content) => content.type === "text")?.text; - expect(text).toBe("node-host-ok"); - expect(providerRequests).toBeGreaterThanOrEqual(2); + socket.send(encodeJson({ v: 1, type: "get_entries", requestId: "entries_1" })); + yield* waitFor(() => + frames.some((frame) => frame.type === "entries" && frame.requestId === "entries_1"), + ); + const entries = frames.find( + (frame) => frame.type === "entries" && frame.requestId === "entries_1", + )?.entries; + expect(collectUserText(entries)).toContain("change direction"); - socket.send(JSON.stringify({ v: 1, type: "get_entries", requestId: "entries_1" })); - await waitFor(() => - frames.some((frame) => frame.type === "entries" && frame.requestId === "entries_1"), - ); - const entries = frames.find( - (frame) => frame.type === "entries" && frame.requestId === "entries_1", - )?.entries; - const userText = Array.isArray(entries) - ? entries - .filter((entry) => entry.type === "message" && entry.message?.role === "user") - .flatMap((entry) => { - const content = entry.message.content; - if (typeof content === "string") return [content]; - if (!Array.isArray(content)) return []; - return content.filter((part) => part.type === "text").map((part) => part.text); - }) - : []; - expect(userText).toContain("change direction"); - - socket.close(); - host.close(); - }); + socket.close(); + host.close(); + }), + ), + )); }); diff --git a/apps/backend/tests/Analytics.integration.test.ts b/apps/backend/tests/Analytics.integration.test.ts index 638302379..6a298d92b 100644 --- a/apps/backend/tests/Analytics.integration.test.ts +++ b/apps/backend/tests/Analytics.integration.test.ts @@ -1,4 +1,5 @@ import { EventCaptureService } from "@voidhash/core/services/analyticsIngest/EventCaptureService"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, apiKeys, @@ -8,7 +9,7 @@ import { projects, sql, } from "@voidhash/db"; -import { Effect } from "effect"; +import { Clock, DateTime, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -18,82 +19,79 @@ import { import { getSelfhostRuntimeConfig } from "../src/config.ts"; describe("self-host analytics queue", () => { - it("captures, processes, and acknowledges an event without ClickHouse", async () => { - const config = getSelfhostRuntimeConfig(); - const suffix = crypto.randomUUID(); - const projectId = `project_capture_${suffix}`; - const token = `vh_pk_capture_${suffix.replaceAll("-", "")}`; - const database = Db.layer(config.database); - const program = Effect.scoped( + it("captures, processes, and acknowledges an event without ClickHouse", () => + Effect.runPromise( Effect.gen(function* () { - const db = yield* Db; - yield* db.insert(projects).values({ - id: projectId, - name: "Capture integration", - organizationId: `organization_${suffix}`, - slug: `capture-${suffix}`, - }); - yield* db.insert(apiKeys).values({ - end: token.slice(-4), - id: `apiKey_capture_${suffix}`, - isPublic: true, - key: token, - name: "Capture integration", - prefix: "vh_pk_", - projectId, - }); + const config = getSelfhostRuntimeConfig(); + const suffix = generateId("test"); + const projectId = `project_capture_${suffix}`; + const token = `vh_pk_capture_${suffix.replaceAll("-", "")}`; + const database = Db.layer(config.database); + const program = Effect.scoped( + Effect.gen(function* () { + const db = yield* Db; + const now = yield* DateTime.nowAsDate; + yield* db.insert(projects).values({ + id: projectId, + name: "Capture integration", + organizationId: `organization_${suffix}`, + slug: `capture-${suffix}`, + }); + yield* db.insert(apiKeys).values({ + end: token.slice(-4), + id: `apiKey_capture_${suffix}`, + isPublic: true, + key: token, + name: "Capture integration", + prefix: "vh_pk_", + projectId, + }); - yield* Effect.forkScoped(runSelfhostAnalyticsConsumers(config)); - const capture = yield* EventCaptureService; - const result = yield* capture.captureEvents({ - events: [ - { - context: {}, - distinct_id: `person_${suffix}`, - event: "selfhost_integration", - properties: { plan: "pro" }, - uuid: `event_${suffix}`, - }, - ], - request: { - headers: {}, - path: "/i/v1/capture", - receivedAt: new Date(), - requestId: `request_${suffix}`, - sentAt: new Date(), - token, - }, - }); - expect(result).toEqual({ accepted: 1, rejected: 0 }); + yield* Effect.forkScoped(runSelfhostAnalyticsConsumers(config)); + const capture = yield* EventCaptureService; + const result = yield* capture.captureEvents({ + events: [ + { + context: {}, + distinct_id: `person_${suffix}`, + event: "selfhost_integration", + properties: { plan: "pro" }, + uuid: `event_${suffix}`, + }, + ], + request: { + headers: {}, + path: "/i/v1/capture", + receivedAt: now, + requestId: `request_${suffix}`, + sentAt: now, + token, + }, + }); + expect(result).toEqual({ accepted: 1, rejected: 0 }); - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const rows = yield* db.query.persons.findMany({ where: { projectId } }); - if (rows.length > 0) return rows.length; - yield* Effect.sleep("25 millis"); - } - return yield* Effect.die("analytics queue did not process the captured event"); - }).pipe( - Effect.provide(database), - Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), - ), - ); + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while ((yield* Clock.currentTimeMillis) < deadline) { + const rows = yield* db.query.persons.findMany({ where: { projectId } }); + if (rows.length > 0) return rows.length; + yield* Effect.sleep("25 millis"); + } + return yield* Effect.die("analytics queue did not process the captured event"); + }).pipe( + Effect.provide(database), + Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), + ), + ); - let count = 0; - try { - count = await Effect.runPromise(program); - } finally { - await Effect.runPromise( - Effect.gen(function* () { + const cleanup = Effect.gen(function* () { const db = yield* Db; yield* db.delete(apiKeys).where(eq(apiKeys.projectId, projectId)); yield* db.delete(personIdentities).where(eq(personIdentities.projectId, projectId)); yield* db.delete(persons).where(eq(persons.projectId, projectId)); yield* db.delete(projects).where(eq(projects.id, projectId)); - }).pipe(Effect.provide(database)), - ); - await Effect.runPromise( - Effect.gen(function* () { + }).pipe(Effect.provide(database), Effect.orDie); + + const cleanupQueue = Effect.gen(function* () { const db = yield* Db; // The cluster queue driver hands the store a JSON string, which the // store then JSON-encodes into `element`, so the body is doubly @@ -102,10 +100,14 @@ describe("self-host analytics queue", () => { DELETE FROM effect_queue WHERE (element::jsonb #>> '{}')::jsonb -> 'envelope' ->> 'projectId' = ${projectId} `); - }).pipe(Effect.provide(Db.layer(config.platformDatabase))), - ); - } + }).pipe(Effect.provide(Db.layer(config.platformDatabase)), Effect.orDie); + + const count = yield* program.pipe( + Effect.ensuring(cleanup), + Effect.ensuring(cleanupQueue), + ); - expect(count).toBe(1); - }); + expect(count).toBe(1); + }), + )); }); diff --git a/apps/backend/tests/BackendAdapters.test.ts b/apps/backend/tests/BackendAdapters.test.ts index b60c77e17..b9d8582ff 100644 --- a/apps/backend/tests/BackendAdapters.test.ts +++ b/apps/backend/tests/BackendAdapters.test.ts @@ -1,22 +1,35 @@ +/* + * This suite exercises the self-host configuration adapter (`src/config.ts`), + * which is a synchronous `process.env` reader consumed from synchronous call + * sites on the pre-runtime bootstrap path. Testing it means driving the real + * `process.env` object directly: every case rebuilds the environment, deletes + * or assigns individual variables, and then calls the synchronous getter. + */ +// oxlint-disable effect/noGlobals -- the subject under test IS the synchronous process.env config adapter; the only way to assert its behaviour is to mutate process.env from synchronous test bodies, and there is no Effect Config/ConfigProvider seam to substitute because the getters run before any Effect runtime exists. import { ProjectSchemaCache } from "@voidhash/core/services"; import { Effect, Redacted } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { MemoryProjectSchemaCacheLive } from "../src/backend/ProjectSchemaCache.ts"; import { getSelfhostMigrationDatabaseConfig, getSelfhostRuntimeConfig } from "../src/config.ts"; const originalEnvironment = { ...process.env }; -afterEach(() => { - process.env = { ...originalEnvironment }; -}); - -beforeEach(() => { - process.env.SELFHOST_MODE = "local-evaluation"; -}); +/** + * Runs a configuration test against a pristine environment. The environment is + * rebuilt before the body rather than restored by a lifecycle hook, so each case + * is isolated from whatever the previous one set. + */ +const configTest = (name: string, body: () => void): void => { + it(name, () => { + process.env = { ...originalEnvironment, SELFHOST_MODE: "local-evaluation" }; + body(); + process.env = { ...originalEnvironment }; + }); +}; describe("self-host runtime configuration", () => { - it("uses local development defaults", () => { + configTest("uses local development defaults", () => { delete process.env.NODE_ENV; delete process.env.ANTHROPIC_API_KEY; delete process.env.CLICKHOUSE_URL; @@ -58,7 +71,7 @@ describe("self-host runtime configuration", () => { expect(config.auth.rootUsername).toBe("root"); }); - it("reads BYO agent provider and model settings", () => { + configTest("reads BYO agent provider and model settings", () => { process.env.OPENAI_API_KEY = "configured-openai-key"; process.env.OPENAI_BASE_URL = "https://models.example.test/v1"; process.env.VOIDHASH_AGENT_MODEL_PROVIDER = "openai"; @@ -78,7 +91,7 @@ describe("self-host runtime configuration", () => { expect(Redacted.value(agent.openaiApiKey!)).toBe("configured-openai-key"); }); - it("reads authenticated TLS SMTP settings", () => { + configTest("reads authenticated TLS SMTP settings", () => { process.env.SMTP_HOST = "smtp.example.com"; process.env.SMTP_PORT = "465"; process.env.SMTP_SECURE = "true"; @@ -105,7 +118,7 @@ describe("self-host runtime configuration", () => { expect(Redacted.value(mailer.password!)).toBe("secret"); }); - it("accepts real root credentials in production", () => { + configTest("accepts real root credentials in production", () => { process.env.VOIDHASH_ROOT_USERNAME = "operator"; process.env.VOIDHASH_ROOT_PASSWORD = "a-real-root-password"; process.env.VOIDHASH_AUTH_SECRET = "a-real-session-signing-secret"; @@ -117,7 +130,7 @@ describe("self-host runtime configuration", () => { expect(Redacted.value(auth.rootPassword)).toBe("a-real-root-password"); }); - it("names every unconfigured standalone credential when production starts", () => { + configTest("names every unconfigured standalone credential when production starts", () => { process.env.NODE_ENV = "production"; process.env.SELFHOST_MODE = "production"; delete process.env.VOIDHASH_ROOT_USERNAME; @@ -127,14 +140,14 @@ describe("self-host runtime configuration", () => { expect(() => getSelfhostRuntimeConfig()).toThrow(/VOIDHASH_ROOT_USERNAME/); }); - it("supports an explicit plaintext connection for an internal Compose database", () => { + configTest("supports an explicit plaintext connection for an internal Compose database", () => { process.env.DATABASE_HOST = "postgres"; process.env.DATABASE_SSL = "false"; expect(getSelfhostRuntimeConfig().database).toMatchObject({ host: "postgres", ssl: false }); }); - it("falls back to the application connection for migrations", () => { + configTest("falls back to the application connection for migrations", () => { process.env.DATABASE_HOST = "postgres"; process.env.DATABASE_PORT = "6543"; process.env.DATABASE_NAME = "voidhash"; @@ -153,7 +166,7 @@ describe("self-host runtime configuration", () => { }); }); - it("overrides only the direct-TCP fields migrations need", () => { + configTest("overrides only the direct-TCP fields migrations need", () => { process.env.DATABASE_HOST = "broker.internal.local"; process.env.DATABASE_PORT = "5432"; process.env.DATABASE_NAME = "voidhash"; @@ -175,7 +188,7 @@ describe("self-host runtime configuration", () => { }); }); - it("enables ClickHouse only when its URL is configured", () => { + configTest("enables ClickHouse only when its URL is configured", () => { process.env.CLICKHOUSE_URL = "http://clickhouse:8123"; process.env.CLICKHOUSE_DATABASE = "analytics"; delete process.env.CLICKHOUSE_ADMIN_USERNAME; @@ -193,8 +206,8 @@ describe("self-host runtime configuration", () => { }); describe("memory project schema cache", () => { - it("stores, invalidates, and expires project schemas", async () => { - await Effect.runPromise( + it("stores, invalidates, and expires project schemas", () => + Effect.runPromise( Effect.gen(function* () { const cache = yield* ProjectSchemaCache; const project = cache.getByName("project-1"); @@ -208,6 +221,5 @@ describe("memory project schema cache", () => { yield* project.set({ version: 2 }, 0); expect(yield* project.get()).toBeUndefined(); }).pipe(Effect.provide(MemoryProjectSchemaCacheLive)), - ); - }); + )); }); diff --git a/apps/backend/tests/Background.integration.test.ts b/apps/backend/tests/Background.integration.test.ts index 249500c6d..58d78c5e6 100644 --- a/apps/backend/tests/Background.integration.test.ts +++ b/apps/backend/tests/Background.integration.test.ts @@ -2,17 +2,19 @@ import { type CronJob, CronScheduler } from "@voidhash/platform/CronScheduler"; import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { WorkflowRunner } from "@voidhash/platform/WorkflowRunner"; import * as TestWorkflowRunner from "@voidhash/platform/TestWorkflowRunner"; -import { Effect, Layer } from "effect"; +import { generateId } from "@voidhash/core/utils/generate-id"; +import { constant } from "@voidhash/lib/lang"; +import { Clock, DateTime, Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { makeSelfhostAnalyticsRuntimeLive } from "../src/backend/Analytics.ts"; import { makeSelfhostCronJobs } from "../src/backend/Background.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const requiredJobNames = [ +const requiredJobNames = constant([ "AppStoreExpireParkedNotificationsWorkflow", "PurchaseLedgerDrainWorkflow", -] as const; +]); const twoDaysMillis = 2 * 24 * 60 * 60 * 1000; @@ -37,7 +39,7 @@ const runThroughScheduler = (job: CronJob) => let executions = 0; const probe: CronJob = { ...job, - name: `${job.name}-probe-${crypto.randomUUID()}`, + name: `${job.name}-probe-${generateId("test")}`, run: (context) => job.run(context).pipe( Effect.tap(() => @@ -47,39 +49,40 @@ const runThroughScheduler = (job: CronJob) => ), ), }; - const now = Date.now(); - yield* scheduler.tick(probe, new Date(now)); - yield* scheduler.tick(probe, new Date(now + twoDaysMillis)); + const now = yield* Clock.currentTimeMillis; + yield* scheduler.tick(probe, DateTime.toDateUtc(DateTime.makeUnsafe(now))); + yield* scheduler.tick(probe, DateTime.toDateUtc(DateTime.makeUnsafe(now + twoDaysMillis))); return executions; }); describe("self-host scheduled jobs", () => { - it("registers the required background jobs and executes them through the scheduler", async () => { - const testRunner = TestWorkflowRunner.make(); + it("registers the required background jobs and executes them through the scheduler", () => + Effect.runPromise( + Effect.gen(function* () { + const testRunner = TestWorkflowRunner.make(); - const outcome = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const jobs: ReadonlyArray> = - yield* makeSelfhostCronJobs(); - const registered = jobs.map((job) => job.name); - const executions: Record = {}; - for (const name of requiredJobNames) { - const job = jobs.find((candidate) => candidate.name === name); - if (job === undefined) continue; - executions[name] = yield* runThroughScheduler(job); - } - return { executions, registered }; - }).pipe( - Effect.provide(Layer.succeed(WorkflowRunner, testRunner)), - Effect.provide(makeSelfhostAnalyticsRuntimeLive(getSelfhostRuntimeConfig())), - ), - ), - ); + const outcome = yield* Effect.scoped( + Effect.gen(function* () { + const jobs: ReadonlyArray> = + yield* makeSelfhostCronJobs(); + const registered = jobs.map((job) => job.name); + const executions: Record = {}; + for (const name of requiredJobNames) { + const job = jobs.find((candidate) => candidate.name === name); + if (job === undefined) continue; + executions[name] = yield* runThroughScheduler(job); + } + return { executions, registered }; + }).pipe( + Effect.provide(Layer.succeed(WorkflowRunner, testRunner)), + Effect.provide(makeSelfhostAnalyticsRuntimeLive(getSelfhostRuntimeConfig())), + ), + ); - expect(outcome.registered).toEqual(expect.arrayContaining([...requiredJobNames])); - for (const name of requiredJobNames) { - expect(outcome.executions[name]).toBeGreaterThanOrEqual(1); - } - }); + expect(outcome.registered).toEqual(expect.arrayContaining([...requiredJobNames])); + for (const name of requiredJobNames) { + expect(outcome.executions[name]).toBeGreaterThanOrEqual(1); + } + }), + )); }); diff --git a/apps/backend/tests/Clickhouse.integration.test.ts b/apps/backend/tests/Clickhouse.integration.test.ts index 7861e217c..4d48bbfe9 100644 --- a/apps/backend/tests/Clickhouse.integration.test.ts +++ b/apps/backend/tests/Clickhouse.integration.test.ts @@ -7,6 +7,7 @@ import { } from "@voidhash/clickhouse-db/analytics/schema"; import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; import { EventCaptureService } from "@voidhash/core/services/analyticsIngest/EventCaptureService"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { apiKeys, Db, @@ -16,7 +17,8 @@ import { projects, sql as pgSql, } from "@voidhash/db"; -import { Context, Effect, Layer } from "effect"; +import { constant } from "@voidhash/lib/lang"; +import { Clock, Context, Data, DateTime, Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -29,155 +31,166 @@ import { } from "../src/backend/Clickhouse.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const analyticsTables = [ +const analyticsTables = constant([ CLICKHOUSE_EVENTS_TABLE, CLICKHOUSE_PERSONS_TABLE, CLICKHOUSE_PERSON_IDENTITY_TABLE, CLICKHOUSE_PERSON_IDENTITY_OVERRIDES_TABLE, CLICKHOUSE_PERSON_IDENTITY_PENDING_OVERRIDES_V2_TABLE, -] as const; +]); + +class MissingClickhouseConfigError extends Data.TaggedError("MissingClickhouseConfigError")<{ + readonly message: string; +}> {} const countEvents = ( layer: Layer.Layer, projectId: string, organizationId?: string, ) => - Effect.runPromise( - Effect.gen(function* () { - const ch = yield* ClickhouseWebClient.ClickhouseWebClient; - const query = ch<{ readonly total: string }>` + Effect.gen(function* () { + const ch = yield* ClickhouseWebClient.ClickhouseWebClient; + const query = ch<{ readonly total: string }>` SELECT count() AS total FROM ${ch.literal(CLICKHOUSE_EVENTS_TABLE)} WHERE project_id = ${ch.param("String", projectId)} `; - const rows = yield* organizationId - ? ch.withClickhouseSettings(query, { SQL_organization_id: organizationId }) - : query; + if (!organizationId) { + const rows = yield* query; return Number(rows[0]?.total ?? 0); - }).pipe(Effect.provide(layer), Effect.scoped), - ); + } + const rows = yield* ch.withClickhouseSettings(query, { + SQL_organization_id: organizationId, + }); + return Number(rows[0]?.total ?? 0); + }).pipe(Effect.provide(layer), Effect.scoped); describe("self-host ClickHouse analytics", () => { - it("writes captured events and enforces the runtime access split", async () => { - const config = getSelfhostRuntimeConfig(); - if (!config.clickhouse) throw new Error("CLICKHOUSE_URL is required for this test"); - await Effect.runPromise(migrateSelfhostClickhouse(config.clickhouse)); - const clickhouse = makeSelfhostClickhouseLayers(config.clickhouse); - const database = Db.layer(config.database); - const suffix = crypto.randomUUID(); - const projectId = `project_clickhouse_${suffix}`; - const organizationId = `organization_clickhouse_${suffix}`; - const token = `vh_pk_clickhouse_${suffix.replaceAll("-", "")}`; + it("writes captured events and enforces the runtime access split", () => + Effect.runPromise( + Effect.gen(function* () { + const config = getSelfhostRuntimeConfig(); + const clickhouseConfig = config.clickhouse; + if (!clickhouseConfig) { + return yield* new MissingClickhouseConfigError({ + message: "CLICKHOUSE_URL is required for this test", + }); + } + yield* migrateSelfhostClickhouse(clickhouseConfig); + const clickhouse = makeSelfhostClickhouseLayers(clickhouseConfig); + const database = Db.layer(config.database); + const suffix = generateId("test"); + const projectId = `project_clickhouse_${suffix}`; + const organizationId = `organization_clickhouse_${suffix}`; + const token = `vh_pk_clickhouse_${suffix.replaceAll("-", "")}`; - try { - const written = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { + const teardown = Effect.gen(function* () { + yield* Effect.gen(function* () { + const ch = yield* ClickhouseWebClient.ClickhouseWebClient; + yield* Effect.forEach( + analyticsTables, + (table) => + ch.asCommand(ch` + ALTER TABLE ${ch(table)} DELETE + WHERE project_id = ${projectId} + `), + { discard: true }, + ); + }).pipe(Effect.provide(clickhouse.readWrite), Effect.scoped); + yield* Effect.gen(function* () { const db = yield* Db; - yield* db.insert(projects).values({ - id: projectId, - name: "ClickHouse integration", - organizationId, - slug: `clickhouse-${suffix}`, - }); - yield* db.insert(apiKeys).values({ - end: token.slice(-4), - id: `apiKey_clickhouse_${suffix}`, - isPublic: true, - key: token, - name: "ClickHouse integration", - prefix: "vh_pk_", - projectId, - }); + yield* db.delete(apiKeys).where(eq(apiKeys.projectId, projectId)); + yield* db.delete(personIdentities).where(eq(personIdentities.projectId, projectId)); + yield* db.delete(persons).where(eq(persons.projectId, projectId)); + yield* db.delete(projects).where(eq(projects.id, projectId)); + }).pipe(Effect.provide(database)); + yield* Effect.gen(function* () { + const db = yield* Db; + // The cluster queue driver hands the store a JSON string, which the + // store then JSON-encodes into `element`, so the body is doubly + // encoded: unwrap the outer JSON scalar before reading its fields. + yield* db.execute(pgSql` + DELETE FROM effect_queue + WHERE (element::jsonb #>> '{}')::jsonb -> 'envelope' ->> 'projectId' = ${projectId} + `); + }).pipe(Effect.provide(Db.layer(config.platformDatabase))); + }).pipe(Effect.orDie); - yield* Effect.forkScoped( - runSelfhostAnalyticsConsumers(config, clickhouse.readWrite), - ); - const capture = yield* EventCaptureService; - yield* capture.captureEvents({ - events: [ - { - context: {}, - distinct_id: `person_${suffix}`, - event: "selfhost_clickhouse_integration", - properties: { plan: "pro" }, - uuid: `event_${suffix}`, + return yield* Effect.gen(function* () { + const written = yield* Effect.scoped( + Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(projects).values({ + id: projectId, + name: "ClickHouse integration", + organizationId, + slug: `clickhouse-${suffix}`, + }); + yield* db.insert(apiKeys).values({ + end: token.slice(-4), + id: `apiKey_clickhouse_${suffix}`, + isPublic: true, + key: token, + name: "ClickHouse integration", + prefix: "vh_pk_", + projectId, + }); + + yield* Effect.forkScoped( + runSelfhostAnalyticsConsumers(config, clickhouse.readWrite), + ); + const capture = yield* EventCaptureService; + const now = yield* DateTime.nowAsDate; + yield* capture.captureEvents({ + events: [ + { + context: {}, + distinct_id: `person_${suffix}`, + event: "selfhost_clickhouse_integration", + properties: { plan: "pro" }, + uuid: `event_${suffix}`, + }, + ], + request: { + headers: {}, + path: "/i/v1/capture", + receivedAt: now, + requestId: `request_${suffix}`, + sentAt: now, + token, }, - ], - request: { - headers: {}, - path: "/i/v1/capture", - receivedAt: new Date(), - requestId: `request_${suffix}`, - sentAt: new Date(), - token, - }, - }); + }); - const readWriteContext = yield* Layer.build(clickhouse.readWrite); - const ch = Context.get( - readWriteContext, - ClickhouseWebClient.ClickhouseWebClient, - ); - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const rows = yield* ch<{ readonly total: string }>` + const readWriteContext = yield* Layer.build(clickhouse.readWrite); + const ch = Context.get( + readWriteContext, + ClickhouseWebClient.ClickhouseWebClient, + ); + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while ((yield* Clock.currentTimeMillis) < deadline) { + const rows = yield* ch<{ readonly total: string }>` SELECT count() AS total FROM ${ch.literal(CLICKHOUSE_EVENTS_TABLE)} WHERE project_id = ${ch.param("String", projectId)} `; - if (Number(rows[0]?.total ?? 0) > 0) return Number(rows[0]?.total); - yield* Effect.sleep("25 millis"); - } - return yield* Effect.die("analytics event did not land in ClickHouse"); - }).pipe( - Effect.provide(database), - Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), - Effect.provide(clickhouse.readOnly), - ), - ), - ); + if (Number(rows[0]?.total ?? 0) > 0) return Number(rows[0]?.total); + yield* Effect.sleep("25 millis"); + } + return yield* Effect.die("analytics event did not land in ClickHouse"); + }).pipe( + Effect.provide(database), + Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), + Effect.provide(clickhouse.readOnly), + ), + ); - expect(written).toBe(1); - expect(await countEvents(clickhouse.readOnly, projectId, organizationId)).toBe(1); - expect(await countEvents(clickhouse.readOnly, projectId, "another-organization")).toBe(0); - expect(await countEvents(clickhouse.analyticsQuery, projectId)).toBe(1); - } finally { - await Effect.runPromise( - Effect.gen(function* () { - const ch = yield* ClickhouseWebClient.ClickhouseWebClient; - yield* Effect.forEach( - analyticsTables, - (table) => - ch.asCommand(ch` - ALTER TABLE ${ch(table)} DELETE - WHERE project_id = ${projectId} - `), - { discard: true }, + expect(written).toBe(1); + expect(yield* countEvents(clickhouse.readOnly, projectId, organizationId)).toBe(1); + expect(yield* countEvents(clickhouse.readOnly, projectId, "another-organization")).toBe( + 0, ); - }).pipe(Effect.provide(clickhouse.readWrite), Effect.scoped), - ); - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - yield* db.delete(apiKeys).where(eq(apiKeys.projectId, projectId)); - yield* db.delete(personIdentities).where(eq(personIdentities.projectId, projectId)); - yield* db.delete(persons).where(eq(persons.projectId, projectId)); - yield* db.delete(projects).where(eq(projects.id, projectId)); - }).pipe(Effect.provide(database)), - ); - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - // The cluster queue driver hands the store a JSON string, which the - // store then JSON-encodes into `element`, so the body is doubly - // encoded: unwrap the outer JSON scalar before reading its fields. - yield* db.execute(pgSql` - DELETE FROM effect_queue - WHERE (element::jsonb #>> '{}')::jsonb -> 'envelope' ->> 'projectId' = ${projectId} - `); - }).pipe(Effect.provide(Db.layer(config.platformDatabase))), - ); - } - }, 30_000); + expect(yield* countEvents(clickhouse.analyticsQuery, projectId)).toBe(1); + }).pipe(Effect.ensuring(teardown)); + }), + ), 30_000); }); diff --git a/apps/backend/tests/Compiler.test.ts b/apps/backend/tests/Compiler.test.ts index 2765d83d3..6d8fe0a53 100644 --- a/apps/backend/tests/Compiler.test.ts +++ b/apps/backend/tests/Compiler.test.ts @@ -17,49 +17,56 @@ const validComponent = ` `; describe("self-host component compiler", () => { - it("compiles and extracts a component manifest", async () => { - const result = await Effect.runPromise(compiler.compileAndExtract(validComponent)); + it("compiles and extracts a component manifest", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* compiler.compileAndExtract(validComponent); - expect(result.status).toBe("ready"); - if (result.status === "ready") { - expect(result.manifest).toMatchObject({ manifestVersion: 2, title: "Hero" }); - expect(result.previewTrees.default).toMatchObject({ - treeVersion: 2, - state: "default", - root: { type: "text", text: "Go Pro", style: {} }, - }); - } - }); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.manifest).toMatchObject({ manifestVersion: 2, title: "Hero" }); + expect(result.previewTrees.default).toMatchObject({ + treeVersion: 2, + state: "default", + root: { type: "text", text: "Go Pro", style: {} }, + }); + } + }), + )); - it("classifies source and runtime failures without escaping", async () => { - const compile = await Effect.runPromise( - compiler.compileAndExtract("export default function Hero() { return ; }"), - ); - const runtime = await Effect.runPromise( - compiler.compileAndExtract('throw new Error("boom"); export default {};'), - ); + it("classifies source and runtime failures without escaping", () => + Effect.runPromise( + Effect.gen(function* () { + const compile = yield* compiler.compileAndExtract( + "export default function Hero() { return ; }", + ); + const runtime = yield* compiler.compileAndExtract( + 'throw new Error("boom"); export default {};', + ); - expect(compile).toMatchObject({ phase: "compile", status: "error" }); - expect(runtime).toMatchObject({ phase: "runtime", status: "error" }); - }); + expect(compile).toMatchObject({ phase: "compile", status: "error" }); + expect(runtime).toMatchObject({ phase: "runtime", status: "error" }); + }), + )); - it("bounds evaluation and disables dynamic code generation", async () => { - const loop = await Effect.runPromise( - compiler.compileAndExtract("while (true) {} export default {};"), - ); - const dynamicCode = await Effect.runPromise( - compiler.compileAndExtract('Function("return 1")(); export default {};'), - ); + it("bounds evaluation and disables dynamic code generation", () => + Effect.runPromise( + Effect.gen(function* () { + const loop = yield* compiler.compileAndExtract("while (true) {} export default {};"); + const dynamicCode = yield* compiler.compileAndExtract( + 'Function("return 1")(); export default {};', + ); - expect(loop).toMatchObject({ phase: "runtime", status: "error" }); - expect(dynamicCode).toMatchObject({ phase: "runtime", status: "error" }); - if (loop.status === "error") { - expect(loop.diagnostics[0]?.message).toContain("timed out"); - } - if (dynamicCode.status === "error") { - expect(dynamicCode.diagnostics[0]?.message).toContain( - "Code generation from strings disallowed", - ); - } - }); + expect(loop).toMatchObject({ phase: "runtime", status: "error" }); + expect(dynamicCode).toMatchObject({ phase: "runtime", status: "error" }); + if (loop.status === "error") { + expect(loop.diagnostics[0]?.message).toContain("timed out"); + } + if (dynamicCode.status === "error") { + expect(dynamicCode.diagnostics[0]?.message).toContain( + "Code generation from strings disallowed", + ); + } + }), + )); }); diff --git a/apps/backend/tests/CompilerClient.integration.test.ts b/apps/backend/tests/CompilerClient.integration.test.ts index 47470e025..22986e7da 100644 --- a/apps/backend/tests/CompilerClient.integration.test.ts +++ b/apps/backend/tests/CompilerClient.integration.test.ts @@ -1,19 +1,24 @@ import { ComponentCompiler } from "@voidhash/core/services/paywallWorkspace/ComponentCompiler"; -import { Effect } from "effect"; +import { Config, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { makeHttpComponentCompilerLive } from "../src/compiler/CompilerClient.ts"; // The compiler is part of the provisioned stack, so a missing URL is a broken // environment rather than a reason to skip. -const compilerUrl = process.env.SELFHOST_COMPILER_URL ?? "http://127.0.0.1:5002"; +const compilerUrl = Config.string("SELFHOST_COMPILER_URL").pipe( + Config.withDefault("http://127.0.0.1:5002"), + Effect.orDie, +); describe("self-host component compiler client", () => { - it("round-trips compile and extraction results through HTTP", async () => { - const result = await Effect.runPromise( + it("round-trips compile and extraction results through HTTP", () => + Effect.runPromise( Effect.gen(function* () { - const compiler = yield* ComponentCompiler; - return yield* compiler.compileAndExtract(` + const url = yield* compilerUrl; + const result = yield* Effect.gen(function* () { + const compiler = yield* ComponentCompiler; + return yield* compiler.compileAndExtract(` import { defineComponent } from "@voidhash/paywalls"; export default defineComponent({ title: "Client Card", @@ -22,13 +27,13 @@ describe("self-host component compiler client", () => { previews: { default: {} }, render: () => null, }); - `); - }).pipe(Effect.provide(makeHttpComponentCompilerLive(compilerUrl ?? ""))), - ); + `); + }).pipe(Effect.provide(makeHttpComponentCompilerLive(url))); - expect(result.status).toBe("ready"); - if (result.status === "ready") { - expect(result.manifest).toMatchObject({ manifestVersion: 2, title: "Client Card" }); - } - }); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.manifest).toMatchObject({ manifestVersion: 2, title: "Client Card" }); + } + }), + )); }); diff --git a/apps/backend/tests/MimicDocumentIdle.test.ts b/apps/backend/tests/MimicDocumentIdle.test.ts index ee03d0983..f18464e26 100644 --- a/apps/backend/tests/MimicDocumentIdle.test.ts +++ b/apps/backend/tests/MimicDocumentIdle.test.ts @@ -23,121 +23,118 @@ const control: DurableEntityAlarmControlShape = { }; describe("Mimic Node idle alarm dispatch", () => { - it("samples the current time whenever a reused dispatch effect runs", async () => { - const observed: number[] = []; - const clock = vi.spyOn(Date, "now").mockReturnValue(100); - const dispatch = dispatchMimicDocumentIdleAlarms( - makeMemoryDurableEntityHost(), - { - control: { - listDueAlarms: (now) => + it("samples the current time whenever a reused dispatch effect runs", () => + Effect.runPromise( + Effect.gen(function* () { + const observed: number[] = []; + const clock = vi.spyOn(Date, "now").mockReturnValue(100); + const dispatch = dispatchMimicDocumentIdleAlarms( + makeMemoryDurableEntityHost(), + { + control: { + listDueAlarms: (now) => + Effect.sync(() => { + observed.push(now); + return []; + }), + }, + debounceMs: 1, + publish: () => Effect.void, + }, + () => 0, + ); + + yield* Effect.gen(function* () { + yield* dispatch; + clock.mockReturnValue(200); + yield* dispatch; + }).pipe( + Effect.ensuring( Effect.sync(() => { - observed.push(now); - return []; + clock.mockRestore(); }), - }, - debounceMs: 1, - publish: () => Effect.void, - }, - () => 0, - ); - - try { - await Effect.runPromise(dispatch); - clock.mockReturnValue(200); - await Effect.runPromise(dispatch); - } finally { - clock.mockRestore(); - } + ), + ); - expect(observed).toEqual([100, 200]); - }); + expect(observed).toEqual([100, 200]); + }), + )); - it("publishes and records a persisted dirty revision", async () => { - const entities = makeMemoryDurableEntityHost(); - const published: MimicDocumentIdleMessageType[] = []; - await Effect.runPromise( - entities.run(address, (entity) => - Effect.gen(function* () { - yield* entity.keyValue.put(IDLE_DIRTY_SEQ_KEY, 7); - yield* entity.keyValue.put(IDLE_NOTIFIED_SEQ_KEY, 4); - yield* entity.alarm.set(0); - }), - ), - ); + it("publishes and records a persisted dirty revision", () => + Effect.runPromise( + Effect.gen(function* () { + const entities = makeMemoryDurableEntityHost(); + const published: MimicDocumentIdleMessageType[] = []; + yield* entities.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.put(IDLE_DIRTY_SEQ_KEY, 7); + yield* entity.keyValue.put(IDLE_NOTIFIED_SEQ_KEY, 4); + yield* entity.alarm.set(0); + }), + ); - await Effect.runPromise( - dispatchMimicDocumentIdleAlarms( - entities, - { - control, - debounceMs: 1, - publish: (message) => - Effect.sync(() => { - published.push(message); - }), - }, - () => 0, - ), - ); + yield* dispatchMimicDocumentIdleAlarms( + entities, + { + control, + debounceMs: 1, + publish: (message) => + Effect.sync(() => { + published.push(message); + }), + }, + () => 0, + ); - expect(published).toEqual([ - { collectionId: "collection-1", documentId: "document-1", seq: 7 }, - ]); - expect( - await Effect.runPromise( - entities.run(address, (entity) => - entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), - ), - ), - ).toBe(7); - expect( - await Effect.runPromise( - entities.run(address, (entity) => entity.alarm.get), - ), - ).toBeUndefined(); - }); + expect(published).toEqual([ + { collectionId: "collection-1", documentId: "document-1", seq: 7 }, + ]); + expect( + yield* entities.run(address, (entity) => + entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), + ), + ).toBe(7); + expect( + yield* entities.run(address, (entity) => entity.alarm.get), + ).toBeUndefined(); + }), + )); - it("consumes the alarm without publishing when a collaborator reconnected", async () => { - const entities = makeMemoryDurableEntityHost(); - const published: MimicDocumentIdleMessageType[] = []; - await Effect.runPromise( - entities.run(address, (entity) => - Effect.gen(function* () { - yield* entity.keyValue.put(IDLE_DIRTY_SEQ_KEY, 8); - yield* entity.keyValue.put(IDLE_NOTIFIED_SEQ_KEY, 7); - yield* entity.alarm.set(0); - }), - ), - ); + it("consumes the alarm without publishing when a collaborator reconnected", () => + Effect.runPromise( + Effect.gen(function* () { + const entities = makeMemoryDurableEntityHost(); + const published: MimicDocumentIdleMessageType[] = []; + yield* entities.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.put(IDLE_DIRTY_SEQ_KEY, 8); + yield* entity.keyValue.put(IDLE_NOTIFIED_SEQ_KEY, 7); + yield* entity.alarm.set(0); + }), + ); - await Effect.runPromise( - dispatchMimicDocumentIdleAlarms( - entities, - { - control, - debounceMs: 1, - publish: (message) => - Effect.sync(() => { - published.push(message); - }), - }, - () => 1, - ), - ); + yield* dispatchMimicDocumentIdleAlarms( + entities, + { + control, + debounceMs: 1, + publish: (message) => + Effect.sync(() => { + published.push(message); + }), + }, + () => 1, + ); - expect(published).toEqual([]); - expect( - await Effect.runPromise( - entities.run(address, (entity) => - entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), - ), - ), - ).toBe(7); - expect( - await Effect.runPromise( - entities.run(address, (entity) => entity.alarm.get), - ), - ).toBeUndefined(); - }); + expect(published).toEqual([]); + expect( + yield* entities.run(address, (entity) => + entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), + ), + ).toBe(7); + 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 e47667437..1da954aae 100644 --- a/apps/backend/tests/MimicNode.integration.test.ts +++ b/apps/backend/tests/MimicNode.integration.test.ts @@ -1,6 +1,8 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- integration test boots the real Node HTTP server the mimic WebSocket adapter attaches to. import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; +import { generateId } from "@voidhash/core/utils/generate-id"; +import { causeMessage } from "@voidhash/lib/lang"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import type { SchemaObject, Value } from "@voidhash/mimic-core"; import { @@ -10,42 +12,41 @@ import { } from "@voidhash/platform/DurableEntity"; import { PgClusterDurableEntityLive } from "@voidhash/platform-selfhost/ClusterDurableEntity"; import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; -import { Effect, ManagedRuntime, Redacted } from "effect"; +import { Config, Data, Effect, Layer, ManagedRuntime, Redacted, Schema } from "effect"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; import { makeMimicNodeHostLive, type MimicNodeConfig } from "../src/mimic/MimicNode.ts"; import { installMimicNodeWebSocketServer } from "../src/mimic/MimicNodeWebSocket.ts"; -const config: MimicNodeConfig = { - database: { - host: process.env.SELFHOST_PG_HOST ?? "127.0.0.1", - port: Number(process.env.SELFHOST_PG_PORT ?? "5432"), - database: process.env.SELFHOST_PG_DATABASE ?? "voidhash", - username: process.env.SELFHOST_PG_USERNAME ?? "voidhash", - password: Redacted.make(process.env.SELFHOST_PG_PASSWORD ?? "password"), - }, - documents: { - host: process.env.SELFHOST_PG_HOST ?? "127.0.0.1", - port: Number(process.env.SELFHOST_PG_PORT ?? "5432"), - database: process.env.SELFHOST_PG_DATABASE ?? "voidhash", - username: process.env.SELFHOST_PG_USERNAME ?? "voidhash", - password: Redacted.make(process.env.SELFHOST_PG_PASSWORD ?? "password"), - }, -}; +class MimicNodeTestError extends Data.TaggedError("MimicNodeTestError")<{ + readonly message: string; +}> {} -// The entity host runs a single-node cluster, which claims every shard in the -// database it is built over. Pointing it at the platform test database keeps it -// from stealing messages addressed to the deployment this suite runs against; -// control and document state stay in the application database above. -const platformConfig: PgPlatformConfig = { - host: process.env.PLATFORM_SELFHOST_PG_HOST ?? "127.0.0.1", - port: Number(process.env.PLATFORM_SELFHOST_PG_PORT ?? "5432"), - database: process.env.PLATFORM_SELFHOST_PG_DATABASE ?? "voidhash", - username: process.env.PLATFORM_SELFHOST_PG_USERNAME ?? "voidhash", - password: Redacted.make(process.env.PLATFORM_SELFHOST_PG_PASSWORD ?? "password"), +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); + +const messageType = (message: unknown): string | undefined => { + if (typeof message !== "object" || message === null) return undefined; + if (!("type" in message)) return undefined; + if (typeof message.type !== "string") return undefined; + return message.type; }; +const readPgConfig = (prefix: string) => + Effect.gen(function* () { + const config: PgPlatformConfig = { + host: yield* Config.string(`${prefix}_PG_HOST`).pipe(Config.withDefault("127.0.0.1")), + port: yield* Config.int(`${prefix}_PG_PORT`).pipe(Config.withDefault(5432)), + database: yield* Config.string(`${prefix}_PG_DATABASE`).pipe(Config.withDefault("voidhash")), + username: yield* Config.string(`${prefix}_PG_USERNAME`).pipe(Config.withDefault("voidhash")), + password: yield* Config.redacted(`${prefix}_PG_PASSWORD`).pipe( + Config.withDefault(Redacted.make("password")), + ), + }; + return config; + }); + const schema: SchemaObject = { kind: "object", fields: { @@ -59,149 +60,180 @@ const value: Value = { // Every build owns its own single-node cluster, which is what makes the // restart assertions meaningful: nothing process-local carries over. +// +// The entity host runs a single-node cluster, which claims every shard in the +// database it is built over. Pointing it at the platform test database keeps it +// from stealing messages addressed to the deployment this suite runs against; +// control and document state stay in the application database. const hostLayer = () => - makeMimicNodeHostLive(config, PgClusterDurableEntityLive(platformConfig)); - -const runHost = (program: Effect.Effect): Promise => - Effect.runPromise( - Effect.scoped(program.pipe(Effect.provide(hostLayer()))) as Effect.Effect, + Layer.unwrap( + Effect.gen(function* () { + const database = yield* readPgConfig("SELFHOST"); + const platformConfig = yield* readPgConfig("PLATFORM_SELFHOST"); + const config: MimicNodeConfig = { database, documents: database }; + return makeMimicNodeHostLive(config, PgClusterDurableEntityLive(platformConfig)); + }), ); -const runStandalone = (program: Effect.Effect): Promise => - Effect.runPromise(program as Effect.Effect); +type MimicNodeHostServices = HostServiceTag | DurableEntityHost | DurableEntityAlarmControl; + +const runHost = (program: Effect.Effect) => + Effect.scoped(program.pipe(Effect.provide(hostLayer()))); describe("self-host mimic Node composition", () => { - it("restores control and document state after the host layer restarts", async () => { - const suffix = crypto.randomUUID(); - const created = await runHost( + it("restores control and document state after the host layer restarts", () => + Effect.runPromise( Effect.gen(function* () { - const host = yield* HostServiceTag; - const database = yield* host.createDatabase(`restart-${suffix}`, "integration"); - const collection = yield* host.createCollection(database.id, "documents", schema); - const document = yield* host.createDocument(collection.id, undefined, value); - return { database, collection, document }; - }), - ); + const suffix = generateId("test"); + const created = yield* runHost( + Effect.gen(function* () { + const host = yield* HostServiceTag; + const database = yield* host.createDatabase(`restart-${suffix}`, "integration"); + const collection = yield* host.createCollection(database.id, "documents", schema); + const document = yield* host.createDocument(collection.id, undefined, value); + return { database, collection, document }; + }), + ); - const restored = await runHost( - Effect.gen(function* () { - const host = yield* HostServiceTag; - return yield* host.getDocument(created.collection.id, created.document.id); - }), - ); + const restored = yield* runHost( + Effect.gen(function* () { + const host = yield* HostServiceTag; + return yield* host.getDocument(created.collection.id, created.document.id); + }), + ); - expect(restored).toEqual(created.document); + expect(restored).toEqual(created.document); - await runHost( - Effect.gen(function* () { - const host = yield* HostServiceTag; - yield* host.deleteDocument(created.collection.id, created.document.id); - yield* host.deleteCollection(created.collection.id); - yield* host.deleteDatabase(created.database.id); + yield* runHost( + Effect.gen(function* () { + const host = yield* HostServiceTag; + yield* host.deleteDocument(created.collection.id, created.document.id); + yield* host.deleteCollection(created.collection.id); + yield* host.deleteDatabase(created.database.id); + }), + ); }), - ); - }); + )); - it("serves the document auth and snapshot protocol over a real Node WebSocket", async () => { - const runtime = ManagedRuntime.make(hostLayer()); - const host = await runtime.runPromise(HostServiceTag); - const entities = await runtime.runPromise(DurableEntityHost); - const entityControl = await runtime.runPromise(DurableEntityAlarmControl); - const server = createServer(); - const closeWebSockets = installMimicNodeWebSocketServer(server, host, entities, { - control: entityControl, - debounceMs: 15_000, - pollIntervalMs: 60_000, - publish: () => Effect.void, - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - - const suffix = crypto.randomUUID(); - const documentId = `ws-${suffix.slice(0, 20)}`; - let databaseId: string | undefined; - let collectionId: string | undefined; - try { - const created = await runStandalone( - Effect.gen(function* () { - const database = yield* host.createDatabase(`ws-${suffix}`, "integration"); - const collection = yield* host.createCollection(database.id, "documents", schema); - const document = yield* host.createDocument(collection.id, documentId, value); - const auth = yield* host.createDocumentAuthToken( - collection.id, - document.id, - "write", - [], - 60, - ); - return { database, collection, document, auth }; - }), - ); - databaseId = created.database.id; - collectionId = created.collection.id; - const address = server.address() as AddressInfo; - const socket = new WebSocket( - `ws://127.0.0.1:${address.port}/ws/v1/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, - ); - const messages = await new Promise((resolve, reject) => { - const received: unknown[] = []; - const timeout = setTimeout(() => reject(new Error("timed out waiting for snapshot")), 5_000); - socket.once("error", reject); - socket.once("open", () => - socket.send(JSON.stringify({ type: "auth", token: created.auth.token })), + it("serves the document auth and snapshot protocol over a real Node WebSocket", () => + Effect.runPromise( + Effect.gen(function* () { + const runtime = ManagedRuntime.make(hostLayer()); + const host = yield* Effect.promise(() => runtime.runPromise(HostServiceTag)); + const entities = yield* Effect.promise(() => runtime.runPromise(DurableEntityHost)); + const entityControl = yield* Effect.promise(() => + runtime.runPromise(DurableEntityAlarmControl), ); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as { readonly type?: string }; - received.push(message); - if (message.type === "snapshot") { - clearTimeout(timeout); - resolve(received); - } + const server = createServer(); + const closeWebSockets = installMimicNodeWebSocketServer(server, host, entities, { + control: entityControl, + debounceMs: 15_000, + pollIntervalMs: 60_000, + publish: () => Effect.void, }); - }); - expect(messages).toContainEqual( - expect.objectContaining({ type: "auth_result", success: true, permission: "write" }), - ); - expect(messages).toContainEqual( - expect.objectContaining({ type: "snapshot", value, version: 1 }), - ); - const entityAddress = makeDurableEntityAddress( - "mimic-document", - `${collectionId}:${documentId}`, - ); - const attached = await Effect.runPromise( - entities.run(entityAddress, (entity) => entity.sessions.list), - ); - expect(attached).toHaveLength(1); - - const closed = new Promise((resolve) => socket.once("close", () => resolve())); - socket.close(1000, "done"); - await closed; - let remaining = attached; - for (let attempt = 0; attempt < 20 && remaining.length > 0; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 10)); - remaining = await Effect.runPromise( - entities.run(entityAddress, (entity) => entity.sessions.list), - ); - } - expect(remaining).toHaveLength(0); - } finally { - if (databaseId && collectionId) { - const cleanupDatabaseId = databaseId; - const cleanupCollectionId = collectionId; - await runStandalone( - Effect.gen(function* () { + yield* Effect.callback((resume) => { + server.once("error", (error) => + resume(Effect.fail(new MimicNodeTestError({ message: causeMessage(error) }))), + ); + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + + const suffix = generateId("test"); + const documentId = `ws-${suffix.slice(0, 20)}`; + let databaseId: string | undefined; + let collectionId: string | undefined; + + const cleanup = Effect.gen(function* () { + if (databaseId && collectionId) { + const cleanupDatabaseId = databaseId; + const cleanupCollectionId = collectionId; yield* host.deleteDocument(cleanupCollectionId, documentId); yield* host.deleteCollection(cleanupCollectionId); yield* host.deleteDatabase(cleanupDatabaseId); - }), - ); - } - closeWebSockets(); - await new Promise((resolve) => server.close(() => resolve())); - await runtime.dispose(); - } - }); + } + closeWebSockets(); + yield* Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + yield* Effect.promise(() => runtime.dispose()); + }).pipe(Effect.orDie); + + const body = Effect.gen(function* () { + const created = yield* Effect.gen(function* () { + const database = yield* host.createDatabase(`ws-${suffix}`, "integration"); + const collection = yield* host.createCollection(database.id, "documents", schema); + const document = yield* host.createDocument(collection.id, documentId, value); + const auth = yield* host.createDocumentAuthToken( + collection.id, + document.id, + "write", + [], + 60, + ); + return { database, collection, document, auth }; + }); + databaseId = created.database.id; + collectionId = created.collection.id; + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* Effect.fail( + new MimicNodeTestError({ message: "HTTP server did not expose a TCP port" }), + ); + } + const socket = new WebSocket( + `ws://127.0.0.1:${address.port}/ws/v1/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, + ); + const messages = yield* Effect.callback, MimicNodeTestError>( + (resume) => { + const received: unknown[] = []; + socket.once("error", (error) => + resume(Effect.fail(new MimicNodeTestError({ message: causeMessage(error) }))), + ); + socket.once("open", () => + socket.send(encodeJson({ type: "auth", token: created.auth.token })), + ); + socket.on("message", (data) => { + // oxlint-disable-next-line typescript/no-base-to-string -- `data` is the ws RawData union (Buffer | ArrayBuffer | Buffer[]); every frame this server sends is UTF-8 JSON, and Buffer.toString() is the documented way to read it. + const message = decodeJson(data.toString()); + received.push(message); + if (messageType(message) === "snapshot") resume(Effect.succeed(received)); + }); + }, + ).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new MimicNodeTestError({ message: "timed out waiting for snapshot" }), + ), + }), + ); + expect(messages).toContainEqual( + expect.objectContaining({ type: "auth_result", success: true, permission: "write" }), + ); + expect(messages).toContainEqual( + expect.objectContaining({ type: "snapshot", value, version: 1 }), + ); + const entityAddress = makeDurableEntityAddress( + "mimic-document", + `${collectionId}:${documentId}`, + ); + const attached = yield* entities.run(entityAddress, (entity) => entity.sessions.list); + expect(attached).toHaveLength(1); + + yield* Effect.callback((resume) => { + socket.once("close", () => resume(Effect.void)); + socket.close(1000, "done"); + }); + let remaining = attached; + for (let attempt = 0; attempt < 20 && remaining.length > 0; attempt += 1) { + yield* Effect.sleep("10 millis"); + remaining = yield* entities.run(entityAddress, (entity) => entity.sessions.list); + } + expect(remaining).toHaveLength(0); + }); + + yield* body.pipe(Effect.ensuring(cleanup)); + }), + )); }); diff --git a/apps/backend/tests/MimicNodeWebSocket.test.ts b/apps/backend/tests/MimicNodeWebSocket.test.ts index 7d6fed53a..e5079e6de 100644 --- a/apps/backend/tests/MimicNodeWebSocket.test.ts +++ b/apps/backend/tests/MimicNodeWebSocket.test.ts @@ -1,12 +1,13 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the test stands up a real `node:http` server to receive live requests; an `HttpServer` layer would not exercise the same wire path. import { createServer, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; +import { constant } from "@voidhash/lib/lang"; +import { objectValue } from "@voidhash/mimic-core"; import type { HostService } from "@voidhash/mimic-db/app/hostService"; -import type { SessionAttachment } from "@voidhash/mimic-db/ws/document-session"; import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; -import { Effect } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { Data, Effect, Option, Schema } from "effect"; +import { describe, expect, it } from "vitest"; import WebSocket from "ws"; import { installMimicNodeWebSocketServer } from "../src/mimic/MimicNodeWebSocket.ts"; @@ -14,93 +15,167 @@ import { installMimicNodeWebSocketServer } from "../src/mimic/MimicNodeWebSocket const collectionId = "collection-1"; const documentId = "document-1"; +class TestServerAddressError extends Data.TaggedError("TestServerAddressError")<{ + readonly message: string; +}> {} + +const notImplemented = (name: string) => () => + Effect.die(new Error(`HostService.${name} is not reachable from the document socket protocol`)); + /** * The slice of the host the document socket protocol touches while a client - * authenticates. Everything else stays unimplemented on purpose: reaching for - * it in this test would mean the socket path grew a dependency it should not - * have. + * authenticates. Everything else dies on purpose: reaching for it in this test + * would mean the socket path grew a dependency it should not have. */ -const stubHost = { +const stubHost: HostService = { + authenticateBasic: notImplemented("authenticateBasic"), authenticateDocumentToken: () => - Effect.succeed({ tokenId: "token-1", permission: "write" as const }), + Effect.succeed({ tokenId: "token-1", permission: constant("write") }), + createDatabase: notImplemented("createDatabase"), + listDatabases: notImplemented("listDatabases"), + deleteDatabase: notImplemented("deleteDatabase"), + createCollection: notImplemented("createCollection"), + listCollections: notImplemented("listCollections"), + deleteCollection: notImplemented("deleteCollection"), + createUser: notImplemented("createUser"), + listUsers: notImplemented("listUsers"), + deleteUser: notImplemented("deleteUser"), + grantPermission: notImplemented("grantPermission"), + revokePermission: notImplemented("revokePermission"), + listGrants: notImplemented("listGrants"), + createDocumentAuthToken: notImplemented("createDocumentAuthToken"), + createDocument: notImplemented("createDocument"), getDocument: () => Effect.succeed({ - value: { kind: "object" as const, fields: {} }, + collectionId, + id: documentId, + value: objectValue(), version: 1, }), + listDocuments: notImplemented("listDocuments"), + deleteDocument: notImplemented("deleteDocument"), + submitTransaction: notImplemented("submitTransaction"), + attachConnection: notImplemented("attachConnection"), + heartbeatConnection: notImplemented("heartbeatConnection"), + getConnectionDocument: notImplemented("getConnectionDocument"), + submitConnectionTransaction: notImplemented("submitConnectionTransaction"), + detachConnection: notImplemented("detachConnection"), getPresenceSnapshot: () => Effect.succeed({ presences: {} }), setPresence: () => Effect.void, removePresence: () => Effect.void, -} as unknown as HostService; + ensureDatabasePermission: notImplemented("ensureDatabasePermission"), + databaseIdForCollection: notImplemented("databaseIdForCollection"), +}; + +const AuthMessage = Schema.Struct({ + type: Schema.Literal("auth"), + token: Schema.String, +}); +const encodeAuthMessage = Schema.encodeSync(Schema.fromJsonString(AuthMessage)); + +const ServerMessage = Schema.Struct({ type: Schema.optional(Schema.String) }); +const decodeServerMessage = Schema.decodeUnknownOption(Schema.fromJsonString(ServerMessage)); -const cleanups: Array<() => Promise | void> = []; +const utf8 = new TextDecoder(); -afterEach(async () => { - for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +/** + * `ws` hands frame payloads over as a Buffer, an ArrayBuffer, or (when + * `fragments` are kept) an array of Buffers; decode each shape explicitly + * rather than relying on default stringification. + */ +const rawDataToString = (data: WebSocket.RawData): string => { + if (Array.isArray(data)) { + return data.map((chunk) => utf8.decode(chunk)).join(""); + } + return utf8.decode(data); +}; + +const SessionAttachmentShape = Schema.Struct({ + authenticated: Schema.Boolean, + permission: Schema.optional(Schema.Literals(["read", "write"])), }); +const decodeSessionAttachment = Schema.decodeUnknownSync(SessionAttachmentShape); -const listen = (server: Server): Promise => - new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve((server.address() as AddressInfo).port); +/** Binds the server to an ephemeral loopback port and returns it. */ +const listen = (server: Server) => + Effect.gen(function* () { + yield* Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.fail(error)); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resume(Effect.void); + }); }); + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* new TestServerAddressError({ + message: "Test server did not expose a TCP address", + }); + } + return address.port; }); describe("mimic Node WebSocket sessions", () => { - it("keeps the entity session attachment in step with authentication", async () => { - const entities = makeMemoryDurableEntityHost(); - const server = createServer(); - const close = installMimicNodeWebSocketServer(server, stubHost, entities, { - control: { listDueAlarms: () => Effect.succeed([]) }, - debounceMs: 15_000, - pollIntervalMs: 60_000, - publish: () => Effect.void, - }); - cleanups.push(() => { - close(); - return new Promise((resolve) => server.close(() => resolve())); - }); - const port = await listen(server); - - const socket = new WebSocket( - `ws://127.0.0.1:${port}/ws/v1/databases/database-1/collections/${collectionId}/documents/${documentId}`, - ); - cleanups.push(() => void socket.close()); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("timed out waiting for a snapshot")), 5_000); - socket.once("error", reject); - socket.once("open", () => - socket.send(JSON.stringify({ type: "auth", token: "token-1" })), - ); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as { readonly type?: string }; - if (message.type === "snapshot") { - clearTimeout(timeout); - resolve(); - } - }); - }); + it("keeps the entity session attachment in step with authentication", () => + Effect.runPromise( + Effect.gen(function* () { + const entities = makeMemoryDurableEntityHost(); + const server = createServer(); + const close = installMimicNodeWebSocketServer(server, stubHost, entities, { + control: { listDueAlarms: () => Effect.succeed([]) }, + debounceMs: 15_000, + pollIntervalMs: 60_000, + publish: () => Effect.void, + }); + yield* Effect.addFinalizer(() => + Effect.callback((resume) => { + close(); + server.close(() => resume(Effect.void)); + }), + ); + const port = yield* listen(server); + + const socket = new WebSocket( + `ws://127.0.0.1:${port}/ws/v1/databases/database-1/collections/${collectionId}/documents/${documentId}`, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => socket.close())); + yield* Effect.callback((resume) => { + socket.once("error", (error) => resume(Effect.fail(error))); + socket.once("open", () => + socket.send(encodeAuthMessage({ type: "auth", token: "token-1" })), + ); + socket.on("message", (data) => { + const message = decodeServerMessage(rawDataToString(data)); + if (Option.isSome(message) && message.value.type === "snapshot") { + resume(Effect.void); + } + }); + }).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.die(new Error("timed out waiting for a snapshot")), + }), + ); - const address = makeDurableEntityAddress( - "mimic-document", - `${collectionId}:${documentId}`, - ); - const attachments = await Effect.runPromise( - entities.run(address, (entity) => - entity.sessions.list.pipe( - Effect.flatMap((sessions) => - Effect.forEach(sessions, (session) => session.getAttachment), + const address = makeDurableEntityAddress( + "mimic-document", + `${collectionId}:${documentId}`, + ); + const attachments = yield* entities.run(address, (entity) => + entity.sessions.list.pipe( + Effect.flatMap((sessions) => + Effect.forEach(sessions, (session) => session.getAttachment), + ), ), - ), - ), - ); - - // Host-side broadcasts filter on this exact flag, so a stale attachment - // here means every authenticated browser socket is silently skipped. - expect(attachments).toHaveLength(1); - expect((attachments[0] as SessionAttachment).authenticated).toBe(true); - expect((attachments[0] as SessionAttachment).permission).toBe("write"); - }); + ); + + // Host-side broadcasts filter on this exact flag, so a stale attachment + // here means every authenticated browser socket is silently skipped. + expect(attachments).toHaveLength(1); + const attachment = decodeSessionAttachment(attachments[0]); + expect(attachment.authenticated).toBe(true); + expect(attachment.permission).toBe("write"); + }).pipe(Effect.scoped), + )); }); diff --git a/apps/backend/tests/PaywallRelease.integration.test.ts b/apps/backend/tests/PaywallRelease.integration.test.ts index 0a130b3cf..5c9135502 100644 --- a/apps/backend/tests/PaywallRelease.integration.test.ts +++ b/apps/backend/tests/PaywallRelease.integration.test.ts @@ -8,14 +8,14 @@ import { import type { AnyAuthSession } from "@voidhash/core/domain/auth/Auth"; import { AuthSession } from "@voidhash/core/domain/auth/Auth"; import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/PaywallAssetConfig"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, ReleaseStatus, eq, paywallReleases, paywalls } from "@voidhash/db"; -import { Effect, Layer } from "effect"; +import { DateTime, Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const makeSession = (projectId: string, userId: string): AnyAuthSession => { - const now = new Date(); +const makeSession = (projectId: string, userId: string, now: Date): AnyAuthSession => { return { cookie: null, method: "user", @@ -47,112 +47,112 @@ const makeSession = (projectId: string, userId: string): AnyAuthSession => { }; describe("self-host paywall releases", () => { - it("creates, publishes, and advances a visual paywall release", async () => { - const config = getSelfhostRuntimeConfig(); - const suffix = crypto.randomUUID(); - const paywallId = `paywall_${suffix}`; - const projectId = `project_${suffix}`; - const userId = `user_${suffix}`; - const objects = new Map(); - const database = Db.layer(config.database); - const dependencies = Layer.mergeAll( - database, - AuditLogPort.noop, - Layer.succeed(MimicHost, { - closePaywallConnection: () => Effect.die("unused"), - createPaywallEditToken: () => Effect.die("unused"), - ensurePaywallDocument: () => Effect.void, - getConnectedPaywallDocument: () => Effect.die("unused"), - getPaywallDocument: () => Effect.die("unused"), - getPaywallSnapshot: () => Effect.succeed({ id: "root", type: "root" }), - heartbeatPaywallConnection: () => Effect.die("unused"), - openPaywallConnection: () => Effect.die("unused"), - submitConnectedPaywallTransaction: () => Effect.die("unused"), - submitPaywallTransaction: () => Effect.die("unused"), - }), - Layer.succeed(PaywallArtifactStore, { - bucketName: "selfhost-release-test", - getObject: (key) => - Effect.sync(() => { - const body = objects.get(key); - return body === undefined ? null : { body, contentType: "text/html; charset=utf-8" }; + it("creates, publishes, and advances a visual paywall release", () => + Effect.runPromise( + Effect.gen(function* () { + const config = getSelfhostRuntimeConfig(); + const suffix = generateId("test"); + const paywallId = `paywall_${suffix}`; + const projectId = `project_${suffix}`; + const userId = `user_${suffix}`; + const now = yield* DateTime.nowAsDate; + const objects = new Map(); + const database = Db.layer(config.database); + const dependencies = Layer.mergeAll( + database, + AuditLogPort.noop, + Layer.succeed(MimicHost, { + closePaywallConnection: () => Effect.die("unused"), + createPaywallEditToken: () => Effect.die("unused"), + ensurePaywallDocument: () => Effect.void, + getConnectedPaywallDocument: () => Effect.die("unused"), + getPaywallDocument: () => Effect.die("unused"), + getPaywallSnapshot: () => Effect.succeed({ id: "root", type: "root" }), + heartbeatPaywallConnection: () => Effect.die("unused"), + openPaywallConnection: () => Effect.die("unused"), + submitConnectedPaywallTransaction: () => Effect.die("unused"), + submitPaywallTransaction: () => Effect.die("unused"), }), - head: (key) => - Effect.succeed(objects.has(key) ? { size: objects.get(key)?.length ?? 0 } : null), - putObject: ({ body, key }) => - Effect.sync(() => { - objects.set(key, body); + Layer.succeed(PaywallArtifactStore, { + bucketName: "selfhost-release-test", + getObject: (key) => + Effect.sync(() => { + const body = objects.get(key); + if (body === undefined) return null; + return { body, contentType: "text/html; charset=utf-8" }; + }), + head: (key) => + Effect.sync(() => { + if (!objects.has(key)) return null; + return { size: objects.get(key)?.length ?? 0 }; + }), + putObject: ({ body, key }) => + Effect.sync(() => { + objects.set(key, body); + }), }), - }), - Layer.succeed(PaywallAssetConfig, { - cdnUrl: "http://localhost:5001", - publicBaseUrl: "http://localhost:5001", - }), - Layer.succeed(SnapshotHtmlRenderer, { - render: ({ metadata }) => - Effect.succeed(`release ${metadata.version}`), - }), - ); - const releaseLayer = PaywallReleaseService.layer.pipe(Layer.provide(dependencies)); + Layer.succeed(PaywallAssetConfig, { + cdnUrl: "http://localhost:5001", + publicBaseUrl: "http://localhost:5001", + }), + Layer.succeed(SnapshotHtmlRenderer, { + render: ({ metadata }) => + Effect.succeed(`release ${metadata.version}`), + }), + ); + const releaseLayer = PaywallReleaseService.layer.pipe(Layer.provide(dependencies)); - try { - await Effect.runPromise( - Effect.gen(function* () { + const cleanup = Effect.gen(function* () { const db = yield* Db; - yield* db.insert(paywalls).values({ - id: paywallId, - name: "Self-host release", - projectId, - slug: `selfhost-release-${suffix}`, - }); - }).pipe(Effect.provide(database)), - ); + yield* db.delete(paywallReleases).where(eq(paywallReleases.paywallId, paywallId)); + yield* db.delete(paywalls).where(eq(paywalls.id, paywallId)); + }).pipe(Effect.provide(database), Effect.orDie); - const result = await Effect.runPromise( - Effect.gen(function* () { - const releases = yield* PaywallReleaseService; - const firstDraft = yield* releases.createRelease(paywallId); - const draft = yield* releases.getDraftRelease(paywallId); - const firstPublished = yield* releases.publishRelease(firstDraft.releaseId); - const secondDraft = yield* releases.createRelease(paywallId); - const secondPublished = yield* releases.publishRelease(secondDraft.releaseId); - return { draft, firstDraft, firstPublished, secondDraft, secondPublished }; - }).pipe( - Effect.provide(releaseLayer), - Effect.provideService(AuthSession, makeSession(projectId, userId)), - ), - ); + yield* Effect.gen(function* () { + yield* Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(paywalls).values({ + id: paywallId, + name: "Self-host release", + projectId, + slug: `selfhost-release-${suffix}`, + }); + }).pipe(Effect.provide(database)); - const rows = await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - return yield* db.query.paywallReleases.findMany({ - orderBy: { version: "asc" }, - where: { paywallId }, - }); - }).pipe(Effect.provide(database)), - ); + const result = yield* Effect.gen(function* () { + const releases = yield* PaywallReleaseService; + const firstDraft = yield* releases.createRelease(paywallId); + const draft = yield* releases.getDraftRelease(paywallId); + const firstPublished = yield* releases.publishRelease(firstDraft.releaseId); + const secondDraft = yield* releases.createRelease(paywallId); + const secondPublished = yield* releases.publishRelease(secondDraft.releaseId); + return { draft, firstDraft, firstPublished, secondDraft, secondPublished }; + }).pipe( + Effect.provide(releaseLayer), + Effect.provideService(AuthSession, makeSession(projectId, userId, now)), + ); - expect(result.draft?.releaseId).toBe(result.firstDraft.releaseId); - expect(result.firstPublished.version).toBe(1); - expect(result.secondDraft.version).toBe(2); - expect(result.secondPublished.version).toBe(2); - expect(objects.size).toBe(2); - expect(rows.filter((row) => row.status === ReleaseStatus.released)).toHaveLength(2); - expect( - rows.find((row) => row.version === 1 && row.status === ReleaseStatus.released)?.isActive, - ).toBe(false); - expect( - rows.find((row) => row.version === 2 && row.status === ReleaseStatus.released)?.isActive, - ).toBe(true); - } finally { - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - yield* db.delete(paywallReleases).where(eq(paywallReleases.paywallId, paywallId)); - yield* db.delete(paywalls).where(eq(paywalls.id, paywallId)); - }).pipe(Effect.provide(database)), - ); - } - }); + const rows = yield* Effect.gen(function* () { + const db = yield* Db; + return yield* db.query.paywallReleases.findMany({ + orderBy: { version: "asc" }, + where: { paywallId }, + }); + }).pipe(Effect.provide(database)); + + expect(result.draft?.releaseId).toBe(result.firstDraft.releaseId); + expect(result.firstPublished.version).toBe(1); + expect(result.secondDraft.version).toBe(2); + expect(result.secondPublished.version).toBe(2); + expect(objects.size).toBe(2); + expect(rows.filter((row) => row.status === ReleaseStatus.released)).toHaveLength(2); + expect( + rows.find((row) => row.version === 1 && row.status === ReleaseStatus.released)?.isActive, + ).toBe(false); + expect( + rows.find((row) => row.version === 2 && row.status === ReleaseStatus.released)?.isActive, + ).toBe(true); + }).pipe(Effect.ensuring(cleanup)); + }), + )); }); diff --git a/apps/backend/tests/Push.integration.test.ts b/apps/backend/tests/Push.integration.test.ts index e60eb2a02..647808257 100644 --- a/apps/backend/tests/Push.integration.test.ts +++ b/apps/backend/tests/Push.integration.test.ts @@ -1,6 +1,7 @@ import { PushDeliveryDispatch } from "@voidhash/core/services/notifications/PushDeliveryDispatch"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, sql } from "@voidhash/db"; -import { Context, Effect, Layer } from "effect"; +import { Clock, Context, Effect, Layer, Predicate } from "effect"; import { describe, expect, it } from "vitest"; import { makeSelfhostAnalyticsRuntimeLive } from "../src/backend/Analytics.ts"; @@ -10,54 +11,61 @@ import { } from "../src/backend/Push.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -describe("self-host push-delivery queue", () => { - it("dispatches and acknowledges a delivery pointer through the consumer", async () => { - const config = getSelfhostRuntimeConfig(); - const deliveryId = `pushDelivery_${crypto.randomUUID()}`; +/** Reads the `total` column off an untyped SQL row. */ +const totalOf = (row: unknown): number => { + if (!Predicate.isObject(row)) return 0; + return Number(row["total"] ?? 0); +}; - const remaining = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - // The queue rows live in the platform database, which is a different - // connection from the application tables the consumer reads. - const platformContext = yield* Layer.build(Db.layer(config.platformDatabase)); - const db = Context.get(platformContext, Db); - // The cluster queue driver hands the store a JSON string, which the - // store then JSON-encodes into `element`, so the body is doubly - // encoded: unwrap the outer JSON scalar before reading its fields. - yield* db.execute(sql` - DELETE FROM effect_queue - WHERE (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} - `); - const dispatchContext = yield* Layer.build(SelfhostPushDeliveryDispatchLive); - const dispatch = Context.get(dispatchContext, PushDeliveryDispatch); - yield* Effect.forkScoped(runSelfhostPushDeliveryConsumers(config)); - yield* dispatch.dispatch([ - { - projectId: "project_push_integration", - provider: "fcm", - pushNotificationDeliveryId: deliveryId, - pushNotificationSendId: "pushSend_integration", - }, - ]); +describe("self-host push-delivery queue", () => { + it("dispatches and acknowledges a delivery pointer through the consumer", () => + Effect.runPromise( + Effect.gen(function* () { + const config = getSelfhostRuntimeConfig(); + const deliveryId = `pushDelivery_${generateId("test")}`; - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const rows = yield* db.execute(sql` - SELECT COUNT(*)::integer AS total - FROM effect_queue - WHERE completed = FALSE - AND (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} + const remaining = yield* Effect.scoped( + Effect.gen(function* () { + // The queue rows live in the platform database, which is a different + // connection from the application tables the consumer reads. + const platformContext = yield* Layer.build(Db.layer(config.platformDatabase)); + const db = Context.get(platformContext, Db); + // The cluster queue driver hands the store a JSON string, which the + // store then JSON-encodes into `element`, so the body is doubly + // encoded: unwrap the outer JSON scalar before reading its fields. + yield* db.execute(sql` + DELETE FROM effect_queue + WHERE (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} `); - const total = Number((rows[0] as { readonly total?: number } | undefined)?.total ?? 0); - if (total === 0) return total; - yield* Effect.sleep("25 millis"); - } - return 1; - }).pipe(Effect.provide(makeSelfhostAnalyticsRuntimeLive(config))), - ), - ); + const dispatchContext = yield* Layer.build(SelfhostPushDeliveryDispatchLive); + const dispatch = Context.get(dispatchContext, PushDeliveryDispatch); + yield* Effect.forkScoped(runSelfhostPushDeliveryConsumers(config)); + yield* dispatch.dispatch([ + { + projectId: "project_push_integration", + provider: "fcm", + pushNotificationDeliveryId: deliveryId, + pushNotificationSendId: "pushSend_integration", + }, + ]); + + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while ((yield* Clock.currentTimeMillis) < deadline) { + const rows = yield* db.execute(sql` + SELECT COUNT(*)::integer AS total + FROM effect_queue + WHERE completed = FALSE + AND (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} + `); + const total = totalOf(rows[0]); + if (total === 0) return total; + yield* Effect.sleep("25 millis"); + } + return 1; + }).pipe(Effect.provide(makeSelfhostAnalyticsRuntimeLive(config))), + ); - expect(remaining).toBe(0); - }); + expect(remaining).toBe(0); + }), + )); }); diff --git a/apps/backend/tests/SecurityConfig.test.ts b/apps/backend/tests/SecurityConfig.test.ts index 8476f862d..9d277bd4a 100644 --- a/apps/backend/tests/SecurityConfig.test.ts +++ b/apps/backend/tests/SecurityConfig.test.ts @@ -1,8 +1,9 @@ +import { constant } from "@voidhash/lib/lang"; import { afterEach, describe, expect, it, vi } from "vitest"; import { validateSelfhostSecurityConfig } from "../src/config.ts"; -const validProductionEnvironment = { +const validProductionEnvironment = constant({ CLICKHOUSE_URL: "", DATABASE_PASSWORD: "database-secret", MIMIC_PUBLIC_BASE_URL: "https://mimic.example.test", @@ -15,7 +16,7 @@ const validProductionEnvironment = { VOIDHASH_AUTH_SECRET: "session-signing-secret-with-entropy", VOIDHASH_ROOT_PASSWORD: "root-secret-with-sufficient-entropy", VOIDHASH_ROOT_USERNAME: "operator", -} as const; +}); const stubEnvironment = (environment: Record) => { for (const [name, value] of Object.entries(environment)) { @@ -23,6 +24,7 @@ const stubEnvironment = (environment: Record) => { } }; +// oxlint-disable-next-line effect/noTestLifecycleHooks -- `vi.unstubAllEnvs()` restores vitest's process-env stubs, which live in the vitest lifecycle and have no Effect-scoped equivalent. afterEach(() => { vi.unstubAllEnvs(); }); diff --git a/apps/backend/tests/StandaloneAuth.integration.test.ts b/apps/backend/tests/StandaloneAuth.integration.test.ts index 29f8d2ff5..e5441cc75 100644 --- a/apps/backend/tests/StandaloneAuth.integration.test.ts +++ b/apps/backend/tests/StandaloneAuth.integration.test.ts @@ -7,9 +7,9 @@ import { signStandaloneAuthToken, } from "@voidhash/core/utils/crypto/standalone-auth-token"; import { Db, eq, sql, user } from "@voidhash/db"; -import { Context, Effect, Layer, Redacted } from "effect"; +import { Context, DateTime, Effect, Exit, Layer, Redacted } from "effect"; import * as HttpHeaders from "effect/unstable/http/Headers"; -import { afterAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { makeSelfhostAuthLayers } from "../src/backend/Backend.ts"; import { getSelfhostDatabaseConfig } from "../src/config.ts"; @@ -47,84 +47,104 @@ const resolve = (headers: Record) => Effect.provide(LocalUserSessionService.layer), Effect.provide(database), ); - }).pipe(Effect.scoped, Effect.runPromise); + }).pipe(Effect.scoped); + +const optionalName = (name?: string): { readonly name?: string } => { + if (!name) return {}; + return { name }; +}; const token = (email: string, name?: string) => - Effect.runPromise(signStandaloneAuthToken({ email, secret, ...(name ? { name } : {}) })); + signStandaloneAuthToken({ email, secret, ...optionalName(name) }); + +/** Runs a test body and always drops the rows the suite provisions. */ +const runTest = (body: Effect.Effect) => + Effect.runPromise(body.pipe(Effect.ensuring(cleanup.pipe(Effect.orDie)))); describe("standalone identity provider against Postgres", () => { - afterAll(async () => { - await Effect.runPromise(cleanup); - }); - - it("creates the root user row on first cookie authentication", async () => { - await Effect.runPromise(cleanup); - const session = await resolve({ - cookie: `${STANDALONE_AUTH_COOKIE_NAME}=${await token(rootEmail, "Root Operator")}`, - }); - - expect(session.method).toBe("user"); - expect(session.user?.email).toBe(rootEmail); - expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); - expect(session.user?.name).toBe("Root Operator"); - }); - - it("resolves the same single user through the bearer path", async () => { - const bearer = await token(rootEmail, "Root Operator"); - - const first = await resolve({ authorization: `Bearer ${bearer}` }); - const second = await resolve({ cookie: `${STANDALONE_AUTH_COOKIE_NAME}=${bearer}` }); - - expect(first.user?.id).toBe(second.user?.id); - expect(first.user?.email).toBe(rootEmail); - }); - - it("rejects a token signed with a different secret", async () => { - const forged = await Effect.runPromise( - signStandaloneAuthToken({ email: rootEmail, secret: "not-the-secret" }), - ); + it("creates the root user row on first cookie authentication", () => + runTest( + Effect.gen(function* () { + yield* cleanup; + const session = yield* resolve({ + cookie: `${STANDALONE_AUTH_COOKIE_NAME}=${yield* token(rootEmail, "Root Operator")}`, + }); - await expect(resolve({ authorization: `Bearer ${forged}` })).rejects.toBeDefined(); - }); + expect(session.method).toBe("user"); + expect(session.user?.email).toBe(rootEmail); + expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); + expect(session.user?.name).toBe("Root Operator"); + }), + )); - it("rejects a request with no credentials", async () => { - await expect(resolve({})).rejects.toBeDefined(); - }); + it("resolves the same single user through the bearer path", () => + runTest( + Effect.gen(function* () { + const bearer = yield* token(rootEmail, "Root Operator"); - it("adopts an existing row for the same email instead of creating a second user", async () => { - await Effect.runPromise(cleanup); - await Effect.runPromise( + const first = yield* resolve({ authorization: `Bearer ${bearer}` }); + const second = yield* resolve({ cookie: `${STANDALONE_AUTH_COOKIE_NAME}=${bearer}` }); + + expect(first.user?.id).toBe(second.user?.id); + expect(first.user?.email).toBe(rootEmail); + }), + )); + + it("rejects a token signed with a different secret", () => + runTest( Effect.gen(function* () { - const db = yield* Db; - yield* db.insert(user).values({ - banned: false, - banExpires: null, - banReason: null, - createdAt: new Date(), - customImageUrl: null, + const forged = yield* signStandaloneAuthToken({ email: rootEmail, - emailVerified: true, - id: "user_standaloneadopt00000000", - image: null, - name: "Previously Provisioned", - role: null, - updatedAt: new Date(), - workosUserId: "user_external_previous", + secret: "not-the-secret", }); - }).pipe(Effect.provide(database), Effect.scoped), - ); - const session = await resolve({ authorization: `Bearer ${await token(rootEmail)}` }); + const exit = yield* Effect.exit(resolve({ authorization: `Bearer ${forged}` })); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - expect(session.user?.id).toBe("user_standaloneadopt00000000"); - expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); + it("rejects a request with no credentials", () => + runTest( + Effect.gen(function* () { + const exit = yield* Effect.exit(resolve({})); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - const rows = await Effect.runPromise( + it("adopts an existing row for the same email instead of creating a second user", () => + runTest( Effect.gen(function* () { - const db = yield* Db; - return yield* db.select().from(user).where(eq(user.email, rootEmail)); - }).pipe(Effect.provide(database), Effect.scoped), - ); - expect(rows).toHaveLength(1); - }); + yield* cleanup; + const now = yield* DateTime.nowAsDate; + yield* Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(user).values({ + banned: false, + banExpires: null, + banReason: null, + createdAt: now, + customImageUrl: null, + email: rootEmail, + emailVerified: true, + id: "user_standaloneadopt00000000", + image: null, + name: "Previously Provisioned", + role: null, + updatedAt: now, + workosUserId: "user_external_previous", + }); + }).pipe(Effect.provide(database), Effect.scoped); + + const session = yield* resolve({ authorization: `Bearer ${yield* token(rootEmail)}` }); + + expect(session.user?.id).toBe("user_standaloneadopt00000000"); + expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); + + const rows = yield* Effect.gen(function* () { + const db = yield* Db; + return yield* db.select().from(user).where(eq(user.email, rootEmail)); + }).pipe(Effect.provide(database), Effect.scoped); + expect(rows).toHaveLength(1); + }), + )); }); diff --git a/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts b/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts index dc8de8c22..c043b4e5d 100644 --- a/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts +++ b/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts @@ -2,7 +2,7 @@ import { StandaloneOrgDirectoryLive } from "@voidhash/core/services/organization import { OrgDirectoryPort } from "@voidhash/core/services/organizations/OrgDirectoryPort"; import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, eq, member, organization, user } from "@voidhash/db"; -import { Effect } from "effect"; +import { DateTime, Effect } from "effect"; import { afterAll, describe, expect, it } from "vitest"; import { getSelfhostDatabaseConfig } from "../src/config.ts"; @@ -24,28 +24,30 @@ const withServices = (effect: Effect.Effect) ); describe("local organization directory", () => { - afterAll(async () => { - await withServices( + // oxlint-disable-next-line effect/noTestLifecycleHooks -- deletes the shared fixture rows once after the whole suite; the rows outlive every individual test's Effect scope, so acquireRelease cannot own them. + afterAll(() => + withServices( Effect.gen(function* () { const db = yield* Db; yield* db.delete(member).where(eq(member.id, memberId)); yield* db.delete(organization).where(eq(organization.id, orgId)); yield* db.delete(user).where(eq(user.id, userId)); }), - ); - }); + ), + ); - it("synthesizes provider ids that satisfy the NOT NULL workos columns", async () => { - await withServices( + it("synthesizes provider ids that satisfy the NOT NULL workos columns", () => + withServices( Effect.gen(function* () { const port = yield* OrgDirectoryPort; const db = yield* Db; + const now = yield* DateTime.nowAsDate; yield* db.insert(user).values({ banned: false, banExpires: null, banReason: null, - createdAt: new Date(), + createdAt: now, customImageUrl: null, email, emailVerified: true, @@ -53,7 +55,7 @@ describe("local organization directory", () => { image: null, name: "Directory Dev", role: null, - updatedAt: new Date(), + updatedAt: now, workosUserId: `local_${"a".repeat(24)}`, }); @@ -76,7 +78,7 @@ describe("local organization directory", () => { // The real INSERTs OrganizationService performs — proof the synthesized // ids actually satisfy the constraints. yield* db.insert(organization).values({ - createdAt: new Date(), + createdAt: now, id: orgId, logo: null, metadata: null, @@ -85,7 +87,7 @@ describe("local organization directory", () => { workosOrganizationId: createdOrg.id, }); yield* db.insert(member).values({ - createdAt: new Date(), + createdAt: now, id: memberId, organizationId: orgId, role: "admin", @@ -93,11 +95,10 @@ describe("local organization directory", () => { workosMembershipId: membership.id, }); }), - ); - }); + )); - it("reads users and memberships back out of the local tables", async () => { - await withServices( + it("reads users and memberships back out of the local tables", () => + withServices( Effect.gen(function* () { const port = yield* OrgDirectoryPort; @@ -115,15 +116,13 @@ describe("local organization directory", () => { const org = yield* port.getOrganizationByExternalId(orgId); expect(org?.name).toBe("Directory Org"); }), - ); - }); + )); - it("returns null for an unknown email", async () => { - await withServices( + it("returns null for an unknown email", () => + withServices( Effect.gen(function* () { const port = yield* OrgDirectoryPort; expect(yield* port.findUserByEmail("nobody@integration.test")).toBeNull(); }), - ); - }); + )); }); diff --git a/apps/backend/tests/ThumbnailQueue.integration.test.ts b/apps/backend/tests/ThumbnailQueue.integration.test.ts index 50e49c15a..a19c079ad 100644 --- a/apps/backend/tests/ThumbnailQueue.integration.test.ts +++ b/apps/backend/tests/ThumbnailQueue.integration.test.ts @@ -1,6 +1,7 @@ import { PaywallThumbnailService } from "@voidhash/core/services/paywallThumbnails/PaywallThumbnailService"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, sql } from "@voidhash/db"; -import { Effect } from "effect"; +import { Clock, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { makeSelfhostAnalyticsRuntimeLive } from "../src/backend/Analytics.ts"; @@ -12,14 +13,26 @@ import { } from "../src/mimic/MimicDocumentIdleQueue.ts"; describe("self-host thumbnail queue", () => { - it("delivers and acknowledges an idle-document revision", async () => { - const config = getSelfhostRuntimeConfig(); - const documentId = `thumbnail-${crypto.randomUUID()}`; - const handled: Array<{ readonly documentId: string; readonly seq: number }> = []; + it("delivers and acknowledges an idle-document revision", () => + Effect.runPromise( + Effect.gen(function* () { + const config = getSelfhostRuntimeConfig(); + const documentId = `thumbnail-${generateId("test")}`; + const handled: Array<{ readonly documentId: string; readonly seq: number }> = []; - try { - await Effect.runPromise( - Effect.scoped( + const cleanup = Effect.gen(function* () { + const db = yield* Db; + // The cluster queue driver hands the store a JSON string, which the + // store then JSON-encodes into `element`, so the body is doubly + // encoded: unwrap the outer JSON scalar before reading its fields. + yield* db.execute(sql` + DELETE FROM effect_queue + WHERE queue_name = ${mimicDocumentIdleQueueName} + AND (element::jsonb #>> '{}')::jsonb ->> 'documentId' = ${documentId} + `); + }).pipe(Effect.provide(Db.layer(config.platformDatabase)), Effect.orDie); + + yield* Effect.scoped( Effect.gen(function* () { const publish = yield* makeSelfhostMimicDocumentIdlePublisher; const service = PaywallThumbnailService.of({ @@ -37,29 +50,17 @@ describe("self-host thumbnail queue", () => { ); yield* publish({ collectionId: "collection-1", documentId, seq: 17 }); - const deadline = Date.now() + 10_000; - while (handled.length === 0 && Date.now() < deadline) { + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while (handled.length === 0 && (yield* Clock.currentTimeMillis) < deadline) { yield* Effect.sleep("25 millis"); } }), - ).pipe(Effect.provide(makeSelfhostAnalyticsRuntimeLive(config))), - ); - } finally { - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - // The cluster queue driver hands the store a JSON string, which the - // store then JSON-encodes into `element`, so the body is doubly - // encoded: unwrap the outer JSON scalar before reading its fields. - yield* db.execute(sql` - DELETE FROM effect_queue - WHERE queue_name = ${mimicDocumentIdleQueueName} - AND (element::jsonb #>> '{}')::jsonb ->> 'documentId' = ${documentId} - `); - }).pipe(Effect.provide(Db.layer(config.platformDatabase))), - ); - } + ).pipe( + Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), + Effect.ensuring(cleanup), + ); - expect(handled).toEqual([{ documentId, seq: 17 }]); - }); + expect(handled).toEqual([{ documentId, seq: 17 }]); + }), + )); }); diff --git a/apps/backend/tests/Thumbnails.test.ts b/apps/backend/tests/Thumbnails.test.ts index 765212ea9..76ccbb6ff 100644 --- a/apps/backend/tests/Thumbnails.test.ts +++ b/apps/backend/tests/Thumbnails.test.ts @@ -14,83 +14,100 @@ import { SelfhostSnapshotImageRendererLive, } from "../src/backend/Thumbnails.ts"; -describe("self-host paywall thumbnail renderer", () => { - it("provides the manifest cache required by the thumbnail service", async () => { - const renderer = Layer.succeed(SnapshotImageRenderer, { - render: () => Effect.succeed(new Uint8Array()), - }); - const dependencies = Layer.mergeAll( - Layer.succeed(Db, {} as never), - Layer.succeed(MimicHost, {} as never), - Layer.succeed(PaywallArtifactStore, {} as never), - Layer.succeed(ComponentCompiler, {} as never), - Layer.succeed(PublicFileStore, {} as never), - ); +/** + * Stub for a service the subject never touches. Member access fails loudly as a + * defect instead of silently yielding `undefined`, so a future dependency on one + * of these layers surfaces immediately rather than as a confusing crash. + */ +const unusedService = (): A => + new Proxy(Object.create(null), { + get: (_target, property) => { + if (typeof property === "symbol") return undefined; + return Effect.runSync( + Effect.die(new Error(`unused test service member accessed: ${property}`)), + ); + }, + }); - const context = await Effect.runPromise( - Effect.scoped( - Layer.build(makeSelfhostPaywallThumbnailServiceLive({}, renderer)).pipe( - Effect.provide(dependencies), - ), - ), - ); +describe("self-host paywall thumbnail renderer", () => { + it("provides the manifest cache required by the thumbnail service", () => + Effect.runPromise( + Effect.gen(function* () { + const renderer = Layer.succeed(SnapshotImageRenderer, { + render: () => Effect.succeed(new Uint8Array()), + }); + const dependencies = Layer.mergeAll( + Layer.succeed(Db, unusedService()), + Layer.succeed(MimicHost, unusedService()), + Layer.succeed(PaywallArtifactStore, unusedService()), + Layer.succeed(ComponentCompiler, unusedService()), + Layer.succeed(PublicFileStore, unusedService()), + ); - expect(Context.get(context, PaywallThumbnailService)).toBeDefined(); - }); + const context = yield* Effect.scoped( + Layer.build(makeSelfhostPaywallThumbnailServiceLive({}, renderer)).pipe( + Effect.provide(dependencies), + ), + ); - it("renders static paywall HTML through the screenshot port", async () => { - const screenshots: string[] = []; - const png = new Uint8Array([137, 80, 78, 71]); - const screenshot = Layer.succeed( - HtmlScreenshot, - HtmlScreenshot.of({ - screenshot: (options) => - Effect.sync(() => { - screenshots.push(options.html); - return png; - }), + expect(Context.get(context, PaywallThumbnailService)).toBeDefined(); }), - ); + )); - const rendered = await Effect.runPromise( + it("renders static paywall HTML through the screenshot port", () => + Effect.runPromise( Effect.gen(function* () { - const renderer = yield* SnapshotImageRenderer; - return yield* renderer.render({ - componentTrees: {}, - localComponentTrees: {}, - deviceScaleFactor: 2, - height: 812, - snapshot: { - type: "root", - id: "root", - parentId: null, - pos: "a0", - data: { name: "Paywall" }, - children: [], - }, - width: 375, - }); - }).pipe( - Effect.provide( - SelfhostSnapshotImageRendererLive.pipe( - Layer.provide(screenshot), - Layer.provide( - Layer.succeed(PublicFileStore, { - publicBaseUrl: "https://files.test", - publicUrl: (key) => `https://files.test/files/${key}`, - putObject: () => Effect.void, - getObject: () => Effect.succeed(null), - deleteObject: () => Effect.void, + const screenshots: string[] = []; + const png = new Uint8Array([137, 80, 78, 71]); + const screenshot = Layer.succeed( + HtmlScreenshot, + HtmlScreenshot.of({ + screenshot: (options) => + Effect.sync(() => { + screenshots.push(options.html); + return png; }), + }), + ); + + const rendered = yield* Effect.gen(function* () { + const renderer = yield* SnapshotImageRenderer; + return yield* renderer.render({ + componentTrees: {}, + localComponentTrees: {}, + deviceScaleFactor: 2, + height: 812, + snapshot: { + type: "root", + id: "root", + parentId: null, + pos: "a0", + data: { name: "Paywall" }, + children: [], + }, + width: 375, + }); + }).pipe( + Effect.provide( + SelfhostSnapshotImageRendererLive.pipe( + Layer.provide(screenshot), + Layer.provide( + Layer.succeed(PublicFileStore, { + publicBaseUrl: "https://files.test", + publicUrl: (key) => `https://files.test/files/${key}`, + putObject: () => Effect.void, + getObject: () => Effect.succeed(null), + deleteObject: () => Effect.void, + }), + ), ), ), - ), - ), - ); + ); - expect(rendered).toEqual(png); - expect(screenshots).toHaveLength(1); - expect(screenshots[0]).toContain('id="paywall-root"'); - expect(screenshots[0]).not.toContain("__VOIDHASH_PAYWALL__"); - }); + expect(rendered).toEqual(png); + expect(screenshots).toHaveLength(1); + expect(screenshots[0]).toContain('id="paywall-root"'); + expect(screenshots[0]).not.toContain("__VOIDHASH_PAYWALL__"); + }), + )); }); diff --git a/apps/backend/tests/WorkflowComposition.integration.test.ts b/apps/backend/tests/WorkflowComposition.integration.test.ts index 55b3e1c06..ce67a5eec 100644 --- a/apps/backend/tests/WorkflowComposition.integration.test.ts +++ b/apps/backend/tests/WorkflowComposition.integration.test.ts @@ -1,8 +1,9 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the test stands up a real `node:http` server to receive live requests; an `HttpServer` layer would not exercise the same wire path. import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; import { DeliverWebhookRegistration } from "@voidhash/core/workflows/DeliverWebhook"; import { DeliverWebhook } from "@voidhash/core/workflows/definitions"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, WebhookDeliveryStatus, @@ -12,119 +13,76 @@ import { webhookDeliveryAttempts, webhookEndpoints, } from "@voidhash/db"; -import { Effect, Layer } from "effect"; +import { Clock, Data, DateTime, Effect, Layer, Schema } from "effect"; import * as Workflow from "@voidhash/platform/Workflow"; import { describe, expect, it } from "vitest"; import { makeSelfhostPlatformLayers } from "../src/backend/PlatformProfile.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -describe("self-host workflow composition", () => { - it("delivers a webhook through the durable cluster runner", async () => { - const receivedBodies: string[] = []; - const server = createServer((request, response) => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - receivedBodies.push(Buffer.concat(chunks).toString("utf8")); - response.writeHead(204).end(); - }); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); +class TestServerAddressError extends Data.TaggedError("TestServerAddressError")<{ + readonly message: string; +}> {} - const config = getSelfhostRuntimeConfig(); - const address = server.address() as AddressInfo; - const suffix = crypto.randomUUID(); - const endpointId = `webhookEndpoint_${suffix}`; - const deliveryId = `webhookDelivery_${suffix}`; - const database = Db.layer(config.database); - const platformDatabase = Db.layer(config.platformDatabase); - const platform = makeSelfhostPlatformLayers(config); - const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); +const WebhookPayload = Schema.Struct({ deliveryId: Schema.String }); +const encodeWebhookPayload = Schema.encodeSync(Schema.fromJsonString(WebhookPayload)); - try { - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - yield* db.insert(webhookEndpoints).values({ - events: ["person.created"], - id: endpointId, - name: "workflow integration", - projectId: `project_${suffix}`, - secret: "whsec_integration", - url: `http://127.0.0.1:${address.port}/webhook`, +describe("self-host workflow composition", () => { + it("delivers a webhook through the durable cluster runner", () => + Effect.runPromise( + Effect.gen(function* () { + const receivedBodies: string[] = []; + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + receivedBodies.push(Buffer.concat(chunks).toString("utf8")); + response.writeHead(204).end(); }); - yield* db.insert(webhookDeliveries).values({ - eventOccurredAt: new Date(), - eventType: "person.created", - id: deliveryId, - payload: { deliveryId }, - projectId: `project_${suffix}`, - webhookEndpointId: endpointId, + }); + yield* Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.fail(error)); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resume(Effect.void); }); - }).pipe(Effect.provide(database)), - ); + }); - const attempts = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* DeliverWebhookRegistration.register(database); - yield* Workflow.execute(DeliverWebhook, { - attemptNumber: 1, - deliveryId, - endpointId, - eventType: "person.created", - payload: { deliveryId }, - secret: "whsec_integration", - url: `http://127.0.0.1:${address.port}/webhook`, - }); + const config = getSelfhostRuntimeConfig(); + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* new TestServerAddressError({ + message: "Test webhook receiver did not expose a TCP address", + }); + } + const suffix = generateId("test"); + const endpointId = `webhookEndpoint_${suffix}`; + const deliveryId = `webhookDelivery_${suffix}`; + const database = Db.layer(config.database); + const platformDatabase = Db.layer(config.platformDatabase); + const platform = makeSelfhostPlatformLayers(config); + const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); + const teardown = Effect.gen(function* () { + yield* Effect.gen(function* () { const db = yield* Db; - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const row = yield* db.query.webhookDeliveries.findFirst({ - where: { id: deliveryId }, - }); - if (row?.status === WebhookDeliveryStatus.Succeeded) { - return yield* db.query.webhookDeliveryAttempts.findMany({ - where: { webhookDeliveryId: deliveryId }, - }); - } - yield* Effect.sleep("25 millis"); - } - return yield* Effect.die("webhook workflow timed out"); - }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), - ), - ); - - expect(receivedBodies).toEqual([JSON.stringify({ deliveryId })]); - expect(attempts).toHaveLength(1); - expect(attempts[0]).toMatchObject({ attemptNumber: 1, statusCode: 204, succeeded: true }); - } finally { - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - yield* db - .delete(webhookDeliveryAttempts) - .where(eq(webhookDeliveryAttempts.webhookDeliveryId, deliveryId)); - yield* db.delete(webhookDeliveries).where(eq(webhookDeliveries.id, deliveryId)); - yield* db.delete(webhookEndpoints).where(eq(webhookEndpoints.id, endpointId)); - }).pipe(Effect.provide(database)), - ); - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - // The cluster workflow engine keeps no tables of its own: an - // execution, its activities, its deferreds, and its durable clocks - // are all rows in `cluster_messages` addressed to the same - // `entity_id` (the hashed execution ID). Only the `run` row carries - // the workflow payload, so it is what maps a delivery back to an - // execution. Those rows live in the platform database, which is a - // different connection from the application tables above. - yield* db.execute(sql` + yield* db + .delete(webhookDeliveryAttempts) + .where(eq(webhookDeliveryAttempts.webhookDeliveryId, deliveryId)); + yield* db.delete(webhookDeliveries).where(eq(webhookDeliveries.id, deliveryId)); + yield* db.delete(webhookEndpoints).where(eq(webhookEndpoints.id, endpointId)); + }).pipe(Effect.provide(database)); + yield* Effect.gen(function* () { + const db = yield* Db; + // The cluster workflow engine keeps no tables of its own: an + // execution, its activities, its deferreds, and its durable clocks + // are all rows in `cluster_messages` addressed to the same + // `entity_id` (the hashed execution ID). Only the `run` row carries + // the workflow payload, so it is what maps a delivery back to an + // execution. Those rows live in the platform database, which is a + // different connection from the application tables above. + yield* db.execute(sql` DELETE FROM cluster_replies WHERE request_id IN ( SELECT request_id FROM cluster_messages @@ -134,16 +92,75 @@ describe("self-host workflow composition", () => { ) ) `); - yield* db.execute(sql` + yield* db.execute(sql` DELETE FROM cluster_messages WHERE entity_id IN ( SELECT entity_id FROM cluster_messages WHERE tag = 'run' AND payload::jsonb ->> 'deliveryId' = ${deliveryId} ) `); - }).pipe(Effect.provide(platformDatabase)), - ); - await new Promise((resolve) => server.close(() => resolve())); - } - }); + }).pipe(Effect.provide(platformDatabase)); + yield* Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + }).pipe(Effect.orDie); + + return yield* Effect.gen(function* () { + const occurredAt = yield* DateTime.nowAsDate; + yield* Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(webhookEndpoints).values({ + events: ["person.created"], + id: endpointId, + name: "workflow integration", + projectId: `project_${suffix}`, + secret: "whsec_integration", + url: `http://127.0.0.1:${address.port}/webhook`, + }); + yield* db.insert(webhookDeliveries).values({ + eventOccurredAt: occurredAt, + eventType: "person.created", + id: deliveryId, + payload: { deliveryId }, + projectId: `project_${suffix}`, + webhookEndpointId: endpointId, + }); + }).pipe(Effect.provide(database)); + + const attempts = yield* Effect.scoped( + Effect.gen(function* () { + yield* DeliverWebhookRegistration.register(database); + yield* Workflow.execute(DeliverWebhook, { + attemptNumber: 1, + deliveryId, + endpointId, + eventType: "person.created", + payload: { deliveryId }, + secret: "whsec_integration", + url: `http://127.0.0.1:${address.port}/webhook`, + }); + + const db = yield* Db; + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while ((yield* Clock.currentTimeMillis) < deadline) { + const row = yield* db.query.webhookDeliveries.findFirst({ + where: { id: deliveryId }, + }); + if (row?.status === WebhookDeliveryStatus.Succeeded) { + return yield* db.query.webhookDeliveryAttempts.findMany({ + where: { webhookDeliveryId: deliveryId }, + }); + } + yield* Effect.sleep("25 millis"); + } + return yield* Effect.die("webhook workflow timed out"); + }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), + ); + + expect(receivedBodies).toEqual([encodeWebhookPayload({ deliveryId })]); + expect(attempts).toHaveLength(1); + expect(attempts[0]).toMatchObject({ attemptNumber: 1, statusCode: 204, succeeded: true }); + }).pipe(Effect.ensuring(teardown)); + }), + )); }); diff --git a/apps/backend/tests/WorkflowRegistry.integration.test.ts b/apps/backend/tests/WorkflowRegistry.integration.test.ts index f6c19f562..88fb3c58b 100644 --- a/apps/backend/tests/WorkflowRegistry.integration.test.ts +++ b/apps/backend/tests/WorkflowRegistry.integration.test.ts @@ -1,5 +1,5 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the test stands up a real loopback FX server whose `.address()`/`.close()` handles are driven directly; `HttpServer` from effect/unstable/http would not hand back the `http.Server` this fixture needs. import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; import { AnalyticsDispatchService } from "@voidhash/core/services/analyticsIngest/AnalyticsDispatchService"; import { @@ -14,6 +14,7 @@ import { StripeReplayParkedNotifications, } from "@voidhash/core/workflows/definitions"; import { backendWorkflows } from "@voidhash/core/workflows/registry"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, type InsertPurchaseLedger, @@ -30,361 +31,406 @@ import { webhookDeliveryAttempts, webhookEndpoints, } from "@voidhash/db"; +import { causeMessage, constant } from "@voidhash/lib/lang"; import * as Workflow from "@voidhash/platform/Workflow"; -import { Effect, Exit, Layer } from "effect"; +import { Clock, Data, DateTime, Effect, Exit, Layer, Schema } from "effect"; import { describe, expect, it } from "vitest"; import { makeSelfhostPlatformLayers } from "../src/backend/PlatformProfile.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; +class WorkflowRegistryTestError extends Data.TaggedError("WorkflowRegistryTestError")<{ + readonly message: string; +}> {} + +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); + const FX_UPDATE_UNIX = 1_767_225_600; -const FX_AS_OF_DATE = new Date(FX_UPDATE_UNIX * 1_000); +const FX_AS_OF_DATE = DateTime.toDateUtc(DateTime.makeUnsafe(FX_UPDATE_UNIX * 1_000)); const FX_CURRENCY = "XTS"; const DAY_MS = 24 * 60 * 60 * 1_000; describe("self-host workflow registry", () => { - it("executes every workflow through the Postgres-backed cluster runner", async () => { - const marker = `workflow-coverage-${crypto.randomUUID()}`; - const webhookEndpointId = `${marker}-endpoint`; - const webhookDeliveryId = `${marker}-delivery`; - const ledgerId = `${marker}-ledger`; - const expireId = `${marker}-expire`; - const appProductId = `${marker}-app-product`; - const appSdkId = `${marker}-app-sdk`; - const googleId = `${marker}-google`; - const stripeId = `${marker}-stripe`; - const expireTriggeredAt = new Date().toISOString(); - const notificationIds = [expireId, appProductId, appSdkId, googleId, stripeId]; - const receivedWebhookBodies: string[] = []; - let fxRequests = 0; + it("executes every workflow through the Postgres-backed cluster runner", () => + Effect.runPromise( + Effect.gen(function* () { + const marker = `workflow-coverage-${generateId("test")}`; + const webhookEndpointId = `${marker}-endpoint`; + const webhookDeliveryId = `${marker}-delivery`; + const ledgerId = `${marker}-ledger`; + const expireId = `${marker}-expire`; + const appProductId = `${marker}-app-product`; + const appSdkId = `${marker}-app-sdk`; + const googleId = `${marker}-google`; + const stripeId = `${marker}-stripe`; + const expireTriggeredAt = (yield* DateTime.nowAsDate).toISOString(); + const notificationIds = [expireId, appProductId, appSdkId, googleId, stripeId]; + const receivedWebhookBodies: string[] = []; + let fxRequests = 0; - const server = createServer((request, response) => { - if (request.url?.endsWith("/latest/USD")) { - fxRequests++; - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - base_code: "USD", - conversion_rates: { [FX_CURRENCY]: 2 }, - result: "success", - time_last_update_unix: FX_UPDATE_UNIX, - }), - ); - return; - } + const server = createServer((request, response) => { + if (request.url?.endsWith("/latest/USD")) { + fxRequests++; + response.writeHead(200, { "content-type": "application/json" }); + response.end( + encodeJson({ + base_code: "USD", + conversion_rates: { [FX_CURRENCY]: 2 }, + result: "success", + time_last_update_unix: FX_UPDATE_UNIX, + }), + ); + return; + } - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - receivedWebhookBodies.push(Buffer.concat(chunks).toString("utf8")); - response.writeHead(204).end(); - }); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + receivedWebhookBodies.push(Buffer.concat(chunks).toString("utf8")); + response.writeHead(204).end(); + }); + }); + const address = yield* Effect.callback< + { readonly port: number }, + WorkflowRegistryTestError + >((resume) => { + const onError = (error: Error) => + resume(Effect.fail(new WorkflowRegistryTestError({ message: causeMessage(error) }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const listening = server.address(); + if (listening === null || typeof listening === "string") { + resume( + Effect.fail( + new WorkflowRegistryTestError({ + message: "HTTP server did not expose a TCP port", + }), + ), + ); + return; + } + resume(Effect.succeed({ port: listening.port })); + }); + }); - const address = server.address() as AddressInfo; - const originalFxBaseUrl = process.env.EXCHANGE_RATE_API_BASE_URL; - const originalFxApiKey = process.env.EXCHANGE_RATE_API_KEY; - process.env.EXCHANGE_RATE_API_BASE_URL = `http://127.0.0.1:${address.port}/fx`; - process.env.EXCHANGE_RATE_API_KEY = "integration"; + // oxlint-disable-next-line effect/noGlobals -- test lifecycle fixture: `getSelfhostRuntimeConfig()` below reads the FX endpoint synchronously from the real environment, so the loopback server's port has to be staged in process.env and restored in cleanup; a Config layer would not reach that synchronous read. + const originalFxBaseUrl = process.env.EXCHANGE_RATE_API_BASE_URL; + // oxlint-disable-next-line effect/noGlobals -- test lifecycle fixture: original value captured so cleanup can restore the real environment. + const originalFxApiKey = process.env.EXCHANGE_RATE_API_KEY; + // oxlint-disable-next-line effect/noGlobals -- test lifecycle fixture: points the synchronous selfhost config read at the loopback FX server started above. + process.env.EXCHANGE_RATE_API_BASE_URL = `http://127.0.0.1:${address.port}/fx`; + // oxlint-disable-next-line effect/noGlobals -- test lifecycle fixture: supplies the API key the synchronous selfhost config read expects. + process.env.EXCHANGE_RATE_API_KEY = "integration"; - const config = getSelfhostRuntimeConfig(); - const database = Db.layer(config.database); - const platformDatabase = Db.layer(config.platformDatabase); - const platform = makeSelfhostPlatformLayers(config); - const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); - const workflowInfra = Layer.merge(database, AnalyticsDispatchService.noop); + const config = getSelfhostRuntimeConfig(); + const database = Db.layer(config.database); + const platformDatabase = Db.layer(config.platformDatabase); + const platform = makeSelfhostPlatformLayers(config); + const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); + const workflowInfra = Layer.merge(database, AnalyticsDispatchService.noop); - const cleanupApplicationRows = Effect.gen(function* () { - const db = yield* Db; - yield* db - .delete(webhookDeliveryAttempts) - .where(eq(webhookDeliveryAttempts.webhookDeliveryId, webhookDeliveryId)) - .pipe(Effect.ignore); - yield* db - .delete(webhookDeliveries) - .where(eq(webhookDeliveries.id, webhookDeliveryId)) - .pipe(Effect.ignore); - yield* db - .delete(webhookEndpoints) - .where(eq(webhookEndpoints.id, webhookEndpointId)) - .pipe(Effect.ignore); - yield* db - .delete(paymentProviderNotificationProcessed) - .where(inArray(paymentProviderNotificationProcessed.id, notificationIds)) - .pipe(Effect.ignore); - yield* db.delete(purchaseLedger).where(eq(purchaseLedger.id, ledgerId)).pipe(Effect.ignore); - yield* db - .delete(fxRates) - .where(and(eq(fxRates.currency, FX_CURRENCY), eq(fxRates.asOfDate, FX_AS_OF_DATE))) - .pipe(Effect.ignore); - }).pipe(Effect.provide(database)); + const cleanupApplicationRows = Effect.gen(function* () { + const db = yield* Db; + yield* db + .delete(webhookDeliveryAttempts) + .where(eq(webhookDeliveryAttempts.webhookDeliveryId, webhookDeliveryId)) + .pipe(Effect.ignore); + yield* db + .delete(webhookDeliveries) + .where(eq(webhookDeliveries.id, webhookDeliveryId)) + .pipe(Effect.ignore); + yield* db + .delete(webhookEndpoints) + .where(eq(webhookEndpoints.id, webhookEndpointId)) + .pipe(Effect.ignore); + yield* db + .delete(paymentProviderNotificationProcessed) + .where(inArray(paymentProviderNotificationProcessed.id, notificationIds)) + .pipe(Effect.ignore); + yield* db + .delete(purchaseLedger) + .where(eq(purchaseLedger.id, ledgerId)) + .pipe(Effect.ignore); + yield* db + .delete(fxRates) + .where(and(eq(fxRates.currency, FX_CURRENCY), eq(fxRates.asOfDate, FX_AS_OF_DATE))) + .pipe(Effect.ignore); + }).pipe(Effect.provide(database)); - const cleanupWorkflowRows = Effect.gen(function* () { - const db = yield* Db; - const markerPattern = `%${marker}%`; - yield* db.execute(sql` - DELETE FROM cluster_replies - WHERE request_id IN ( - SELECT request_id FROM cluster_messages - WHERE entity_id IN ( - SELECT entity_id FROM cluster_messages - WHERE tag = 'run' - AND ( - payload::jsonb::text LIKE ${markerPattern} - OR payload::jsonb ->> 'triggeredAt' = ${expireTriggeredAt} + const cleanupWorkflowRows = Effect.gen(function* () { + const db = yield* Db; + const markerPattern = `%${marker}%`; + yield* db.execute(sql` + DELETE FROM cluster_replies + WHERE request_id IN ( + SELECT request_id FROM cluster_messages + WHERE entity_id IN ( + SELECT entity_id FROM cluster_messages + WHERE tag = 'run' + AND ( + payload::jsonb::text LIKE ${markerPattern} + OR payload::jsonb ->> 'triggeredAt' = ${expireTriggeredAt} + ) ) - ) - ) - `); - yield* db.execute(sql` - DELETE FROM cluster_messages - WHERE entity_id IN ( - SELECT entity_id FROM cluster_messages - WHERE tag = 'run' - AND ( - payload::jsonb::text LIKE ${markerPattern} - OR payload::jsonb ->> 'triggeredAt' = ${expireTriggeredAt} ) - ) - `); - }).pipe(Effect.provide(platformDatabase), Effect.ignore); - - try { - await Effect.runPromise(cleanupApplicationRows); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const db = yield* Db; + `); + yield* db.execute(sql` + DELETE FROM cluster_messages + WHERE entity_id IN ( + SELECT entity_id FROM cluster_messages + WHERE tag = 'run' + AND ( + payload::jsonb::text LIKE ${markerPattern} + OR payload::jsonb ->> 'triggeredAt' = ${expireTriggeredAt} + ) + ) + `); + }).pipe(Effect.provide(platformDatabase), Effect.ignore); - yield* db.insert(webhookEndpoints).values({ - events: ["person.created"], - id: webhookEndpointId, - name: "workflow registry integration", - projectId: `${marker}-project`, - secret: "whsec_integration", - url: `http://127.0.0.1:${address.port}/webhook`, - }); - yield* db.insert(webhookDeliveries).values({ - eventOccurredAt: new Date(), - eventType: "person.created", - id: webhookDeliveryId, - payload: { marker }, - projectId: `${marker}-project`, - webhookEndpointId, - }); + const body = Effect.gen(function* () { + yield* cleanupApplicationRows; + yield* Effect.scoped( + Effect.gen(function* () { + const db = yield* Db; - const ledgerRow: InsertPurchaseLedger = { - attemptCount: 0, - claimedAt: null, - claimedBy: null, - eventsPayload: [], - id: ledgerId, - idempotencyKey: `${marker}-ledger-key`, - lastError: null, - nextAttemptAt: null, - organizationId: `${marker}-organization`, - personId: `${marker}-person`, - projectId: `${marker}-project`, - providerEventType: "integration-test", - providerId: "stripe", - publishedAt: null, - rawProviderPayload: null, - resultPayload: {}, - source: "webhook", - status: PurchaseLedgerStatus.Pending, - }; - yield* db.insert(purchaseLedger).values(ledgerRow); + yield* db.insert(webhookEndpoints).values({ + events: ["person.created"], + id: webhookEndpointId, + name: "workflow registry integration", + projectId: `${marker}-project`, + secret: "whsec_integration", + url: `http://127.0.0.1:${address.port}/webhook`, + }); + yield* db.insert(webhookDeliveries).values({ + eventOccurredAt: yield* DateTime.nowAsDate, + eventType: "person.created", + id: webhookDeliveryId, + payload: { marker }, + projectId: `${marker}-project`, + webhookEndpointId, + }); - yield* db.insert(paymentProviderNotificationProcessed).values([ - { - id: expireId, - notificationType: "integration-test", - notificationUuid: `${expireId}-uuid`, - parkedRawPayload: null, - parkedUntilOriginalTransactionId: `${expireId}-original`, - paymentProviderConfigurationId: `${expireId}-config`, - processedAt: new Date(Date.now() - 91 * DAY_MS), - providerId: "apple-app-store", - result: "parked_pending_sdk_confirmation", - source: "webhook", - }, - { - id: appProductId, - notificationType: "integration-test", - notificationUuid: `${appProductId}-uuid`, - parkedRawPayload: null, - parkedUntilProviderProductKey: `${appProductId}-key`, - paymentProviderConfigurationId: `${appProductId}-config`, - providerId: "apple-app-store", - result: "parked_pending_product_mapping", - source: "webhook", - }, - { - id: appSdkId, - notificationType: "integration-test", - notificationUuid: `${appSdkId}-uuid`, - parkedRawPayload: null, - parkedUntilOriginalTransactionId: `${appSdkId}-original`, - paymentProviderConfigurationId: `${appSdkId}-config`, - providerId: "apple-app-store", - result: "parked_pending_sdk_confirmation", - source: "webhook", - }, - { - id: googleId, - notificationType: "integration-test", - notificationUuid: `${googleId}-uuid`, - parkedRawPayload: null, - parkedUntilProviderProductKey: `${googleId}-key`, - paymentProviderConfigurationId: `${googleId}-config`, - providerId: "google-play", - result: "parked_pending_product_mapping", - source: "webhook", - }, - { - id: stripeId, - notificationType: "integration-test", - notificationUuid: `${stripeId}-uuid`, - parkedRawPayload: null, - parkedUntilProviderProductKey: `${stripeId}-key`, - paymentProviderConfigurationId: `${stripeId}-config`, + const ledgerRow: InsertPurchaseLedger = { + attemptCount: 0, + claimedAt: null, + claimedBy: null, + eventsPayload: [], + id: ledgerId, + idempotencyKey: `${marker}-ledger-key`, + lastError: null, + nextAttemptAt: null, + organizationId: `${marker}-organization`, + personId: `${marker}-person`, + projectId: `${marker}-project`, + providerEventType: "integration-test", providerId: "stripe", - result: "parked_pending_product_mapping", + publishedAt: null, + rawProviderPayload: null, + resultPayload: {}, source: "webhook", - }, - ]); + status: PurchaseLedgerStatus.Pending, + }; + yield* db.insert(purchaseLedger).values(ledgerRow); - yield* Effect.forEach( - backendWorkflows, - (registration) => registration.register(workflowInfra), - { discard: true }, - ); + yield* db.insert(paymentProviderNotificationProcessed).values([ + { + id: expireId, + notificationType: "integration-test", + notificationUuid: `${expireId}-uuid`, + parkedRawPayload: null, + parkedUntilOriginalTransactionId: `${expireId}-original`, + paymentProviderConfigurationId: `${expireId}-config`, + processedAt: DateTime.toDateUtc( + DateTime.makeUnsafe((yield* Clock.currentTimeMillis) - 91 * DAY_MS), + ), + providerId: "apple-app-store", + result: "parked_pending_sdk_confirmation", + source: "webhook", + }, + { + id: appProductId, + notificationType: "integration-test", + notificationUuid: `${appProductId}-uuid`, + parkedRawPayload: null, + parkedUntilProviderProductKey: `${appProductId}-key`, + paymentProviderConfigurationId: `${appProductId}-config`, + providerId: "apple-app-store", + result: "parked_pending_product_mapping", + source: "webhook", + }, + { + id: appSdkId, + notificationType: "integration-test", + notificationUuid: `${appSdkId}-uuid`, + parkedRawPayload: null, + parkedUntilOriginalTransactionId: `${appSdkId}-original`, + paymentProviderConfigurationId: `${appSdkId}-config`, + providerId: "apple-app-store", + result: "parked_pending_sdk_confirmation", + source: "webhook", + }, + { + id: googleId, + notificationType: "integration-test", + notificationUuid: `${googleId}-uuid`, + parkedRawPayload: null, + parkedUntilProviderProductKey: `${googleId}-key`, + paymentProviderConfigurationId: `${googleId}-config`, + providerId: "google-play", + result: "parked_pending_product_mapping", + source: "webhook", + }, + { + id: stripeId, + notificationType: "integration-test", + notificationUuid: `${stripeId}-uuid`, + parkedRawPayload: null, + parkedUntilProviderProductKey: `${stripeId}-key`, + paymentProviderConfigurationId: `${stripeId}-config`, + providerId: "stripe", + result: "parked_pending_product_mapping", + source: "webhook", + }, + ]); - const webhookResult = yield* Workflow.execute(DeliverWebhook, { - attemptNumber: 1, - deliveryId: webhookDeliveryId, - endpointId: webhookEndpointId, - eventType: "person.created", - payload: { marker }, - secret: "whsec_integration", - url: `http://127.0.0.1:${address.port}/webhook`, - }); - expect(webhookResult).toBeUndefined(); - expect(receivedWebhookBodies).toEqual([JSON.stringify({ marker })]); - expect( - (yield* db.query.webhookDeliveries.findFirst({ where: { id: webhookDeliveryId } })) - ?.status, - ).toBe(WebhookDeliveryStatus.Succeeded); + yield* Effect.forEach( + backendWorkflows, + (registration) => registration.register(workflowInfra), + { discard: true }, + ); - const fxPayload = { runId: `${marker}-fx` }; - expect(yield* Workflow.execute(FxRateSync, fxPayload)).toEqual({ refreshedCount: 1 }); - expect(yield* Workflow.execute(FxRateSync, fxPayload)).toEqual({ refreshedCount: 1 }); - expect(fxRequests).toBe(1); - expect( - yield* db.query.fxRates.findFirst({ - where: { asOfDate: { eq: FX_AS_OF_DATE }, currency: FX_CURRENCY }, - }), - ).toMatchObject({ - currency: FX_CURRENCY, - source: `exchange-rate-api:latest:${FX_UPDATE_UNIX}`, - usdRate: 500_000, - }); + const webhookResult = yield* Workflow.execute(DeliverWebhook, { + attemptNumber: 1, + deliveryId: webhookDeliveryId, + endpointId: webhookEndpointId, + eventType: "person.created", + payload: { marker }, + secret: "whsec_integration", + url: `http://127.0.0.1:${address.port}/webhook`, + }); + expect(webhookResult).toBeUndefined(); + expect(receivedWebhookBodies).toEqual([encodeJson({ marker })]); + expect( + (yield* db.query.webhookDeliveries.findFirst({ where: { id: webhookDeliveryId } })) + ?.status, + ).toBe(WebhookDeliveryStatus.Succeeded); - const drain = yield* Workflow.execute(PurchaseLedgerDrain, { - runId: `${marker}-drain`, - }); - expect(drain.batches).toBeGreaterThanOrEqual(1); - expect(drain.batches).toBeLessThanOrEqual(10); - expect( - (yield* db.query.purchaseLedger.findFirst({ where: { id: ledgerId } }))?.status, - ).toBe(PurchaseLedgerStatus.Published); + const fxPayload = { runId: `${marker}-fx` }; + expect(yield* Workflow.execute(FxRateSync, fxPayload)).toEqual({ refreshedCount: 1 }); + expect(yield* Workflow.execute(FxRateSync, fxPayload)).toEqual({ refreshedCount: 1 }); + expect(fxRequests).toBe(1); + expect( + yield* db.query.fxRates.findFirst({ + where: { asOfDate: { eq: FX_AS_OF_DATE }, currency: FX_CURRENCY }, + }), + ).toMatchObject({ + currency: FX_CURRENCY, + source: `exchange-rate-api:latest:${FX_UPDATE_UNIX}`, + usdRate: 500_000, + }); - const expiry = yield* Workflow.execute(AppStoreExpireParkedNotifications, { - triggeredAt: expireTriggeredAt, - }); - expect(expiry.expired).toBeGreaterThanOrEqual(1); - expect( - (yield* db.query.paymentProviderNotificationProcessed.findFirst({ - where: { id: expireId }, - }))?.result, - ).toBe("expired"); + const drain = yield* Workflow.execute(PurchaseLedgerDrain, { + runId: `${marker}-drain`, + }); + expect(drain.batches).toBeGreaterThanOrEqual(1); + expect(drain.batches).toBeLessThanOrEqual(10); + expect( + (yield* db.query.purchaseLedger.findFirst({ where: { id: ledgerId } }))?.status, + ).toBe(PurchaseLedgerStatus.Published); - const replayExpected = { appliedCount: 0, failedCount: 1, totalParked: 1 }; - expect( - yield* Workflow.execute(AppStoreReplayParkedNotifications, { - paymentProviderConfigurationId: `${appProductId}-config`, - paymentProviderProductId: `${appProductId}-product`, - providerProductKey: `${appProductId}-key`, - requestedAt: `${marker}-app-product-request`, - }), - ).toEqual(replayExpected); - expect( - yield* Workflow.execute(AppStoreReplayParkedSdkNotifications, { - originalTransactionId: `${appSdkId}-original`, - paymentProviderConfigurationId: `${appSdkId}-config`, - requestedAt: `${marker}-app-sdk-request`, - }), - ).toEqual(replayExpected); - expect( - yield* Workflow.execute(GooglePlayReplayParkedNotifications, { - paymentProviderConfigurationId: `${googleId}-config`, - paymentProviderProductId: `${googleId}-product`, - providerProductKey: `${googleId}-key`, - requestedAt: `${marker}-google-request`, - }), - ).toEqual(replayExpected); - expect( - yield* Workflow.execute(StripeReplayParkedNotifications, { - paymentProviderConfigurationId: `${stripeId}-config`, - paymentProviderProductId: `${stripeId}-product`, - providerProductKey: `${stripeId}-key`, - requestedAt: `${marker}-stripe-request`, - }), - ).toEqual(replayExpected); + const expiry = yield* Workflow.execute(AppStoreExpireParkedNotifications, { + triggeredAt: expireTriggeredAt, + }); + expect(expiry.expired).toBeGreaterThanOrEqual(1); + expect( + (yield* db.query.paymentProviderNotificationProcessed.findFirst({ + where: { id: expireId }, + }))?.result, + ).toBe("expired"); - for (const [id, note] of [ - [appProductId, "parked_raw_payload missing or not a string"], - [appSdkId, "parked_raw_payload missing or not a string"], - [googleId, "parked_raw_payload missing"], - [stripeId, "parked_raw_payload missing or not a string"], - ] as const) { + const replayExpected = { appliedCount: 0, failedCount: 1, totalParked: 1 }; expect( - yield* db.query.paymentProviderNotificationProcessed.findFirst({ - where: { id }, + yield* Workflow.execute(AppStoreReplayParkedNotifications, { + paymentProviderConfigurationId: `${appProductId}-config`, + paymentProviderProductId: `${appProductId}-product`, + providerProductKey: `${appProductId}-key`, + requestedAt: `${marker}-app-product-request`, }), - ).toMatchObject({ - parkedRawPayload: null, - parkedUntilOriginalTransactionId: null, - parkedUntilProviderProductKey: null, - result: "failed", - resultNote: note, - }); - } + ).toEqual(replayExpected); + expect( + yield* Workflow.execute(AppStoreReplayParkedSdkNotifications, { + originalTransactionId: `${appSdkId}-original`, + paymentProviderConfigurationId: `${appSdkId}-config`, + requestedAt: `${marker}-app-sdk-request`, + }), + ).toEqual(replayExpected); + expect( + yield* Workflow.execute(GooglePlayReplayParkedNotifications, { + paymentProviderConfigurationId: `${googleId}-config`, + paymentProviderProductId: `${googleId}-product`, + providerProductKey: `${googleId}-key`, + requestedAt: `${marker}-google-request`, + }), + ).toEqual(replayExpected); + expect( + yield* Workflow.execute(StripeReplayParkedNotifications, { + paymentProviderConfigurationId: `${stripeId}-config`, + paymentProviderProductId: `${stripeId}-product`, + providerProductKey: `${stripeId}-key`, + requestedAt: `${marker}-stripe-request`, + }), + ).toEqual(replayExpected); - const reconcileExit = yield* Effect.exit( - Workflow.execute(AppStoreReconcileOriginalTransaction, { - originalTransactionId: `${marker}-missing-original`, - paymentProviderConfigurationId: `${marker}-missing-config`, - reason: "admin_repair", - triggeredAt: new Date().toISOString(), - }), - ); - expect(Exit.isFailure(reconcileExit)).toBe(true); - }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), - ), - ); - } finally { - await Effect.runPromise(cleanupApplicationRows); - await Effect.runPromise(cleanupWorkflowRows); - if (originalFxBaseUrl === undefined) delete process.env.EXCHANGE_RATE_API_BASE_URL; - else process.env.EXCHANGE_RATE_API_BASE_URL = originalFxBaseUrl; - if (originalFxApiKey === undefined) delete process.env.EXCHANGE_RATE_API_KEY; - else process.env.EXCHANGE_RATE_API_KEY = originalFxApiKey; - await new Promise((resolve) => server.close(() => resolve())); - } - }, 240_000); + for (const [id, note] of constant([ + [appProductId, "parked_raw_payload missing or not a string"], + [appSdkId, "parked_raw_payload missing or not a string"], + [googleId, "parked_raw_payload missing"], + [stripeId, "parked_raw_payload missing or not a string"], + ])) { + expect( + yield* db.query.paymentProviderNotificationProcessed.findFirst({ + where: { id }, + }), + ).toMatchObject({ + parkedRawPayload: null, + parkedUntilOriginalTransactionId: null, + parkedUntilProviderProductKey: null, + result: "failed", + resultNote: note, + }); + } + + const reconcileExit = yield* Effect.exit( + Workflow.execute(AppStoreReconcileOriginalTransaction, { + originalTransactionId: `${marker}-missing-original`, + paymentProviderConfigurationId: `${marker}-missing-config`, + reason: "admin_repair", + triggeredAt: (yield* DateTime.nowAsDate).toISOString(), + }), + ); + expect(Exit.isFailure(reconcileExit)).toBe(true); + }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), + ); + }); + + const cleanup = Effect.gen(function* () { + yield* cleanupApplicationRows; + yield* cleanupWorkflowRows; + // oxlint-disable-next-line effect/noGlobals -- test lifecycle cleanup: restores the real environment the fixture mutated above; no Effect-scoped equivalent exists for a synchronous process.env read. + if (originalFxBaseUrl === undefined) delete process.env.EXCHANGE_RATE_API_BASE_URL; + // oxlint-disable-next-line effect/noGlobals -- test lifecycle cleanup: restores the captured original FX base URL. + else process.env.EXCHANGE_RATE_API_BASE_URL = originalFxBaseUrl; + // oxlint-disable-next-line effect/noGlobals -- test lifecycle cleanup: restores the real environment the fixture mutated above. + if (originalFxApiKey === undefined) delete process.env.EXCHANGE_RATE_API_KEY; + // oxlint-disable-next-line effect/noGlobals -- test lifecycle cleanup: restores the captured original FX API key. + else process.env.EXCHANGE_RATE_API_KEY = originalFxApiKey; + yield* Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + }).pipe(Effect.orDie); + + yield* body.pipe(Effect.ensuring(cleanup)); + }), + ), 240_000); }); diff --git a/apps/backend/tests/Www.test.ts b/apps/backend/tests/Www.test.ts index f47a4df5d..a3fc748b5 100644 --- a/apps/backend/tests/Www.test.ts +++ b/apps/backend/tests/Www.test.ts @@ -1,58 +1,85 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the test stands up a real `node:http` server to receive live requests; an `HttpServer` layer would not exercise the same wire path. import { createServer } from "node:http"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { NodeFileSystem, NodePath } from "@effect/platform-node"; +import { Data, Effect, FileSystem, Layer, Path } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { describe, expect, it } from "vitest"; import { isWwwRequest, makeWwwRequestHandler } from "../src/www/Www.ts"; -const cleanups: Array<() => Promise> = []; +class TestServerAddressError extends Data.TaggedError("TestServerAddressError")<{ + readonly message: string; +}> {} -afterEach(async () => { - await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); -}); +const testServices = Layer.mergeAll( + NodeFileSystem.layer, + NodePath.layer, + FetchHttpClient.layer, +); -const startTestServer = async (clientDirectory: string) => { - const handler = makeWwwRequestHandler({ - clientDirectory, - fetch: (request) => new Response(`SSR ${new URL(request.url).pathname}`, { - headers: { "content-type": "text/plain" }, - }), - }); - const server = createServer((request, response) => { - handler(request, response).catch((error) => { - response.statusCode = 500; - response.end(String(error)); +/** Starts the WWW handler on an ephemeral port; the server closes with the scope. */ +const startTestServer = (clientDirectory: string) => + Effect.gen(function* () { + const handler = makeWwwRequestHandler({ + clientDirectory, + fetch: (request) => + new Response(`SSR ${new URL(request.url).pathname}`, { + headers: { "content-type": "text/plain" }, + }), }); + const server = createServer((request, response) => { + handler(request, response).catch((error: unknown) => { + response.statusCode = 500; + response.end(String(error)); + }); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ); + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* new TestServerAddressError({ + message: "Test server did not expose a TCP address", + }); + } + return `http://127.0.0.1:${address.port}`; }); - await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); - cleanups.push(() => new Promise((resolveClose) => server.close(() => resolveClose()))); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Test server did not expose a TCP address"); - } - return `http://127.0.0.1:${address.port}`; -}; describe("WWW Node handler", () => { - it("serves built assets and falls back to SSR", async () => { - const root = await mkdtemp(join(tmpdir(), "voidhash-www-")); - cleanups.push(() => rm(root, { force: true, recursive: true })); - await mkdir(join(root, "assets")); - await writeFile(join(root, "assets", "app.js"), "export const ready = true;"); - const origin = await startTestServer(root); + it("serves built assets and falls back to SSR", () => + Effect.runPromise( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "voidhash-www-" }); + yield* fileSystem.makeDirectory(path.join(root, "assets")); + yield* fileSystem.writeFileString( + path.join(root, "assets", "app.js"), + "export const ready = true;", + ); + const origin = yield* startTestServer(root); + const client = yield* HttpClient.HttpClient; - const asset = await fetch(`${origin}/assets/app.js`); - expect(asset.status).toBe(200); - expect(asset.headers.get("cache-control")).toContain("immutable"); - expect(asset.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); - expect(await asset.text()).toBe("export const ready = true;"); + const asset = yield* client.get(`${origin}/assets/app.js`); + expect(asset.status).toBe(200); + expect(asset.headers["cache-control"]).toContain("immutable"); + expect(asset.headers["content-type"]).toBe("text/javascript; charset=utf-8"); + const assetBody = yield* asset.text; + expect(assetBody).toBe("export const ready = true;"); - const page = await fetch(`${origin}/studio`); - expect(page.status).toBe(200); - expect(await page.text()).toBe("SSR /studio"); - }); + const page = yield* client.get(`${origin}/studio`); + expect(page.status).toBe(200); + const pageBody = yield* page.text; + expect(pageBody).toBe("SSR /studio"); + }).pipe(Effect.scoped, Effect.provide(testServices)), + )); }); describe("WWW route ownership", () => { diff --git a/apps/cli/build.ts b/apps/cli/build.ts index e0585d706..2870cd720 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -1,3 +1,6 @@ +import { NodeRuntime } from "@effect/platform-node"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Data, Effect } from "effect"; import * as esbuild from "esbuild"; import * as tsup from "tsup"; @@ -21,31 +24,34 @@ esbuild.buildSync({ target: "node16", }); -const main = async () => { - await tsup.build({ - dts: true, - entryPoints: ["./src/index.ts"], - external: ["esbuild"], - format: ["cjs", "esm"], - outDir: "./dist", - outExtension: (ctx) => { - if (ctx.format === "cjs") { +class BuildFailedError extends Data.TaggedError("BuildFailedError")<{ + readonly message: string; +}> {} + +const main = Effect.tryPromise({ + catch: (cause) => new BuildFailedError({ message: causeMessage(cause) }), + try: () => + tsup.build({ + dts: true, + entryPoints: ["./src/index.ts"], + external: ["esbuild"], + format: ["cjs", "esm"], + outDir: "./dist", + outExtension: (ctx) => { + if (ctx.format === "cjs") { + return { + dts: ".d.ts", + js: ".cjs", + }; + } return { - dts: ".d.ts", - js: ".cjs", + dts: ".d.mts", + js: ".mjs", }; - } - return { - dts: ".d.mts", - js: ".mjs", - }; - }, - splitting: false, - }); -}; - -main().catch((error) => { - // User facing console error. - console.error(error); - process.exit(1); + }, + splitting: false, + }), }); + +// runMain reports the failure and exits with a non-zero code. +NodeRuntime.runMain(main); diff --git a/apps/cli/package.json b/apps/cli/package.json index 1e7999396..96480f096 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -27,7 +27,7 @@ "start": "dotenv -- tsx ./src/index.ts", "build": "rm -rf ./dist && tsx build.ts && cp package.json dist/ && chmod +x ./dist/bin.cjs", "build:dev": "rm -rf ./dist && tsx build.dev.ts && chmod +x ./dist/index.cjs", - "dev:set-local-urls": "npx voidhash-cli config set api_url http://localhost:8787 && npx voidhash-cli config set web_url https://localhost:3000", + "dev:set-local-urls": "npx voidhash-cli config set api_url http://localhost:8787 && npx voidhash-cli config set web_url https://voidhash.localhost", "typecheck": "tsgo --noEmit", "test": "vitest run -c vitest.unit.mts", "test:watch": "vitest -c vitest.unit.mts" @@ -36,6 +36,7 @@ "@better-auth/api-key": "catalog:", "@effect/platform-node": "4.0.0-beta.100", "@voidhash/generated-clients": "workspace:*", + "@voidhash/lib": "workspace:*", "@voidhash/shared": "workspace:*", "@voidhash/studio": "workspace:*", "better-auth": "catalog:", diff --git a/apps/cli/src/cli/commands/auth-token.ts b/apps/cli/src/cli/commands/auth-token.ts index ba595f36a..c30beb063 100644 --- a/apps/cli/src/cli/commands/auth-token.ts +++ b/apps/cli/src/cli/commands/auth-token.ts @@ -1,4 +1,4 @@ -import { Console, Effect } from "effect"; +import { Config, Console, Effect, Schema } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { CliConfig } from "../../domain/services/cli-config"; @@ -9,15 +9,24 @@ const projectFlag = Flag.string("project").pipe( Flag.withDefault(""), ); +/** The optional project header, omitted when no project is selected. */ +const projectHeader = (project: string | undefined): Record => { + if (project === undefined || project.length === 0) return {}; + return { "X-Voidhash-Project": project }; +}; + /** Builds the JSON object expected from a Claude Code MCP headers helper. */ export const buildMcpHeaders = ( apiKey: string, project: string | undefined, ): Record => ({ Authorization: `Bearer ${apiKey}`, - ...(project === undefined || project.length === 0 ? {} : { "X-Voidhash-Project": project }), + ...projectHeader(project), }); +/** Serializes the MCP headers object to the JSON printed on stdout. */ +const McpHeadersJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); + /** Prints authenticated MCP request headers without exposing them as arguments. */ export const authTokenCommand = Command.make("token", { project: projectFlag }, ({ project }) => Effect.gen(function* authTokenCommand() { @@ -28,10 +37,18 @@ export const authTokenCommand = Command.make("token", { project: projectFlag }, userError("You must be logged in. Run 'voidhash-cli auth login' first."), ); } - const selectedProject = - project.trim() || - process.env.CLAUDE_PLUGIN_OPTION_PROJECT?.trim() || - process.env.VOIDHASH_PROJECT?.trim(); - yield* Console.log(JSON.stringify(buildMcpHeaders(config.api_key, selectedProject))); + const pluginProject = yield* Config.string("CLAUDE_PLUGIN_OPTION_PROJECT").pipe( + Config.withDefault(""), + Effect.orDie, + ); + const envProject = yield* Config.string("VOIDHASH_PROJECT").pipe( + Config.withDefault(""), + Effect.orDie, + ); + const selectedProject = project.trim() || pluginProject.trim() || envProject.trim(); + const headersJson = yield* Schema.encodeEffect(McpHeadersJson)( + buildMcpHeaders(config.api_key, selectedProject), + ).pipe(Effect.orDie); + yield* Console.log(headersJson); }), ).pipe(Command.withDescription("Print MCP connection headers from the current CLI login.")); diff --git a/apps/cli/src/cli/commands/deploy.ts b/apps/cli/src/cli/commands/deploy.ts index 74a6faa99..0007f6103 100644 --- a/apps/cli/src/cli/commands/deploy.ts +++ b/apps/cli/src/cli/commands/deploy.ts @@ -1,6 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { Console, Effect, Path } from "effect"; +import { Config, Console, Effect, FileSystem, Path, Schema } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { type BuildPaywallsResult, buildPaywalls } from "../../domain/services/paywall-build"; import { @@ -10,8 +8,60 @@ import { import { SourceCode } from "../../domain/services/source-code"; import { userError } from "../../utils/error-formatter"; -const formatBytes = (bytes: number): string => - bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +}; + +/** `", N asset(s)"` for a paywall that ships assets, empty otherwise. */ +const assetsSuffix = (count: number): string => { + if (count > 0) return `, ${count} asset(s)`; + return ""; +}; + +/** `", custom panel"` for a component that emitted a panel bundle. */ +const panelSuffix = (panel: unknown): string => { + if (panel) return ", custom panel"; + return ""; +}; + +/** The fields of the resolved `@voidhash/paywalls` package.json we stamp. */ +const PackageJsonSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), +}); + +/** + * Best-effort: the `@voidhash/paywalls` version the bundle was built against, + * resolved from the user's project. The package's exports map does not expose + * ./package.json, so walk up from the resolved entry. Falls back to + * `"unknown"` whenever the package cannot be resolved or read. + */ +const resolveRuntimeVersion = ( + projectRoot: string, +): Effect.Effect => + Effect.gen(function* resolveRuntimeVersion() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const entry = yield* Effect.try({ + try: () => require.resolve("@voidhash/paywalls", { paths: [projectRoot] }), + catch: (cause) => cause, + }); + + for (let dir = path.dirname(entry); dir !== path.dirname(dir); dir = path.dirname(dir)) { + const pkgPath = path.join(dir, "package.json"); + const exists = yield* fs.exists(pkgPath); + if (!exists) continue; + const pkg = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PackageJsonSchema))( + yield* fs.readFileString(pkgPath), + ); + if (pkg.name === "@voidhash/paywalls" && pkg.version !== undefined) { + return pkg.version; + } + } + return "unknown"; + }).pipe(Effect.orElseSucceed(() => "unknown")); const reportBuild = ({ manifest, outDir, manifestPath }: BuildPaywallsResult) => Effect.gen(function* reportBuild() { @@ -26,7 +76,7 @@ const reportBuild = ({ manifest, outDir, manifestPath }: BuildPaywallsResult) => ` • ${paywall.title} (${paywall.id})\n` + ` hash ${paywall.contentHash.slice(0, 12)}\n` + ` bundle ${formatBytes(size)}` + - (paywall.assets.length ? `, ${paywall.assets.length} asset(s)` : ""), + assetsSuffix(paywall.assets.length), ); } for (const component of manifest.components) { @@ -35,7 +85,7 @@ const reportBuild = ({ manifest, outDir, manifestPath }: BuildPaywallsResult) => ` hash ${component.contentHash.slice(0, 12)}\n` + ` runtime ${formatBytes(component.artifacts.runtime.bytes)}, ` + `${component.previews.length} preview(s)` + - (component.artifacts.panel ? ", custom panel" : ""), + panelSuffix(component.artifacts.panel), ); } yield* Console.log(`\n ${manifest.assets.length} asset(s)`); @@ -95,32 +145,12 @@ export const deployCommand = Command.make( ); const projectRoot = path.resolve("."); - const cliVersion = process.env.VOIDHASH_CLI_VERSION ?? "0.0.0"; - - // Best-effort: stamp the @voidhash/paywalls version the bundle was built - // against, resolved from the user's project. The package's exports map - // does not expose ./package.json, so walk up from the resolved entry. - const runtimeVersion = yield* Effect.try({ - try: () => { - const entry = require.resolve("@voidhash/paywalls", { - paths: [projectRoot], - }); - for (let dir = dirname(entry); dir !== dirname(dir); dir = dirname(dir)) { - const pkgPath = join(dir, "package.json"); - if (existsSync(pkgPath)) { - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { - name?: string; - version?: string; - }; - if (pkg.name === "@voidhash/paywalls" && typeof pkg.version === "string") { - return pkg.version; - } - } - } - throw new Error("@voidhash/paywalls package.json not found"); - }, - catch: (cause) => cause, - }).pipe(Effect.orElseSucceed(() => "unknown")); + const cliVersion = yield* Config.string("VOIDHASH_CLI_VERSION").pipe( + Config.withDefault("0.0.0"), + Effect.orDie, + ); + + const runtimeVersion = yield* resolveRuntimeVersion(projectRoot); yield* Console.log("Building paywalls…"); diff --git a/apps/cli/src/cli/commands/init.ts b/apps/cli/src/cli/commands/init.ts index ede69f50c..c1dcfd62c 100644 --- a/apps/cli/src/cli/commands/init.ts +++ b/apps/cli/src/cli/commands/init.ts @@ -12,6 +12,18 @@ import { assertFileCanBeCreated } from "../../utils/fs"; import { selectOrganization } from "../../utils/organizations/select-organization"; import { selectProject } from "../../utils/projects/select-project"; +/** The config file name matching the project's source language. */ +const configFileNameFor = (language: "ts" | "js"): string => { + if (language === "ts") return "voidhash.config.ts"; + return "voidhash.config.js"; +}; + +/** The scaffolded SDK client file name matching the project's source language. */ +const clientFileNameFor = (language: "ts" | "js"): string => { + if (language === "ts") return "voidhash.ts"; + return "voidhash.js"; +}; + /** * `voidhash-cli init` * @@ -90,12 +102,7 @@ export const initCommand = Command.make("init", {}, () => // Sanity-check that a publishable key exists for this project; we don't // need to write it anywhere (the user puts it in their app code), but a // missing key is a configuration problem we should surface now. - const apiKeys = (yield* apiClient.apiKeysListApiKeys()) as readonly { - id: string; - isPublic: boolean; - projectId: string; - rawKey?: string; - }[]; + const apiKeys = yield* apiClient.apiKeysListApiKeys(); const publishableApiKey = apiKeys.find( (apiKey) => apiKey.isPublic && apiKey.projectId === project.id, ); @@ -109,7 +116,7 @@ export const initCommand = Command.make("init", {}, () => // Decide where the generated `.d.ts` lives. We default to the project // root since module augmentation works from anywhere in `tsconfig.include`. const language = yield* sourceCode.detectSrcLanguage(); - const configFileName = language === "ts" ? "voidhash.config.ts" : "voidhash.config.js"; + const configFileName = configFileNameFor(language); const configFilePath = path.resolve(configFileName); const typesOutputPath = path.resolve(DEFAULT_TYPES_OUTPUT); @@ -117,7 +124,7 @@ export const initCommand = Command.make("init", {}, () => // Scaffold the SDK client into `src/lib` (or `lib` when there's no `src`), // matching the project's `src` layout and language. const srcDir = yield* sourceCode.retrieveSrcDir(); - const clientFileName = language === "ts" ? "voidhash.ts" : "voidhash.js"; + const clientFileName = clientFileNameFor(language); const clientFilePath = path.join(srcDir, "lib", clientFileName); yield* assertFileCanBeCreated(configFileName, configFilePath); diff --git a/apps/cli/src/cli/commands/studio.ts b/apps/cli/src/cli/commands/studio.ts index 881bd6f5b..a7874cfcf 100644 --- a/apps/cli/src/cli/commands/studio.ts +++ b/apps/cli/src/cli/commands/studio.ts @@ -1,44 +1,68 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import { dirname, join } from "node:path"; import { Console, Effect, FileSystem, Path } from "effect"; import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess } from "effect/unstable/process"; import { userError } from "../../utils/error-formatter"; const DEFAULT_PORT = 4830; /** Resolves the installed Studio app directory and the Vite CLI entry point. */ -const resolveStudioPaths = () => - Effect.try({ +const resolveStudioPaths = Effect.gen(function* resolveStudioPaths() { + const path = yield* Path.Path; + return yield* Effect.try({ try: () => { // `require.resolve` works both in the bundled CJS binary and under tsx in // development. We resolve the package manifest to get the app root, and // Vite's own CLI entry so we can launch it without depending on bin // shims being hoisted in any particular way. - const studioDir = dirname(require.resolve("@voidhash/studio/package.json")); + const studioDir = path.dirname(require.resolve("@voidhash/studio/package.json")); // Resolve Vite via its package.json (an exported subpath) from the Studio // package, then join the CLI entry — `vite/bin/vite.js` is not an exported // subpath, so it can't be resolved directly under Node's exports rules. - const viteDir = dirname(require.resolve("vite/package.json", { paths: [studioDir] })); - const viteBin = join(viteDir, "bin", "vite.js"); + const viteDir = path.dirname(require.resolve("vite/package.json", { paths: [studioDir] })); + const viteBin = path.join(viteDir, "bin", "vite.js"); return { studioDir, viteBin }; }, catch: () => userError("Could not locate the Voidhash Studio app. Reinstall the CLI and try again."), }); +}); -/** Best-effort: open the given URL in the user's default browser. */ -const openBrowser = (url: string): void => { - const command = - process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; - const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; - try { - spawn(command, args, { stdio: "ignore", detached: true }).unref(); - } catch { - // Opening the browser is a convenience; never fail the command over it. +/** The platform-specific command that hands a URL to the default browser. */ +const browserOpenCommand = (url: string) => { + if (process.platform === "darwin") { + return ChildProcess.make("open", [url], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + } + if (process.platform === "win32") { + return ChildProcess.make("cmd", ["/c", "start", "", url], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); } + return ChildProcess.make("xdg-open", [url], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); }; +/** Best-effort: open the given URL in the user's default browser. */ +const openBrowser = (url: string) => + Effect.gen(function* openBrowser() { + const child = yield* browserOpenCommand(url); + // Detach the opener so it outlives this command, mirroring `unref()`. + yield* child.unref; + }).pipe( + Effect.scoped, + // Opening the browser is a convenience; never fail the command over it. + Effect.ignore, + ); + /** * `voidhash-cli studio [--port] [--no-open]` * @@ -77,7 +101,7 @@ export const studioCommand = Command.make( ); } - const { studioDir, viteBin } = yield* resolveStudioPaths(); + const { studioDir, viteBin } = yield* resolveStudioPaths; const url = `http://localhost:${port}`; yield* Console.log("\n Voidhash Studio"); @@ -85,31 +109,33 @@ export const studioCommand = Command.make( yield* Console.log(` Preview: ${url}\n`); // Spawn Vite, keep the command alive until the child exits, and ensure the - // child is terminated if the fiber is interrupted (Ctrl+C). - yield* Effect.acquireUseRelease( - Effect.sync(() => - spawn(process.execPath, [viteBin, "--port", String(port), "--strictPort"], { - cwd: studioDir, - env: { ...process.env, VOIDHASH_PROJECT_ROOT: projectRoot }, - stdio: "inherit", - }), - ), - (child: ChildProcess) => { + // child is terminated if the fiber is interrupted (Ctrl+C) — the scope + // finalizer installed by the spawner sends SIGTERM. + yield* Effect.scoped( + Effect.gen(function* runStudio() { + const child = yield* ChildProcess.make( + process.execPath, + [viteBin, "--port", String(port), "--strictPort"], + { + cwd: studioDir, + detached: false, + env: { VOIDHASH_PROJECT_ROOT: projectRoot }, + extendEnv: true, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ); + if (open) { // Give Vite a moment to bind the port before opening the browser. - setTimeout(() => openBrowser(url), 1500); + yield* Effect.forkScoped( + Effect.sleep("1500 millis").pipe(Effect.andThen(openBrowser(url))), + ); } - return Effect.callback((resume) => { - child.on("exit", () => resume(Effect.void)); - child.on("error", (error) => resume(Effect.die(error))); - }); - }, - (child: ChildProcess) => - Effect.sync(() => { - if (child.exitCode === null && !child.killed) { - child.kill("SIGTERM"); - } - }), + + yield* child.exitCode; + }), ); }), ).pipe(Command.withDescription("Launch the paywall preview Studio for this project.")); diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index bf2349d25..79fe0bdc9 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -39,9 +39,12 @@ const cli = Command.run(command, { }); // Apply debug log level if --debug flag is present -const cliEffect = cli.pipe( - isDebugMode() ? Effect.provideService(References.MinimumLogLevel, "Debug") : (x) => x, -); +const withDebugLogLevel = (effect: Effect.Effect): Effect.Effect => { + if (!isDebugMode()) return effect; + return effect.pipe(Effect.provideService(References.MinimumLogLevel, "Debug")); +}; + +const cliEffect = withDebugLogLevel(cli); const ServicesLayer = Layer.mergeAll( SourceCode.Default, diff --git a/apps/cli/src/domain/schema/paywall-deploy.ts b/apps/cli/src/domain/schema/paywall-deploy.ts index 8d6319804..189f6997c 100644 --- a/apps/cli/src/domain/schema/paywall-deploy.ts +++ b/apps/cli/src/domain/schema/paywall-deploy.ts @@ -5,10 +5,11 @@ * `docs/specs/paywall-deploy-contract.md` (§1); these schemas mirror it * exactly and MUST stay in sync. Breaking changes bump the schema version. */ +import { constant } from "@voidhash/lib/lang"; import { Schema } from "effect"; /** Current deploy manifest schema version (contract §1). */ -export const DEPLOY_MANIFEST_VERSION = 2 as const; +export const DEPLOY_MANIFEST_VERSION = constant(2); /** Paywall/component slug shape (contract §1.1). */ export const DEPLOY_SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/; @@ -146,10 +147,10 @@ export const DeployManifestSchema = Schema.Struct({ (manifest: { readonly paywalls: ReadonlyArray; readonly components: ReadonlyArray; - }) => - manifest.paywalls.length > 0 || manifest.components.length > 0 - ? undefined - : "manifest must contain at least one paywall or one component", + }) => { + if (manifest.paywalls.length > 0 || manifest.components.length > 0) return undefined; + return "manifest must contain at least one paywall or one component"; + }, ), ); export type DeployManifest = typeof DeployManifestSchema.Type; diff --git a/apps/cli/src/domain/services/auth.ts b/apps/cli/src/domain/services/auth.ts index 21b8fdf30..86f1727dd 100644 --- a/apps/cli/src/domain/services/auth.ts +++ b/apps/cli/src/domain/services/auth.ts @@ -1,11 +1,11 @@ import { NodeServices, NodeHttpServer } from "@effect/platform-node"; import type { AuthSession200 } from "@voidhash/generated-clients"; -import { Console, Data, Effect, Layer, PubSub, Context } from "effect"; +import { Console, Data, Effect, Layer, Option, PubSub, Context } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { ChildProcess } from "effect/unstable/process"; import { customAlphabet } from "nanoid"; -import { spawn } from "node:child_process"; +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the created server value is handed to the `@effect/platform-node` HTTP adapter, which requires a real `node:http` Server instance. import { createServer } from "node:http"; -import url from "node:url"; import { CONFIG_FILE_NAME } from "../../constants"; import { ApiClient } from "../../utils/api-client"; @@ -66,6 +66,16 @@ const hasNestedTag = ( typeof error.data._tag === "string" && error.data._tag === innerTag; +/** + * Best-effort: hand the confirmation URL to the user's default browser. The + * opener is detached so it outlives the login command, and never fails it. + */ +const openBrowser = (url: string) => + Effect.gen(function* openBrowser() { + const child = yield* ChildProcess.make("open", [url]); + yield* child.unref; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.ignore); + const isNoSignedInUserError = (error: unknown): error is NoSignedInUserError => error instanceof NoSignedInUserError || hasTag(error, "NoSignedInUserError"); @@ -80,10 +90,12 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => "/callback", Effect.gen(function* CallbackRoute() { const req = yield* HttpServerRequest.HttpServerRequest; - const parsedUrl = url.parse(req.url as string, true); - const { query } = parsedUrl; + const query = Option.match(HttpServerRequest.toURL(req), { + onNone: () => new URLSearchParams(), + onSome: (requestUrl) => requestUrl.searchParams, + }); - if (query.cancelled) { + if (query.get("cancelled")) { yield* PubSub.publish(callbackEvents, { type: "cancelled" }); return HttpServerResponse.text("Login cancelled").pipe( HttpServerResponse.setHeader("Access-Control-Allow-Origin", "*"), @@ -92,8 +104,8 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => } yield* PubSub.publish(callbackEvents, { - code: query.code as string, - key: query.key as string, + code: query.get("code") ?? "", + key: query.get("key") ?? "", type: "success", }); return HttpServerResponse.text("Login successful").pipe( @@ -182,10 +194,11 @@ const make = Effect.gen(function* effect() { // Launch the callback server in a separate fiber to avoid blocking yield* Effect.logDebug(`Starting callback server on ${host}:${port}`); yield* Effect.forkChild( - Effect.catch(runCallbackServer(callbackEventsPubSub), (error) => { - console.log(error); - return Effect.die(error); - }), + Effect.catch(runCallbackServer(callbackEventsPubSub), (error) => + Effect.logError(`Callback server failed: ${String(error)}`).pipe( + Effect.andThen(Effect.die(error)), + ), + ), ); // Set up the application server with routing @@ -204,7 +217,7 @@ const make = Effect.gen(function* effect() { yield* Console.log( `If something goes wrong, copy and paste this URL into your browser: ${confirmationUrl.toString()}\n`, ); - spawn("open", [confirmationUrl.toString()]); + yield* openBrowser(confirmationUrl.toString()); // Wait for the callback event yield* Effect.logDebug("Waiting for callback from browser"); diff --git a/apps/cli/src/domain/services/cli-config.ts b/apps/cli/src/domain/services/cli-config.ts index 2827d76a7..fd3b8b655 100644 --- a/apps/cli/src/domain/services/cli-config.ts +++ b/apps/cli/src/domain/services/cli-config.ts @@ -1,3 +1,4 @@ +import { constant } from "@voidhash/lib/lang"; import { Effect, FileSystem, Layer, Path, Schema, Context } from "effect"; import os from "node:os"; @@ -30,6 +31,21 @@ const baseOf = (config: ConfigFile): ResolvedConfig => ({ web_url: config.web_url, }); +/** + * Builds the profile overrides to keep when resetting a profile. Never persists + * `api_key: null` as an override — that would mask the base key. + */ +const preservedOverrides = (apiKey: string | null | undefined): ProfileOverrides => { + if (apiKey) return { api_key: apiKey }; + return {}; +}; + +/** Raw JSON object shape of the config file, before schema decoding. */ +const RawConfigJsonSchema = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); + +/** Encodes a validated config file to the JSON text written to disk. */ +const ConfigFileJsonSchema = Schema.fromJsonString(CliConfigSchema); + const make = Effect.gen(function* effect() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -56,7 +72,7 @@ const make = Effect.gen(function* effect() { return yield* Effect.succeed(emptyConfig); } const configString = yield* fileSystem.readFileString(filePath); - const configJson = JSON.parse(configString); + const configJson = yield* Schema.decodeUnknownEffect(RawConfigJsonSchema)(configString); yield* Effect.logDebug("Config file loaded successfully"); return yield* Schema.decodeUnknownEffect(CliConfigSchema)({ ...emptyConfig, @@ -106,6 +122,23 @@ const make = Effect.gen(function* effect() { * @param config - Partial configuration values to persist. * @returns An Effect that writes the merged configuration to disk. */ + const mergeIntoConfig = ( + currentConfig: ConfigFile, + config: Partial, + ): ConfigFile => { + if (!activeProfile) return { ...currentConfig, ...config }; + return { + ...currentConfig, + profiles: { + ...currentConfig.profiles, + [activeProfile]: { + ...currentConfig.profiles?.[activeProfile], + ...config, + }, + }, + }; + }; + const writeToConfig = (config: Partial) => Effect.gen(function* writeToConfig() { yield* Effect.logDebug(`Writing config to ${filePath}`); @@ -113,21 +146,10 @@ const make = Effect.gen(function* effect() { Effect.catch(() => Effect.succeed(emptyConfig)), ); - const mergedConfig: ConfigFile = activeProfile - ? { - ...currentConfig, - profiles: { - ...currentConfig.profiles, - [activeProfile]: { - ...currentConfig.profiles?.[activeProfile], - ...config, - }, - }, - } - : { ...currentConfig, ...config }; - - const validatedConfig = yield* Schema.decodeUnknownEffect(CliConfigSchema)(mergedConfig); - yield* fileSystem.writeFileString(filePath, JSON.stringify(validatedConfig)); + const mergedConfig = mergeIntoConfig(currentConfig, config); + + const configJson = yield* Schema.encodeEffect(ConfigFileJsonSchema)(mergedConfig); + yield* fileSystem.writeFileString(filePath, configJson); yield* Effect.logDebug("Config file written successfully"); }).pipe(Effect.withSpan("CliConfig.writeToConfig")); @@ -145,16 +167,13 @@ const make = Effect.gen(function* effect() { if (activeProfile) { const raw = yield* readRawConfig(); - const apiKey = raw.profiles?.[activeProfile]?.api_key; - // Never persist `api_key: null` as an override — that would mask the - // base key. Keep only a real, non-null profile key. - const preserved: ProfileOverrides = apiKey ? { api_key: apiKey } : {}; + const preserved = preservedOverrides(raw.profiles?.[activeProfile]?.api_key); const mergedConfig: ConfigFile = { ...baseOf(raw), profiles: { ...raw.profiles, [activeProfile]: preserved }, }; - const validatedConfig = yield* Schema.decodeUnknownEffect(CliConfigSchema)(mergedConfig); - yield* fileSystem.writeFileString(filePath, JSON.stringify(validatedConfig)); + const configJson = yield* Schema.encodeEffect(ConfigFileJsonSchema)(mergedConfig); + yield* fileSystem.writeFileString(filePath, configJson); yield* Effect.logDebug("Config reset complete"); return; } @@ -167,12 +186,12 @@ const make = Effect.gen(function* effect() { yield* Effect.logDebug("Config reset complete"); }).pipe(Effect.withSpan("CliConfig.resetConfig")); - return { + return constant({ readConfig, readRawConfig, resetConfig, writeToConfig, - } as const; + }); }); type CliConfigShape = Effect.Success; diff --git a/apps/cli/src/domain/services/codegen.ts b/apps/cli/src/domain/services/codegen.ts index 09d8f08f9..2e02c8986 100644 --- a/apps/cli/src/domain/services/codegen.ts +++ b/apps/cli/src/domain/services/codegen.ts @@ -1,4 +1,5 @@ -import { Effect, FileSystem, Layer, Path, Context } from "effect"; +import { constant } from "@voidhash/lib/lang"; +import { DateTime, Effect, FileSystem, Layer, Path, Schema, Context } from "effect"; import { VOIDHASH_FETCHED_AT_COMMENT_PREFIX, @@ -9,11 +10,14 @@ import type { Writable } from "../../utils/types"; import type { NormalizedSchema } from "../schema/normalized-schema"; import type { VoidhashConfigSchema } from "../schema/voidhash-config"; +/** Encodes a slug as a quoted TypeScript string literal. */ +const toStringLiteral = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + function toUnionType(slugs: string[]): string { if (slugs.length === 0) { return "never"; } - return slugs.map((slug) => JSON.stringify(slug)).join(" | "); + return slugs.map((slug) => toStringLiteral(slug)).join(" | "); } /** @@ -33,9 +37,9 @@ function toUnionType(slugs: string[]): string { export function generateTypesDeclaration( schema: NormalizedSchema, version: string, - options: { fetchedAt?: Date } = {}, + options: { fetchedAt: Date }, ): string { - const fetchedAt = (options.fetchedAt ?? new Date()).toISOString(); + const fetchedAt = options.fetchedAt.toISOString(); const productSlugs = [...schema.products.keys()].sort(); const locationSlugs = [...schema.locations.keys()].sort(); @@ -126,7 +130,8 @@ const make = Effect.gen(function* effect() { version: string, ) => Effect.gen(function* generateTypesDeclarationFile() { - const content = generateTypesDeclaration(schema, version); + const fetchedAt = yield* DateTime.nowAsDate; + const content = generateTypesDeclaration(schema, version, { fetchedAt }); yield* fileSystem.writeFileString(filePath, content); return version; }); @@ -137,12 +142,12 @@ const make = Effect.gen(function* effect() { return parseVersionFromDeclaration(content); }); - return { + return constant({ generateClientFile, generateTypesDeclarationFile, generateVoidhashConfigFile, readDeclarationVersion, - } as const; + }); }); type CodegenShape = Effect.Success; diff --git a/apps/cli/src/domain/services/paywall-build.ts b/apps/cli/src/domain/services/paywall-build.ts index 67d108e93..1008b4b8f 100644 --- a/apps/cli/src/domain/services/paywall-build.ts +++ b/apps/cli/src/domain/services/paywall-build.ts @@ -6,9 +6,19 @@ * schemaVersion-2 deploy manifest (contract: docs/specs/paywall-deploy-contract.md). */ import { createHash } from "node:crypto"; -import { existsSync, promises as fsp, readdirSync, statSync } from "node:fs"; -import { basename, dirname, extname, join, posix, relative, sep } from "node:path"; -import { Data, Effect, Schema } from "effect"; + +import { causeMessage } from "@voidhash/lib/lang"; +import { + Data, + DateTime, + Effect, + FileSystem, + Path, + type PlatformError, + Schema, + SchemaGetter, + SchemaTransformation, +} from "effect"; import * as esbuild from "esbuild"; import { @@ -33,10 +43,13 @@ export class PaywallBuildError extends Data.TaggedError("PaywallBuildError")<{ }> {} /** Directory (relative to the project root) where build output is written. */ -export const BUILD_DIR = join(".voidhash", ".build"); +export const BUILD_DIR = ".voidhash/.build"; const SOURCE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"]; +/** Separator of the POSIX paths the manifest records. */ +const POSIX_SEP = "/"; + /** * Binary asset types paywall bundles may import; emitted as files. Derived * from the typecheck gate's extension list so the two can never drift. @@ -71,6 +84,42 @@ const CONTENT_TYPES: Record = { const textEncoder = new TextEncoder(); +/** JSON text codec used wherever the build embeds JSON in generated source. */ +const JsonText = Schema.UnknownFromJsonString; + +/** + * JSON text codec for on-disk build artifacts. `space: 2` keeps the emitted + * `manifest.json` / preview trees human-readable, as they were before. + */ +const PrettyJsonText = Schema.String.pipe( + Schema.decodeTo( + Schema.Unknown, + new SchemaTransformation.Transformation( + SchemaGetter.parseJson(), + SchemaGetter.stringifyJson({ space: 2 }), + ), + ), +); + +/** Serializes a value to JSON text, failing instead of throwing. */ +const toJsonText = (subject: string, value: unknown): Effect.Effect => + Schema.encodeEffect(JsonText)(value).pipe( + Effect.mapError( + (cause) => + new PaywallBuildError({ cause, message: `Failed to serialize ${subject}: ${cause.message}` }), + ), + ); + +/** Serializes a value to the JSON bytes written as a build artifact. */ +const toJsonBytes = (subject: string, value: unknown): Effect.Effect => + Schema.encodeEffect(PrettyJsonText)(value).pipe( + Effect.mapError( + (cause) => + new PaywallBuildError({ cause, message: `Failed to serialize ${subject}: ${cause.message}` }), + ), + Effect.map((json) => textEncoder.encode(`${json}\n`)), + ); + /** Lowercase hex SHA-256 of a string or byte payload. */ export const sha256Hex = (data: Uint8Array | string): string => createHash("sha256").update(data).digest("hex"); @@ -103,12 +152,12 @@ export const computeComponentContentHash = (input: { }:${[...input.previewSha256s].sort().join(":")}`, ); -const contentTypeFor = (path: string): string => - CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream"; +const contentTypeFor = (path: Path.Path, file: string): string => + CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream"; /** Normalizes an absolute path to a project-root-relative POSIX path. */ -const toRelPosix = (projectRoot: string, abs: string): string => - relative(projectRoot, abs).split(sep).join(posix.sep); +const toRelPosix = (path: Path.Path, projectRoot: string, abs: string): string => + path.relative(projectRoot, abs).split(path.sep).join(POSIX_SEP); // Discovery (isSourceFile / idFromFile / listFilesRecursive) is mirrored by // Studio's virtual-paywalls plugin @@ -116,52 +165,110 @@ const toRelPosix = (projectRoot: string, abs: string): string => const isSourceFile = (name: string): boolean => SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !name.endsWith(".d.ts"); -const idFromFile = (file: string): string => basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); +const idFromFile = (path: Path.Path, file: string): string => + path.basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); /** Recursively lists files under a directory (absolute paths). */ -const listFilesRecursive = (dir: string): string[] => { - if (!existsSync(dir)) return []; - const out: string[] = []; - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) { - out.push(...listFilesRecursive(full)); - } else { +const listFilesRecursive: ( + fs: FileSystem.FileSystem, + path: Path.Path, + dir: string, +) => Effect.Effect, PlatformError.PlatformError> = (fs, path, dir) => + Effect.gen(function* listDirectory() { + const exists = yield* fs.exists(dir); + if (!exists) return []; + const out: Array = []; + for (const entry of yield* fs.readDirectory(dir)) { + const full = path.join(dir, entry); + const info = yield* fs.stat(full); + if (info.type === "Directory") { + out.push(...(yield* listFilesRecursive(fs, path, full))); + continue; + } out.push(full); } - } - return out; -}; + return out; + }); -const listSourceFiles = (dir: string): string[] => - listFilesRecursive(dir).filter((f) => isSourceFile(basename(f))); +const listSourceFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + dir: string, +): Effect.Effect, PaywallBuildError> => + listFilesRecursive(fs, path, dir).pipe( + Effect.map((files) => files.filter((f) => isSourceFile(path.basename(f)))), + Effect.mapError((cause) => new PaywallBuildError({ cause, message: `Failed to scan ${dir}` })), + ); /** Turns an esbuild failure into a readable, file-located error message. */ const describeEsbuildFailure = (cause: unknown): string | undefined => { - if ( - typeof cause === "object" && - cause !== null && - "errors" in cause && - Array.isArray((cause as esbuild.BuildFailure).errors) - ) { - return (cause as esbuild.BuildFailure).errors - .map((error) => { - const location = error.location - ? `${error.location.file}:${error.location.line}:${error.location.column}: ` - : ""; - return ` ${location}${error.text}`; - }) - .join("\n"); + if (typeof cause !== "object" || cause === null || !("errors" in cause)) { + return; } - return; + const errors = cause.errors; + if (!Array.isArray(errors)) { + return; + } + return errors + .map((error: esbuild.Message) => { + if (error.location) { + return ` ${error.location.file}:${error.location.line}:${error.location.column}: ${error.text}`; + } + return ` ${error.text}`; + }) + .join("\n"); }; const bundleFailure = (subject: string) => (cause: unknown) => { const details = describeEsbuildFailure(cause); - return new PaywallBuildError({ - cause, - message: details ? `Failed to bundle ${subject}:\n${details}` : `Failed to bundle ${subject}`, - }); + if (details) { + return new PaywallBuildError({ cause, message: `Failed to bundle ${subject}:\n${details}` }); + } + return new PaywallBuildError({ cause, message: `Failed to bundle ${subject}` }); +}; + +// ── Untyped module reading ─────────────────────────────────────────────────── +// +// Paywall/component modules are user code loaded at runtime, so every property +// read off them goes through these guards rather than a type assertion. + +const readProperty = (value: unknown, key: string): unknown => { + if (typeof value !== "object" || value === null) return undefined; + if (!(key in value)) return undefined; + return Reflect.get(value, key); +}; + +const entriesOf = (value: unknown): Array<[string, unknown]> => { + if (typeof value !== "object" || value === null) return []; + return Object.entries(value); +}; + +const recordOf = (value: unknown): Record => Object.fromEntries(entriesOf(value)); + +const readOptionalString = (value: unknown): string | undefined => { + if (typeof value === "string") return value; + return undefined; +}; + +const readNonEmptyString = (value: unknown): string | undefined => { + if (typeof value === "string" && value.length > 0) return value; + return undefined; +}; + +const readStringArray = (value: unknown): Array => { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +}; + +const readArray = (value: unknown): ReadonlyArray => { + if (Array.isArray(value)) return value; + return []; +}; + +/** `": "` for an `Error` cause, a bare `"."` otherwise. */ +const manifestFailureSuffix = (cause: unknown): string => { + if (cause instanceof Error) return `: ${cause.message}`; + return "."; }; // ── User-project library access ────────────────────────────────────────────── @@ -183,9 +290,9 @@ interface UserTreeLib { readonly config?: { readonly products?: ReadonlyArray; readonly variables?: Record; - readonly platform?: "ios" | "android" | "web"; - readonly safeAreaInsets?: ComponentPreviewSafeAreaInsets; - readonly dimensions?: ComponentPreviewDimensions; + readonly platform?: unknown; + readonly safeAreaInsets?: unknown; + readonly dimensions?: unknown; }; readonly state?: string; }, @@ -196,55 +303,26 @@ interface UserReactLib { readonly createElement: (type: unknown, props: Record | null) => unknown; } -interface ComponentPreviewStateLike { - readonly props?: Record; - readonly data?: { - readonly products?: ReadonlyArray; - readonly variables?: Record; - readonly platform?: "ios" | "android" | "web"; - readonly safeAreaInsets?: ComponentPreviewSafeAreaInsets; - readonly dimensions?: ComponentPreviewDimensions; - }; -} - -interface ComponentPreviewSafeAreaInsets { - readonly top: number; - readonly right: number; - readonly bottom: number; - readonly left: number; -} - -interface ComponentPreviewDimensions { - readonly screen: { - readonly width: number; - readonly height: number; - readonly x: number; - readonly y: number; - }; - readonly window: { - readonly width: number; - readonly height: number; - readonly x: number; - readonly y: number; - }; -} - -interface ComponentDefinitionLike { +type ComponentDefinitionLike = Record & { readonly id: string; readonly title?: string; readonly description?: string; - readonly previews: Record; + readonly previews: Record; readonly panel?: unknown; readonly component: unknown; readonly __voidhash: { readonly kind: string }; -} +}; const requireFromProject = ( projectRoot: string, specifier: string, ): Effect.Effect => Effect.try({ - try: () => require(require.resolve(specifier, { paths: [projectRoot] })) as T, + try: () => { + // oxlint-disable-next-line effect/noDynamicImports -- the specifier is resolved out of the end user's project root at runtime (require.resolve with `paths`), so it cannot be a static import in the CLI bundle. + const loaded: T = require(require.resolve(specifier, { paths: [projectRoot] })); + return loaded; + }, catch: (cause) => new PaywallBuildError({ cause, @@ -260,12 +338,12 @@ const requireFromProject = ( * and preview rendering. The shared `safeRegister` helper uses the `ts` * loader, which rejects JSX — hence a dedicated hook here. */ +const loadEsbuildRegister = () => import("esbuild-register/dist/node"); + const registerTsxLoader = (): Effect.Effect<{ unregister: () => void }, PaywallBuildError> => Effect.tryPromise({ - try: async () => { - const { register } = await import("esbuild-register/dist/node"); - return register({ format: "cjs", loader: "tsx" }); - }, + try: () => + loadEsbuildRegister().then(({ register }) => register({ format: "cjs", loader: "tsx" })), catch: (cause) => new PaywallBuildError({ cause, @@ -277,15 +355,14 @@ const loadModuleDefault = (file: string): Effect.Effect { delete require.cache[require.resolve(file)]; - const mod = require(file) as { default?: unknown }; + // oxlint-disable-next-line effect/noDynamicImports -- CJS require paired with the require.cache eviction above, so a rebuilt paywall module is re-read within the same CLI process; a static import would stay cached for the process lifetime. + const mod: { default?: unknown } = require(file); return mod?.default ?? mod; }, catch: (cause) => new PaywallBuildError({ cause, - message: `Failed to load ${file}: ${ - cause instanceof Error ? cause.message : String(cause) - }`, + message: `Failed to load ${file}: ${causeMessage(cause)}`, }), }); @@ -300,34 +377,30 @@ interface PaywallModuleMeta { } /** Reads the `__voidhash` metadata off a paywall module's default export. */ -const loadPaywallMeta = (file: string): Effect.Effect => +const loadPaywallMeta = ( + path: Path.Path, + file: string, +): Effect.Effect => loadModuleDefault(file).pipe( Effect.flatMap((def) => { - const meta = (def as { __voidhash?: Record } | null | undefined)?.__voidhash; - if (!meta || meta.kind !== "paywall") { + const meta = readProperty(def, "__voidhash"); + if (meta === undefined || readProperty(meta, "kind") !== "paywall") { return Effect.fail( new PaywallBuildError({ message: `${file} must default-export createPaywall({ … }) from "@voidhash/paywalls".`, }), ); } - const title = - typeof meta.title === "string" && meta.title.length > 0 ? meta.title : idFromFile(file); - const description = typeof meta.description === "string" ? meta.description : undefined; - const products = Array.isArray(meta.products) - ? meta.products.filter((p): p is string => typeof p === "string") - : []; - const rawVariables = - typeof meta.variables === "object" && meta.variables !== null - ? (meta.variables as Record) - : {}; + const title = readNonEmptyString(readProperty(meta, "title")) ?? idFromFile(path, file); + const description = readOptionalString(readProperty(meta, "description")); + const products = readStringArray(readProperty(meta, "products")); const variables: Record = {}; - for (const [key, value] of Object.entries(rawVariables)) { + for (const [key, value] of entriesOf(readProperty(meta, "variables"))) { if (!isScalar(value)) { return Effect.fail( new PaywallBuildError({ message: - `Variable "${key}" of paywall ${basename(file)} must be a ` + + `Variable "${key}" of paywall ${path.basename(file)} must be a ` + "string, number or boolean (contract §1.1).", }), ); @@ -345,81 +418,90 @@ const loadPaywallMeta = (file: string): Effect.Effect => loadModuleDefault(file).pipe( Effect.flatMap((def) => { - const candidate = def as Partial | null; - if ( - !candidate || - candidate.__voidhash?.kind !== "component" || - typeof candidate.component !== "function" || - typeof candidate.id !== "string" - ) { + const kind = readProperty(readProperty(def, "__voidhash"), "kind"); + const component = readProperty(def, "component"); + const id = readProperty(def, "id"); + if (kind !== "component" || typeof component !== "function" || typeof id !== "string") { return Effect.fail( new PaywallBuildError({ message: `${file} must default-export defineComponent({ … }) from "@voidhash/paywalls".`, }), ); } - const expectedId = idFromFile(file); - if (candidate.id !== expectedId) { + const expectedId = idFromFile(path, file); + if (id !== expectedId) { return Effect.fail( new PaywallBuildError({ message: - `Component id "${candidate.id}" does not match its file name ` + - `"${expectedId}" (${basename(file)}). Rename the file or the id.`, + `Component id "${id}" does not match its file name ` + + `"${expectedId}" (${path.basename(file)}). Rename the file or the id.`, }), ); } - return Effect.succeed({ - ...candidate, - previews: candidate.previews ?? {}, - } as ComponentDefinitionLike); + // The whole default export is handed to `extractComponentManifest`, so + // every own property is carried over, not just the ones read here. + return Effect.succeed({ + ...recordOf(def), + __voidhash: { kind }, + component, + description: readOptionalString(readProperty(def, "description")), + id, + panel: readProperty(def, "panel"), + previews: recordOf(readProperty(def, "previews")), + title: readOptionalString(readProperty(def, "title")), + }); }), ); // ── Output writing ─────────────────────────────────────────────────────────── -const writeFile = (absPath: string, bytes: Uint8Array): Effect.Effect => - Effect.tryPromise({ - try: async () => { - await fsp.mkdir(dirname(absPath), { recursive: true }); - await fsp.writeFile(absPath, bytes); - }, - catch: (cause) => new PaywallBuildError({ cause, message: `Failed to write ${absPath}` }), - }); +const writeFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + absPath: string, + bytes: Uint8Array, +): Effect.Effect => + fs.makeDirectory(path.dirname(absPath), { recursive: true }).pipe( + Effect.andThen(() => fs.writeFile(absPath, bytes)), + Effect.mapError((cause) => new PaywallBuildError({ cause, message: `Failed to write ${absPath}` })), + ); /** Writes `bytes` to `absPath` and returns its manifest artifact entry. */ const writeArtifact = ( + fs: FileSystem.FileSystem, + path: Path.Path, projectRoot: string, absPath: string, bytes: Uint8Array, ): Effect.Effect => - writeFile(absPath, bytes).pipe( + writeFile(fs, path, absPath, bytes).pipe( Effect.map(() => ({ bytes: bytes.byteLength, - contentType: contentTypeFor(absPath), - path: toRelPosix(projectRoot, absPath), + contentType: contentTypeFor(path, absPath), + path: toRelPosix(path, projectRoot, absPath), sha256: sha256Hex(bytes), })), ); const readDeployFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, projectRoot: string, absPath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const bytes = await fsp.readFile(absPath); - return { - bytes: bytes.byteLength, - path: toRelPosix(projectRoot, absPath), - sha256: sha256Hex(bytes), - }; - }, - catch: (cause) => new PaywallBuildError({ cause, message: `Failed to read ${absPath}` }), - }); + fs.readFile(absPath).pipe( + Effect.map((bytes) => ({ + bytes: bytes.byteLength, + path: toRelPosix(path, projectRoot, absPath), + sha256: sha256Hex(bytes), + })), + Effect.mapError((cause) => new PaywallBuildError({ cause, message: `Failed to read ${absPath}` })), + ); // ── Paywall bundling ───────────────────────────────────────────────────────── @@ -460,8 +542,8 @@ const htmlShell = (jsFileName: string): string => `; /** The in-memory entry esbuild bundles for a paywall. */ -const paywallEntryContents = (paywallAbsPath: string): string => - `import paywall from ${JSON.stringify(paywallAbsPath)}; +const paywallEntryContents = (paywallModuleSpecifier: string): string => + `import paywall from ${paywallModuleSpecifier}; import { mountPaywall } from "@voidhash/paywalls/dom"; const root = document.getElementById("root"); if (root) mountPaywall(paywall, root); @@ -474,66 +556,78 @@ interface BuiltPaywallArtifacts { readonly assets: ReadonlyArray<{ relName: string; bytes: Uint8Array }>; } +/** The `assets/…` name an emitted esbuild output file keeps in the bundle. */ +const assetRelName = (rel: string): string => { + const idx = rel.indexOf(`${POSIX_SEP}assets${POSIX_SEP}`); + if (idx >= 0) return rel.slice(idx + 1); + return rel.slice(rel.lastIndexOf(POSIX_SEP) + 1); +}; + /** Bundles a single paywall to HTML + JS (+ assets) in memory via esbuild. */ const bundlePaywall = ( + path: Path.Path, projectRoot: string, voidhashDir: string, paywallAbsPath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const result = await esbuild.build({ - assetNames: "assets/[name]-[hash]", - bundle: true, - define: { "process.env.NODE_ENV": '"production"' }, - format: "iife", - jsx: "automatic", - jsxImportSource: "react", - loader: PAYWALL_ASSET_LOADERS, - logLevel: "silent", - minify: true, - outdir: "out", - platform: "browser", - plugins: [closedImportsPlugin(voidhashDir)], - publicPath: ".", - stdin: { - contents: paywallEntryContents(paywallAbsPath), - loader: "tsx", - resolveDir: projectRoot, - sourcefile: "voidhash-entry.tsx", - }, - target: ["es2019", "safari13"], - write: false, - }); + Effect.gen(function* bundlePaywall() { + const subject = `paywall ${path.basename(paywallAbsPath)}`; + const specifier = yield* toJsonText("the paywall entry point", paywallAbsPath); + + const result = yield* Effect.tryPromise({ + try: () => + esbuild.build({ + assetNames: "assets/[name]-[hash]", + bundle: true, + define: { "process.env.NODE_ENV": '"production"' }, + format: "iife", + jsx: "automatic", + jsxImportSource: "react", + loader: PAYWALL_ASSET_LOADERS, + logLevel: "silent", + minify: true, + outdir: "out", + platform: "browser", + plugins: [closedImportsPlugin(voidhashDir)], + publicPath: ".", + stdin: { + contents: paywallEntryContents(specifier), + loader: "tsx", + resolveDir: projectRoot, + sourcefile: "voidhash-entry.tsx", + }, + target: ["es2019", "safari13"], + write: false, + }), + catch: bundleFailure(subject), + }); - let jsBytes: Uint8Array | undefined; - const assets: Array<{ relName: string; bytes: Uint8Array }> = []; - - for (const file of result.outputFiles) { - const rel = file.path.split(sep).join(posix.sep); - if (rel.endsWith(".js")) { - jsBytes = file.contents; - } else { - // Asset emitted under out/assets/… — keep the assets/… suffix. - const idx = rel.indexOf("/assets/"); - const relName = idx >= 0 ? rel.slice(idx + 1) : posix.basename(rel); - assets.push({ bytes: file.contents, relName }); - } - } + let jsBytes: Uint8Array | undefined; + const assets: Array<{ relName: string; bytes: Uint8Array }> = []; - if (!jsBytes) { - throw new Error("esbuild produced no JavaScript output"); + for (const file of result.outputFiles) { + const rel = file.path.split(path.sep).join(POSIX_SEP); + if (rel.endsWith(".js")) { + jsBytes = file.contents; + continue; } + // Asset emitted under out/assets/… — keep the assets/… suffix. + assets.push({ bytes: file.contents, relName: assetRelName(rel) }); + } - const jsFileName = "bundle.js"; - return { - assets, - htmlBytes: textEncoder.encode(htmlShell(jsFileName)), - jsBytes, - jsFileName, - }; - }, - catch: bundleFailure(`paywall ${basename(paywallAbsPath)}`), + if (!jsBytes) { + return yield* new PaywallBuildError({ + message: `Failed to bundle ${subject}: esbuild produced no JavaScript output`, + }); + } + + const jsFileName = "bundle.js"; + return { + assets, + htmlBytes: textEncoder.encode(htmlShell(jsFileName)), + jsBytes, + jsFileName, + }; }); // ── Component bundling ─────────────────────────────────────────────────────── @@ -619,29 +713,38 @@ const panelBuildOptions = (voidhashDir: string): esbuild.BuildOptions => ({ export const definitionHasPanel = (definition: { readonly panel?: unknown }): boolean => typeof definition.panel === "function"; -const firstJsOutput = (result: esbuild.BuildResult): Uint8Array => { - const file = (result.outputFiles ?? []).find((f) => f.path.endsWith(".js")); - if (!file) { - throw new Error("esbuild produced no JavaScript output"); - } - return file.contents; -}; +const firstJsOutput = (result: esbuild.BuildResult): Uint8Array | undefined => + (result.outputFiles ?? []).find((f) => f.path.endsWith(".js"))?.contents; + +/** Runs an esbuild bundle and returns its single JavaScript output. */ +const bundleSingleJs = ( + subject: string, + options: esbuild.BuildOptions, +): Effect.Effect => + Effect.gen(function* bundleSingleJs() { + const result = yield* Effect.tryPromise({ + try: () => esbuild.build(options), + catch: bundleFailure(subject), + }); + const bytes = firstJsOutput(result); + if (bytes === undefined) { + return yield* new PaywallBuildError({ + message: `Failed to bundle ${subject}: esbuild produced no JavaScript output`, + }); + } + return bytes; + }); /** Bundles a component module to a single ESM `runtime.js`. */ const bundleComponentRuntime = ( + path: Path.Path, voidhashDir: string, componentAbsPath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => - firstJsOutput( - await esbuild.build({ - ...componentBuildOptions(voidhashDir), - entryPoints: [componentAbsPath], - outdir: "out", - }), - ), - catch: bundleFailure(`component ${basename(componentAbsPath)}`), + bundleSingleJs(`component ${path.basename(componentAbsPath)}`, { + ...componentBuildOptions(voidhashDir), + entryPoints: [componentAbsPath], + outdir: "out", }); /** @@ -655,19 +758,14 @@ const bundleComponentRuntime = ( * externals) so the byte output matches the sandbox's require shim exactly. */ const bundleComponentPanel = ( + path: Path.Path, voidhashDir: string, componentAbsPath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => - firstJsOutput( - await esbuild.build({ - ...panelBuildOptions(voidhashDir), - entryPoints: [componentAbsPath], - outdir: "out", - }), - ), - catch: bundleFailure(`panel of component ${basename(componentAbsPath)}`), + bundleSingleJs(`panel of component ${path.basename(componentAbsPath)}`, { + ...panelBuildOptions(voidhashDir), + entryPoints: [componentAbsPath], + outdir: "out", }); // ── Preview tree inspection ────────────────────────────────────────────────── @@ -712,18 +810,19 @@ export const collectRenderErrorPlaceholderReasons = (tree: unknown): string[] => // ── Validation helpers ─────────────────────────────────────────────────────── const validateIds = ( + path: Path.Path, kind: "paywall" | "component", files: ReadonlyArray, ): Effect.Effect => Effect.gen(function* validateIds() { const seen = new Map(); for (const file of files) { - const id = idFromFile(file); + const id = idFromFile(path, file); if (!DEPLOY_SLUG_REGEX.test(id)) { return yield* Effect.fail( new PaywallBuildError({ message: - `Invalid ${kind} id "${id}" (${basename(file)}). Ids derive ` + + `Invalid ${kind} id "${id}" (${path.basename(file)}). Ids derive ` + `from file names and must match ${DEPLOY_SLUG_REGEX}.`, }), ); @@ -782,16 +881,26 @@ export const buildPaywalls = ({ cliVersion, runtimeVersion, onWarn, -}: BuildPaywallsOptions): Effect.Effect => +}: BuildPaywallsOptions): Effect.Effect< + BuildPaywallsResult, + PaywallBuildError, + FileSystem.FileSystem | Path.Path +> => Effect.gen(function* buildPaywalls() { - const warn = (message: string): Effect.Effect => (onWarn ? onWarn(message) : Effect.void); - const voidhashDir = join(projectRoot, ".voidhash"); - const paywallsDir = join(voidhashDir, "paywalls"); - const componentsDir = join(voidhashDir, "components"); - const outDir = join(projectRoot, BUILD_DIR); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - const paywallFiles = listSourceFiles(paywallsDir); - const componentFiles = listSourceFiles(componentsDir); + const warn = (message: string): Effect.Effect => { + if (onWarn) return onWarn(message); + return Effect.void; + }; + const voidhashDir = path.join(projectRoot, ".voidhash"); + const paywallsDir = path.join(voidhashDir, "paywalls"); + const componentsDir = path.join(voidhashDir, "components"); + const outDir = path.join(projectRoot, BUILD_DIR); + + const paywallFiles = yield* listSourceFiles(fs, path, paywallsDir); + const componentFiles = yield* listSourceFiles(fs, path, componentsDir); if (paywallFiles.length === 0 && componentFiles.length === 0) { return yield* Effect.fail( @@ -801,8 +910,8 @@ export const buildPaywalls = ({ ); } - yield* validateIds("paywall", paywallFiles); - yield* validateIds("component", componentFiles); + yield* validateIds(path, "paywall", paywallFiles); + yield* validateIds(path, "component", componentFiles); // Typecheck gate: fail fast, before any bundling. yield* typecheckPaywallSources({ @@ -815,10 +924,13 @@ export const buildPaywalls = ({ ); // Clear any previous build so removed paywalls/components don't linger. - yield* Effect.tryPromise({ - try: () => fsp.rm(outDir, { force: true, recursive: true }), - catch: (cause) => new PaywallBuildError({ cause, message: "Failed to clean build dir" }), - }); + yield* fs + .remove(outDir, { force: true, recursive: true }) + .pipe( + Effect.mapError( + (cause) => new PaywallBuildError({ cause, message: "Failed to clean build dir" }), + ), + ); // Register esbuild so we can `require` paywall/component modules (JSX) to // read metadata and render preview trees. @@ -830,27 +942,33 @@ export const buildPaywalls = ({ const paywalls: DeployPaywall[] = []; for (const file of paywallFiles) { - const id = idFromFile(file); - const meta = yield* loadPaywallMeta(file); - const built = yield* bundlePaywall(projectRoot, voidhashDir, file); + const id = idFromFile(path, file); + const meta = yield* loadPaywallMeta(path, file); + const built = yield* bundlePaywall(path, projectRoot, voidhashDir, file); - const paywallOutDir = join(outDir, "paywalls", id); + const paywallOutDir = path.join(outDir, "paywalls", id); const html = yield* writeArtifact( + fs, + path, projectRoot, - join(paywallOutDir, "index.html"), + path.join(paywallOutDir, "index.html"), built.htmlBytes, ); const js = yield* writeArtifact( + fs, + path, projectRoot, - join(paywallOutDir, built.jsFileName), + path.join(paywallOutDir, built.jsFileName), built.jsBytes, ); const referencedAssets: string[] = []; for (const asset of built.assets) { const deployAsset = yield* writeArtifact( + fs, + path, projectRoot, - join(paywallOutDir, asset.relName), + path.join(paywallOutDir, asset.relName), asset.bytes, ); assetIndex.set(deployAsset.path, deployAsset); @@ -858,13 +976,13 @@ export const buildPaywalls = ({ } referencedAssets.sort(); - const source = yield* readDeployFile(projectRoot, file); + const source = yield* readDeployFile(fs, path, projectRoot, file); paywalls.push({ artifacts: { html, js }, assets: referencedAssets, contentHash: computePaywallContentHash({ - assetSha256s: referencedAssets.map((path) => assetIndex.get(path)?.sha256 ?? ""), + assetSha256s: referencedAssets.map((assetPath) => assetIndex.get(assetPath)?.sha256 ?? ""), htmlSha256: html.sha256, jsSha256: js.sha256, }), @@ -893,9 +1011,9 @@ export const buildPaywalls = ({ const react = yield* requireFromProject(projectRoot, "react"); for (const file of componentFiles) { - const id = idFromFile(file); - const definition = yield* loadComponentDefinition(file); - const componentOutDir = join(outDir, "components", id); + const id = idFromFile(path, file); + const definition = yield* loadComponentDefinition(path, file); + const componentOutDir = path.join(outDir, "components", id); // §2 component manifest. const manifestJson = yield* Effect.try({ @@ -905,34 +1023,37 @@ export const buildPaywalls = ({ cause, message: `Failed to extract the manifest of component "${id}"` + - (cause instanceof Error ? `: ${cause.message}` : "."), + manifestFailureSuffix(cause), }), }); const manifest = yield* writeArtifact( + fs, + path, projectRoot, - join(componentOutDir, "manifest.json"), - textEncoder.encode(`${JSON.stringify(manifestJson, null, 2)}\n`), + path.join(componentOutDir, "manifest.json"), + yield* toJsonBytes(`the manifest of component "${id}"`, manifestJson), ); // §3 preview trees — one per declared state, always including // "default" (rendered with prop defaults when not declared). - const previewStates: Record = { + const previewStates: Record = { default: definition.previews.default ?? {}, ...definition.previews, }; const previews: DeployComponentPreview[] = []; for (const [state, preview] of Object.entries(previewStates)) { + const data = readProperty(preview, "data"); const tree = yield* Effect.tryPromise({ try: () => treeLib.renderToNodeTree( - react.createElement(definition.component, preview.props ?? {}), + react.createElement(definition.component, recordOf(readProperty(preview, "props"))), { config: { - products: preview.data?.products ?? [], - variables: preview.data?.variables ?? {}, - platform: preview.data?.platform, - safeAreaInsets: preview.data?.safeAreaInsets, - dimensions: preview.data?.dimensions, + products: readArray(readProperty(data, "products")), + variables: recordOf(readProperty(data, "variables")), + platform: readProperty(data, "platform"), + safeAreaInsets: readProperty(data, "safeAreaInsets"), + dimensions: readProperty(data, "dimensions"), }, state, }, @@ -954,18 +1075,22 @@ export const buildPaywalls = ({ } const previewFile = yield* writeArtifact( + fs, + path, projectRoot, - join(componentOutDir, "previews", `${state}.json`), - textEncoder.encode(`${JSON.stringify(tree, null, 2)}\n`), + path.join(componentOutDir, "previews", `${state}.json`), + yield* toJsonBytes(`preview "${state}" of component "${id}"`, tree), ); previews.push({ file: previewFile, state }); } // Runtime bundle (and panel bundle, when declared). - const runtimeBytes = yield* bundleComponentRuntime(voidhashDir, file); + const runtimeBytes = yield* bundleComponentRuntime(path, voidhashDir, file); const runtime = yield* writeArtifact( + fs, + path, projectRoot, - join(componentOutDir, "runtime.js"), + path.join(componentOutDir, "runtime.js"), runtimeBytes, ); @@ -974,11 +1099,17 @@ export const buildPaywalls = ({ // per the reserved `artifacts.panel` contract field. let panel: DeployArtifact | null = null; if (definitionHasPanel(definition)) { - const panelBytes = yield* bundleComponentPanel(voidhashDir, file); - panel = yield* writeArtifact(projectRoot, join(componentOutDir, "panel.js"), panelBytes); + const panelBytes = yield* bundleComponentPanel(path, voidhashDir, file); + panel = yield* writeArtifact( + fs, + path, + projectRoot, + path.join(componentOutDir, "panel.js"), + panelBytes, + ); } - const source = yield* readDeployFile(projectRoot, file); + const source = yield* readDeployFile(fs, path, projectRoot, file); components.push({ artifacts: { panel, runtime }, @@ -1001,9 +1132,22 @@ export const buildPaywalls = ({ // ── Manifest ───────────────────────────────────────────────────────────── - const configFile = ["ts", "js", "cjs", "mjs"] - .map((ext) => join(projectRoot, `voidhash.config.${ext}`)) - .find((p) => existsSync(p)); + let configFile: string | undefined; + for (const ext of ["ts", "js", "cjs", "mjs"]) { + const candidate = path.join(projectRoot, `voidhash.config.${ext}`); + const exists = yield* fs + .exists(candidate) + .pipe( + Effect.mapError( + (cause) => + new PaywallBuildError({ cause, message: `Failed to look for ${candidate}` }), + ), + ); + if (exists) { + configFile = candidate; + break; + } + } if (!configFile) { return yield* Effect.fail( new PaywallBuildError({ @@ -1011,14 +1155,16 @@ export const buildPaywalls = ({ }), ); } - const config = yield* readDeployFile(projectRoot, configFile); + const config = yield* readDeployFile(fs, path, projectRoot, configFile); + + const now = yield* DateTime.nowAsDate; const manifest: DeployManifest = { assets: [...assetIndex.values()].sort((a, b) => a.path.localeCompare(b.path)), cliVersion, components, config, - createdAt: new Date().toISOString(), + createdAt: now.toISOString(), paywalls, project, runtimeVersion, @@ -1038,8 +1184,13 @@ export const buildPaywalls = ({ ), ); - const manifestPath = join(outDir, "manifest.json"); - yield* writeFile(manifestPath, textEncoder.encode(`${JSON.stringify(manifest, null, 2)}\n`)); + const manifestPath = path.join(outDir, "manifest.json"); + yield* writeFile( + fs, + path, + manifestPath, + yield* toJsonBytes("the deploy manifest", manifest), + ); return { manifest, manifestPath, outDir }; }); diff --git a/apps/cli/src/domain/services/paywall-closed-imports.ts b/apps/cli/src/domain/services/paywall-closed-imports.ts index 0a68518cf..6983e6a51 100644 --- a/apps/cli/src/domain/services/paywall-closed-imports.ts +++ b/apps/cli/src/domain/services/paywall-closed-imports.ts @@ -4,9 +4,8 @@ * anything else (react-dom, lodash, app code outside `.voidhash`, …) fails the * build with an error naming the offending import. */ -import { realpathSync } from "node:fs"; -import { isAbsolute, resolve, sep } from "node:path"; - +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; import type * as esbuild from "esbuild"; /** Bare specifiers `.voidhash` sources may import. */ @@ -22,19 +21,22 @@ const PAYWALLS_PACKAGE = "@voidhash/paywalls"; const FORBIDDEN_PAYWALLS_SUBPATH = `${PAYWALLS_PACKAGE}/tree`; /** Resolves symlinks (macOS tmp dirs, pnpm) so containment checks compare real paths. */ -const toRealPath = (path: string): string => { - try { - return realpathSync(path); - } catch { - return resolve(path); - } -}; +const toRealPath = (target: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem + .realPath(target) + .pipe(Effect.orElseSucceed(() => path.resolve(target))); + }); -const isPathWithin = (parent: string, child: string): boolean => { - const parentPath = toRealPath(parent); - const childPath = toRealPath(child); - return childPath === parentPath || childPath.startsWith(parentPath + sep); -}; +const isPathWithin = (parent: string, child: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const parentPath = yield* toRealPath(parent); + const childPath = yield* toRealPath(child); + return childPath === parentPath || childPath.startsWith(parentPath + path.sep); + }); const isAllowedBareImport = (specifier: string): boolean => { if (ALLOWED_BARE_IMPORTS.includes(specifier)) { @@ -64,54 +66,70 @@ const disallowedMessage = (specifier: string, importer: string): string => * * @param voidhashDir Absolute path to the project's `.voidhash` directory. */ -export const closedImportsPlugin = (voidhashDir: string): esbuild.Plugin => ({ - name: "voidhash-closed-imports", - setup(build) { - build.onResolve({ filter: /.*/ }, (args) => { - if (args.kind === "entry-point") { - return null; - } - // Only user-authored sources are constrained. Synthetic stdin entries - // (non-absolute importer) count as user sources. - const fromUserSource = !isAbsolute(args.importer) || isPathWithin(voidhashDir, args.importer); - if (!fromUserSource) { - return null; - } +const resolveImport = ( + voidhashDir: string, + args: esbuild.OnResolveArgs, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; - const specifier = args.path; + if (args.kind === "entry-point") { + return null; + } + // Only user-authored sources are constrained. Synthetic stdin entries + // (non-absolute importer) count as user sources. + const fromUserSource = + !path.isAbsolute(args.importer) || (yield* isPathWithin(voidhashDir, args.importer)); + if (!fromUserSource) { + return null; + } - if (specifier.startsWith(".")) { - const target = resolve(args.resolveDir, specifier); - if (!isPathWithin(voidhashDir, target)) { - return { - errors: [ - { - text: - `Import "${specifier}" (in ${args.importer}) escapes the ` + - ".voidhash directory. Paywall sources may only import files " + - "within .voidhash.", - }, - ], - }; - } - return null; - } + const specifier = args.path; - if (isAbsolute(specifier)) { - return isPathWithin(voidhashDir, specifier) - ? null - : { - errors: [{ text: disallowedMessage(specifier, args.importer) }], - }; + if (specifier.startsWith(".")) { + const target = path.resolve(args.resolveDir, specifier); + if (!(yield* isPathWithin(voidhashDir, target))) { + return { + errors: [ + { + text: + `Import "${specifier}" (in ${args.importer}) escapes the ` + + ".voidhash directory. Paywall sources may only import files " + + "within .voidhash.", + }, + ], + }; } + return null; + } - if (isAllowedBareImport(specifier)) { + if (path.isAbsolute(specifier)) { + if (yield* isPathWithin(voidhashDir, specifier)) { return null; } - return { errors: [{ text: disallowedMessage(specifier, args.importer) }], }; - }); + } + + if (isAllowedBareImport(specifier)) { + return null; + } + + return { + errors: [{ text: disallowedMessage(specifier, args.importer) }], + }; + }); + +export const closedImportsPlugin = (voidhashDir: string): esbuild.Plugin => ({ + name: "voidhash-closed-imports", + setup(build) { + // esbuild allows an async `onResolve`; the path checks read the real + // filesystem through the platform `FileSystem`, which has no sync surface. + build.onResolve({ filter: /.*/ }, (args) => + Effect.runPromise( + resolveImport(voidhashDir, args).pipe(Effect.provide(NodeServices.layer)), + ), + ); }, }); diff --git a/apps/cli/src/domain/services/paywall-deploy-upload.ts b/apps/cli/src/domain/services/paywall-deploy-upload.ts index fb904b99f..ff44f8e13 100644 --- a/apps/cli/src/domain/services/paywall-deploy-upload.ts +++ b/apps/cli/src/domain/services/paywall-deploy-upload.ts @@ -4,10 +4,17 @@ * follows the CLI's API conventions — `api_url` base + `x-api-key` header from * the user's CLI config. */ -import { promises as fsp } from "node:fs"; -import { join } from "node:path"; - -import { Data, Effect, Schema } from "effect"; +import { + Data, + Effect, + FileSystem, + Match, + Option, + Path, + Schema, + SchemaGetter, + SchemaTransformation, +} from "effect"; import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"; import type { DeployManifest } from "../schema/paywall-deploy"; @@ -85,13 +92,26 @@ export const collectManifestFiles = (manifest: DeployManifest): Map { - try { - return JSON.parse(body); - } catch { - return; - } -}; +/** JSON text codec used to read server response bodies. */ +const JsonText = Schema.UnknownFromJsonString; + +/** + * JSON text codec used to re-render a server response body for the user. + * `space: 2` keeps the printed detail block readable, as it was before. + */ +const PrettyJsonText = Schema.String.pipe( + Schema.decodeTo( + Schema.Unknown, + new SchemaTransformation.Transformation( + SchemaGetter.parseJson(), + SchemaGetter.stringifyJson({ space: 2 }), + ), + ), +); + +/** Parses a response body as JSON, `None` when it is not JSON at all. */ +const tryParseJson = (body: string): Option.Option => + Schema.decodeUnknownOption(JsonText)(body); /** * Extracts the `missing` hash list from a finalize `409` body (contract §4.3: @@ -99,7 +119,7 @@ const tryParseJson = (body: string): unknown => { * such list — callers then fall back to the generic failure path. */ const readMissingHashes = (body: string): string[] | undefined => { - const parsed = tryParseJson(body); + const parsed = Option.getOrUndefined(tryParseJson(body)); if (typeof parsed !== "object" || parsed === null || !("missing" in parsed)) { return; } @@ -114,23 +134,39 @@ const readMissingHashes = (body: string): string[] | undefined => { return missing; }; +/** The actionable hint appended to a failure of the given HTTP status. */ +const failureHint = (status: number): string => + Match.value(status).pipe( + Match.when( + 400, + () => " The server rejected the manifest — your CLI may be outdated; try upgrading voidhash-cli.", + ), + Match.when(401, () => " Authentication failed. Run 'voidhash-cli auth login' and retry."), + Match.when( + 403, + () => " Check that the team/project in voidhash.config.ts match a project you have access to.", + ), + Match.when( + 409, + () => " The deploy is incomplete (blobs missing server-side). Re-run deploy to retry.", + ), + Match.when(422, () => " The server rejected the deploy contents:"), + Match.orElse(() => ""), + ); + +/** The response body block appended below the failure line, if any. */ +const detailsBlock = (details: string): string => { + if (details) return `\n${details}`; + return ""; +}; + /** Renders a non-2xx response into an actionable message (esp. 422 details). */ const describeHttpFailure = (step: string, status: number, body: string): string => { - const parsed = tryParseJson(body); - const details = parsed !== undefined ? JSON.stringify(parsed, null, 2) : body.trim(); - const hint = - status === 400 - ? " The server rejected the manifest — your CLI may be outdated; try upgrading voidhash-cli." - : status === 401 - ? " Authentication failed. Run 'voidhash-cli auth login' and retry." - : status === 403 - ? " Check that the team/project in voidhash.config.ts match a project you have access to." - : status === 409 - ? " The deploy is incomplete (blobs missing server-side). Re-run deploy to retry." - : status === 422 - ? " The server rejected the deploy contents:" - : ""; - return `${step} failed with status ${status}.${hint}${details ? `\n${details}` : ""}`; + const details = tryParseJson(body).pipe( + Option.flatMap(Schema.encodeUnknownOption(PrettyJsonText)), + Option.getOrElse(() => body.trim()), + ); + return `${step} failed with status ${status}.${failureHint(status)}${detailsBlock(details)}`; }; const failHttp = ( @@ -207,11 +243,13 @@ export const uploadPaywallDeploy = ({ }: UploadPaywallDeployOptions): Effect.Effect< UploadPaywallDeployResult, PaywallDeployUploadError, - HttpClient.HttpClient | CliConfig + HttpClient.HttpClient | CliConfig | FileSystem.FileSystem | Path.Path > => Effect.gen(function* uploadPaywallDeploy() { const httpClient = yield* HttpClient.HttpClient; const cliConfig = yield* CliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const config = yield* cliConfig.readConfig().pipe( Effect.mapError( @@ -244,8 +282,10 @@ export const uploadPaywallDeploy = ({ ) .pipe(Effect.mapError(networkFailure(step))); - const report = (message: string): Effect.Effect => - onProgress ? onProgress(message) : Effect.void; + const report = (message: string): Effect.Effect => { + if (onProgress) return onProgress(message); + return Effect.void; + }; // 1. Create the deploy from the manifest. const createStep = "Creating the deploy"; @@ -275,14 +315,15 @@ export const uploadPaywallDeploy = ({ }), ); } - const bytes = yield* Effect.tryPromise({ - try: () => fsp.readFile(join(projectRoot, relPath)), - catch: (cause) => - new PaywallDeployUploadError({ - cause, - message: `Failed to read ${relPath} for upload.`, - }), - }); + const bytes = yield* fs.readFile(path.join(projectRoot, relPath)).pipe( + Effect.mapError( + (cause) => + new PaywallDeployUploadError({ + cause, + message: `Failed to read ${relPath} for upload.`, + }), + ), + ); const uploadStep = `Uploading ${relPath}`; const uploadResponse = yield* send( uploadStep, diff --git a/apps/cli/src/domain/services/paywall-typecheck.ts b/apps/cli/src/domain/services/paywall-typecheck.ts index e11c76617..d6b995251 100644 --- a/apps/cli/src/domain/services/paywall-typecheck.ts +++ b/apps/cli/src/domain/services/paywall-typecheck.ts @@ -3,9 +3,8 @@ * `.voidhash` sources are typechecked with the TypeScript compiler API using * the project's own `tsconfig.json`, and the build fails listing diagnostics. */ -import { dirname, join } from "node:path"; - -import { Data, Effect } from "effect"; +import { constant } from "@voidhash/lib/lang"; +import { Data, Effect, Path } from "effect"; import ts from "typescript"; export class PaywallTypecheckError extends Data.TaggedError("PaywallTypecheckError")<{ @@ -19,7 +18,7 @@ export class PaywallTypecheckError extends Data.TaggedError("PaywallTypecheckErr * `import hero from "./hero.png"` typechecks, and the bundler emits/inlines * the file. */ -export const PAYWALL_ASSET_EXTENSIONS = [ +export const PAYWALL_ASSET_EXTENSIONS = constant([ "png", "jpg", "jpeg", @@ -30,7 +29,7 @@ export const PAYWALL_ASSET_EXTENSIONS = [ "otf", "woff", "woff2", -] as const; +]); /** Ambient `declare module "*.png" { … }` block per supported asset extension. */ const ASSET_MODULE_DECLARATIONS = PAYWALL_ASSET_EXTENSIONS.map( @@ -56,36 +55,66 @@ const FALLBACK_OPTIONS: ts.CompilerOptions = { const formatHost: ts.FormatDiagnosticsHost = { getCanonicalFileName: (fileName) => fileName, + // oxlint-disable-next-line typescript/unbound-method -- ts.sys is the TypeScript compiler's own host singleton: its members are standalone functions that never read `this`, and the compiler API contract is to hand them over by reference. getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine, }; +/** Message for an unknown failure raised by the TypeScript compiler API. */ +const typecheckFailureMessage = (cause: unknown): string => { + if (cause instanceof Error) return cause.message; + return "Failed to typecheck .voidhash sources."; +}; + +/** Runs a synchronous TypeScript compiler API call as a typed failure. */ +const attempt = (thunk: () => A): Effect.Effect => + Effect.try({ + try: thunk, + catch: (cause) => + new PaywallTypecheckError({ cause, message: typecheckFailureMessage(cause) }), + }); + const loadCompilerOptions = ( + path: Path.Path, projectRoot: string, -): { options: ts.CompilerOptions; configPath: string | undefined } => { - const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, "tsconfig.json"); - if (!configPath) { - return { configPath: undefined, options: { ...FALLBACK_OPTIONS } }; - } +): Effect.Effect< + { options: ts.CompilerOptions; configPath: string | undefined }, + PaywallTypecheckError +> => + Effect.gen(function* loadCompilerOptions() { + const configPath = yield* attempt(() => + // oxlint-disable-next-line typescript/unbound-method -- ts.findConfigFile takes ts.sys.fileExists as a callback; the ts.sys members are `this`-free functions on the compiler's host singleton and are meant to be passed by reference. + ts.findConfigFile(projectRoot, ts.sys.fileExists, "tsconfig.json"), + ); + if (!configPath) { + return { configPath: undefined, options: { ...FALLBACK_OPTIONS } }; + } - const read = ts.readConfigFile(configPath, ts.sys.readFile); - if (read.error) { - throw new Error(ts.formatDiagnostics([read.error], formatHost)); - } - const parsed = ts.parseJsonConfigFileContent( - read.config, - ts.sys, - dirname(configPath), - undefined, - configPath, - ); - // "no inputs were found" (18003) is irrelevant — we supply our own roots. - const configErrors = parsed.errors.filter((e) => e.code !== 18_003); - if (configErrors.length > 0) { - throw new Error(ts.formatDiagnostics(configErrors, formatHost)); - } - return { configPath, options: parsed.options }; -}; + // oxlint-disable-next-line typescript/unbound-method -- ts.readConfigFile takes ts.sys.readFile as a callback; the ts.sys members are `this`-free functions on the compiler's host singleton and are meant to be passed by reference. + const read = yield* attempt(() => ts.readConfigFile(configPath, ts.sys.readFile)); + if (read.error) { + return yield* new PaywallTypecheckError({ + message: ts.formatDiagnostics([read.error], formatHost), + }); + } + const parsed = yield* attempt(() => + ts.parseJsonConfigFileContent( + read.config, + ts.sys, + path.dirname(configPath), + undefined, + configPath, + ), + ); + // "no inputs were found" (18003) is irrelevant — we supply our own roots. + const configErrors = parsed.errors.filter((e) => e.code !== 18_003); + if (configErrors.length > 0) { + return yield* new PaywallTypecheckError({ + message: ts.formatDiagnostics(configErrors, formatHost), + }); + } + return { configPath, options: parsed.options }; + }); /** * Wraps a compiler host so the in-memory asset declaration file exists at @@ -98,12 +127,21 @@ const withAssetDeclarations = (host: ts.CompilerHost, assetDeclPath: string): ts return { ...host, fileExists: (fileName) => fileName === assetDeclPath || fileExists(fileName), - getSourceFile: (fileName, languageVersionOrOptions, ...rest) => - fileName === assetDeclPath - ? ts.createSourceFile(fileName, ASSET_MODULE_DECLARATIONS, languageVersionOrOptions, true) - : getSourceFile(fileName, languageVersionOrOptions, ...rest), - readFile: (fileName) => - fileName === assetDeclPath ? ASSET_MODULE_DECLARATIONS : readFile(fileName), + getSourceFile: (fileName, languageVersionOrOptions, ...rest) => { + if (fileName === assetDeclPath) { + return ts.createSourceFile( + fileName, + ASSET_MODULE_DECLARATIONS, + languageVersionOrOptions, + true, + ); + } + return getSourceFile(fileName, languageVersionOrOptions, ...rest); + }, + readFile: (fileName) => { + if (fileName === assetDeclPath) return ASSET_MODULE_DECLARATIONS; + return readFile(fileName); + }, }; }; @@ -118,42 +156,38 @@ const withAssetDeclarations = (host: ts.CompilerHost, assetDeclPath: string): ts export const typecheckPaywallSources = (options: { readonly projectRoot: string; readonly files: ReadonlyArray; -}): Effect.Effect => - Effect.try({ - try: () => { - const { options: compilerOptions } = loadCompilerOptions(options.projectRoot); +}): Effect.Effect => + Effect.gen(function* typecheckPaywallSources() { + const path = yield* Path.Path; + const { options: compilerOptions } = yield* loadCompilerOptions(path, options.projectRoot); - const finalOptions: ts.CompilerOptions = { - ...compilerOptions, - // The gate only checks — never emit, and never stumble over - // third-party declaration files. - incremental: false, - jsx: compilerOptions.jsx ?? ts.JsxEmit.ReactJSX, - noEmit: true, - skipLibCheck: true, - }; + const finalOptions: ts.CompilerOptions = { + ...compilerOptions, + // The gate only checks — never emit, and never stumble over + // third-party declaration files. + incremental: false, + jsx: compilerOptions.jsx ?? ts.JsxEmit.ReactJSX, + noEmit: true, + skipLibCheck: true, + }; - const assetDeclPath = join(options.projectRoot, ASSET_DECLARATIONS_FILE_NAME); + const assetDeclPath = path.join(options.projectRoot, ASSET_DECLARATIONS_FILE_NAME); + const diagnostics = yield* attempt(() => { const program = ts.createProgram({ host: withAssetDeclarations(ts.createCompilerHost(finalOptions), assetDeclPath), options: finalOptions, rootNames: [...options.files, assetDeclPath], }); - - const diagnostics = ts + return ts .getPreEmitDiagnostics(program) .filter((d) => d.category === ts.DiagnosticCategory.Error); + }); - if (diagnostics.length > 0) { - throw new Error( + if (diagnostics.length > 0) { + return yield* new PaywallTypecheckError({ + message: `TypeScript found ${diagnostics.length} error(s) in .voidhash sources:\n\n` + - ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost), - ); - } - }, - catch: (cause) => - new PaywallTypecheckError({ - cause, - message: cause instanceof Error ? cause.message : "Failed to typecheck .voidhash sources.", - }), + ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost), + }); + } }); diff --git a/apps/cli/src/domain/services/schema.ts b/apps/cli/src/domain/services/schema.ts index 1fd76b2d6..6a2766714 100644 --- a/apps/cli/src/domain/services/schema.ts +++ b/apps/cli/src/domain/services/schema.ts @@ -1,3 +1,4 @@ +import { constant } from "@voidhash/lib/lang"; import { Effect, Layer, Context } from "effect"; import { ApiClient } from "../../utils/api-client"; @@ -8,6 +9,15 @@ import { type NormalizedSchema, } from "../schema/normalized-schema"; +const SUPPORTED_PROVIDER_IDS: ReadonlySet = new Set([ + "appleAppStore", + "googlePlay", +] satisfies ReadonlyArray); + +/** Narrows a provider id reported by the API to one the CLI understands. */ +const isSupportedProviderId = (providerId: string): providerId is ProviderId => + SUPPORTED_PROVIDER_IDS.has(providerId); + const make = Effect.gen(function* effect() { const apiClient = yield* ApiClient; @@ -42,21 +52,20 @@ const make = Effect.gen(function* effect() { }); } - const SUPPORTED_PROVIDER_IDS: ReadonlySet = new Set([ - "appleAppStore", - "googlePlay", - ]); for (const product of response.products) { schema.products.set(product.slug, { name: product.name, perks: [...product.perks], - providers: product.providers - .filter((provider) => SUPPORTED_PROVIDER_IDS.has(provider.providerId as string)) - .map((provider) => ({ - configuration: provider.configuration, - providerId: provider.providerId as ProviderId, - })), + providers: product.providers.flatMap((provider) => { + if (!isSupportedProviderId(provider.providerId)) return []; + return [ + { + configuration: provider.configuration, + providerId: provider.providerId, + }, + ]; + }), slug: product.slug, type: product.type, }); @@ -106,10 +115,10 @@ const make = Effect.gen(function* effect() { ), ); - return { + return constant({ fetchRemoteSchema, fetchSchemaVersion, - } as const; + }); }); type SchemaServiceShape = Effect.Success; diff --git a/apps/cli/src/domain/services/source-code.ts b/apps/cli/src/domain/services/source-code.ts index c5bd0ef25..edf280962 100644 --- a/apps/cli/src/domain/services/source-code.ts +++ b/apps/cli/src/domain/services/source-code.ts @@ -1,3 +1,4 @@ +import { constant } from "@voidhash/lib/lang"; import { Effect, FileSystem, Layer, Path, Schema, Context } from "effect"; import { safeRegister } from "../../utils/js-loading/js-file-loading"; @@ -15,6 +16,9 @@ import { import { PackageJsonSchema } from "../schema/package-json"; import { VoidhashConfigSchema } from "../schema/voidhash-config"; +/** Decodes the raw `package.json` text into the validated manifest. */ +const PackageJsonJson = Schema.fromJsonString(PackageJsonSchema); + const make = Effect.gen(function* effect() { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -109,7 +113,7 @@ const make = Effect.gen(function* effect() { } const packageJson = yield* fs.readFileString(packageJsonPath); - return yield* Schema.decodeUnknownEffect(PackageJsonSchema)(JSON.parse(packageJson)).pipe( + return yield* Schema.decodeUnknownEffect(PackageJsonJson)(packageJson).pipe( Effect.catchTag("SchemaError", (e) => Effect.fail( new InvalidPackageJsonError({ @@ -244,6 +248,7 @@ const make = Effect.gen(function* effect() { const absolutePath = path.resolve(existingPath); const { unregister } = yield* safeRegister(); + // oxlint-disable-next-line effect/noDynamicImports -- loads the user's `voidhash.config` from a path only known at runtime; a static import cannot name a caller-supplied absolute path. const required = require(absolutePath); unregister(); const content = required.default ?? required; @@ -280,7 +285,7 @@ const make = Effect.gen(function* effect() { yield* fs.remove(voidhashConfigPath); }); - return { + return constant({ deleteVoidhashConfig, detectMonorepoRootPath, detectPackageManager, @@ -288,7 +293,7 @@ const make = Effect.gen(function* effect() { loadPackageJson, loadVoidhashConfig, retrieveSrcDir, - } as const; + }); }); type SourceCodeShape = Effect.Success; diff --git a/apps/cli/src/services/auth/index.ts b/apps/cli/src/services/auth/index.ts index a22186a7c..6f9d011d5 100644 --- a/apps/cli/src/services/auth/index.ts +++ b/apps/cli/src/services/auth/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type AuthServiceShape = Effect.Success; diff --git a/apps/cli/src/services/auth/utils/better-auth.ts b/apps/cli/src/services/auth/utils/better-auth.ts index 4df81b91a..25f869a1d 100644 --- a/apps/cli/src/services/auth/utils/better-auth.ts +++ b/apps/cli/src/services/auth/utils/better-auth.ts @@ -24,19 +24,22 @@ const make = Effect.gen(function* effect() { client: typeof authClient, ) => Promise<{ error: E; data?: null } | { error?: null; data: D }>, ) => - Effect.tryPromise({ - catch: (error) => - new BetterAuthClientError({ - cause: error, + Effect.gen(function* use() { + const res = yield* Effect.tryPromise({ + catch: (error) => + new BetterAuthClientError({ + cause: error, + message: "Failed to use better-auth client", + }), + try: () => fn(authClient), + }); + if (res.error) { + return yield* new BetterAuthClientError({ + cause: res.error, message: "Failed to use better-auth client", - }), - try: async () => { - const res = await fn(authClient); - if (res.error) { - throw res.error; - } - return res.data; - }, + }); + } + return res.data; }), }; }); diff --git a/apps/cli/src/services/cli-config/index.ts b/apps/cli/src/services/cli-config/index.ts index 0dfddff68..178190ae1 100644 --- a/apps/cli/src/services/cli-config/index.ts +++ b/apps/cli/src/services/cli-config/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type CliConfigServiceShape = Effect.Success; diff --git a/apps/cli/src/services/organization/index.ts b/apps/cli/src/services/organization/index.ts index 99511c2e5..96345c455 100644 --- a/apps/cli/src/services/organization/index.ts +++ b/apps/cli/src/services/organization/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type OrganizationServiceShape = Effect.Success; diff --git a/apps/cli/src/services/project/index.ts b/apps/cli/src/services/project/index.ts index 94be9ccdc..46ad8b0a1 100644 --- a/apps/cli/src/services/project/index.ts +++ b/apps/cli/src/services/project/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type ProjectServiceShape = Effect.Success; diff --git a/apps/cli/src/services/repository/index.ts b/apps/cli/src/services/repository/index.ts index da583e606..dfdd864c1 100644 --- a/apps/cli/src/services/repository/index.ts +++ b/apps/cli/src/services/repository/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type RepositoryServiceShape = Effect.Success; diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index 8c30ae8f6..5b54b72f6 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -1,14 +1,20 @@ -import { make as makeCoreClient, type VoidhashCoreClient } from "@voidhash/generated-clients"; +import { make as makeCoreClient } from "@voidhash/generated-clients"; import { Effect, Layer, Context } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { CliConfig } from "../domain/services/cli-config"; +/** Builds the API key header set, empty when no key is configured. */ +const apiKeyHeaders = (apiKey: string | null | undefined): Record => { + if (apiKey) return { "x-api-key": apiKey }; + return {}; +}; + const make = Effect.gen(function* effect() { yield* Effect.logDebug("Initializing API client"); const cliConfig = yield* CliConfig; const httpClient = yield* HttpClient.HttpClient; - return makeCoreClient(httpClient as VoidhashCoreClient["httpClient"], { + return makeCoreClient(httpClient, { transformClient: (client) => Effect.succeed( client.pipe( @@ -22,7 +28,7 @@ const make = Effect.gen(function* effect() { return HttpClientRequest.setHeaders( HttpClientRequest.prependUrl(request, config.api_url), - config.api_key ? { "x-api-key": config.api_key } : {}, + apiKeyHeaders(config.api_key), ); }).pipe(Effect.withSpan("ApiClient.transformRequest")), ), diff --git a/apps/cli/src/utils/error-formatter.ts b/apps/cli/src/utils/error-formatter.ts index 488ade62b..029dc3366 100644 --- a/apps/cli/src/utils/error-formatter.ts +++ b/apps/cli/src/utils/error-formatter.ts @@ -1,5 +1,5 @@ import { CliError } from "effect/unstable/cli"; -import { Cause, Console, Effect } from "effect"; +import { Cause, Console, Data, Effect } from "effect"; const CliErrorTypeId = Symbol.for("~effect/cli/CliError"); @@ -7,6 +7,7 @@ const CliErrorTypeId = Symbol.for("~effect/cli/CliError"); * Check if debug mode is enabled via --debug flag */ export const isDebugMode = (): boolean => + // oxlint-disable-next-line effect/noGlobals -- synchronous argv adapter: `--debug` must be readable where the CliConfig layer is built, before the parsed flag values exist, so the effect-based `CommandExecutor`/`Config` path is not available yet. process.argv.includes("--debug") || process.argv.includes("-d"); /** @@ -19,6 +20,7 @@ export const isDebugMode = (): boolean => * every command. */ export const getActiveProfile = (): string | null => { + // oxlint-disable-next-line effect/noGlobals -- as documented above, the `--profile` value is read straight from argv because the parsed flag isn't available where the CliConfig layer is built. const argv = process.argv; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -42,8 +44,16 @@ export const getActiveProfile = (): string | null => { * ) * ``` */ +/** + * The cause carried by a {@link userError} — an ordinary `Error` (so + * `Cause.pretty` renders it) whose only payload is the user-facing message. + */ +export class UserMessageError extends Data.TaggedError("UserMessageError")<{ + readonly message: string; +}> {} + export const userError = (message: string): CliError.UserError => - new CliError.UserError({ cause: new Error(message) }); + new CliError.UserError({ cause: new UserMessageError({ message }) }); /** * Check if an error is a CliError from @effect/cli @@ -77,12 +87,14 @@ export const withValidationErrorHandler = ( Effect.andThen(Console.error(Cause.pretty(cause))), Effect.andThen(Console.error("--- End Debug Trace ---\n")), Effect.andThen(Console.error(failure.message)), + // oxlint-disable-next-line effect/noGlobals -- terminal CLI exit: this handler wraps the whole program, so there is no outer Effect runtime left to carry an exit code; failing instead would re-print the cause the handler just rendered. Effect.andThen(Effect.sync(() => process.exit(1))), ); } // Normal mode: just show the user-friendly message return Console.error(failure.message).pipe( + // oxlint-disable-next-line effect/noGlobals -- terminal CLI exit: this handler wraps the whole program, so there is no outer Effect runtime left to carry an exit code; failing instead would re-print the message just rendered. Effect.andThen(Effect.sync(() => process.exit(1))), ); } @@ -93,17 +105,24 @@ export const withValidationErrorHandler = ( return Console.error("\n--- Debug Trace ---").pipe( Effect.andThen(Console.error(Cause.pretty(cause))), Effect.andThen(Console.error("--- End Debug Trace ---\n")), + // oxlint-disable-next-line effect/noGlobals -- terminal CLI exit: this debug-mode handler wraps the whole program, so there is no outer Effect runtime left to carry an exit code; failing instead would re-print the trace just rendered. Effect.andThen(Effect.sync(() => process.exit(1))), ); } - // Re-fail with non-CliError - const firstFailure = failures[0]; + // Re-fail with non-CliError. Every CliError already returned above, so + // whatever is left in `failures` is outside the excluded union. + const firstFailure = failures.find( + (failure): failure is Exclude => !isCliError(failure), + ); if (firstFailure !== undefined) { - return Effect.fail(firstFailure as Exclude); + return Effect.fail(firstFailure); } // Handle defects - return Effect.failCause(cause as Cause.Cause>); + const defects = cause.reasons.filter( + (reason): reason is Cause.Die | Cause.Interrupt => reason._tag !== "Fail", + ); + return Effect.failCause(Cause.fromReasons(defects)); }), ); diff --git a/apps/cli/src/utils/js-loading/js-file-loading.ts b/apps/cli/src/utils/js-loading/js-file-loading.ts index 38378dbaa..ab6a18d99 100644 --- a/apps/cli/src/utils/js-loading/js-file-loading.ts +++ b/apps/cli/src/utils/js-loading/js-file-loading.ts @@ -7,13 +7,22 @@ export class FailedToLoadJsFileError extends Data.TaggedError("FailedToLoadJsFil readonly cause?: unknown; }> {} +/** + * Lazily loads the tiny TypeScript probe used to detect an esbuild-register + * setup that cannot compile to es5. + */ +const loadEs5Probe = () => import("./_es5"); + +/** Lazily loads esbuild-register, which patches require() to compile TS. */ +const loadEsbuildRegister = () => import("esbuild-register/dist/node"); + const assertES5 = ({ unregister }: { unregister: () => void }) => - Effect.try({ - try: () => require("./_es5.ts"), + Effect.tryPromise({ + try: loadEs5Probe, catch: (e: any) => { unregister(); if ("errors" in e && Array.isArray(e.errors) && e.errors.length > 0) { - const es5Error = (e.errors as any[]).some((it) => + const es5Error = e.errors.some((it: any) => it.text?.includes(`("es5") is not supported yet`), ); if (es5Error) { @@ -39,7 +48,7 @@ export const safeRegister = () => cause: e, message: "An error occurred while trying to load .js/ts file.", }), - try: () => import("esbuild-register/dist/node"), + try: loadEsbuildRegister, }); const res: { unregister: () => void } = yield* Effect.try({ catch: (e) => diff --git a/apps/cli/src/utils/organizations/create-organization.ts b/apps/cli/src/utils/organizations/create-organization.ts index c9969bf56..a9beba003 100644 --- a/apps/cli/src/utils/organizations/create-organization.ts +++ b/apps/cli/src/utils/organizations/create-organization.ts @@ -1,8 +1,6 @@ import { Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; -import { NoSignedInUserError } from "../../domain/errors/auth"; -import { CliConfig } from "../../domain/services/cli-config"; import { ApiClient } from "../api-client"; const validateOrganizationName = (value: string) => { diff --git a/apps/cli/src/utils/source-code.ts b/apps/cli/src/utils/source-code.ts index 5e37e7c4d..8c717ddfe 100644 --- a/apps/cli/src/utils/source-code.ts +++ b/apps/cli/src/utils/source-code.ts @@ -6,8 +6,10 @@ import type { PackageJsonSchema } from "../domain/schema/package-json"; * @param depth - The number of directory levels to go up. * @returns The relative path prefix (e.g., './' for 0, '../' for 1, etc.). */ -export const relativePathPrefixFromDepth = (depth: number) => - depth === 0 ? "./" : `${"../".repeat(depth)}`; +export const relativePathPrefixFromDepth = (depth: number) => { + if (depth === 0) return "./"; + return "../".repeat(depth); +}; /** * Checks if the project is an Expo project. diff --git a/apps/cli/test-monorepo-detection.ts b/apps/cli/test-monorepo-detection.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/cli/tests/domain/schema/paywall-deploy.test.ts b/apps/cli/tests/domain/schema/paywall-deploy.test.ts index 1e40085cd..46255f620 100644 --- a/apps/cli/tests/domain/schema/paywall-deploy.test.ts +++ b/apps/cli/tests/domain/schema/paywall-deploy.test.ts @@ -106,14 +106,26 @@ describe("DeployManifestSchema", () => { it("accepts a component-only manifest and a panel artifact", () => { const fixture = validManifest(); - fixture.paywalls = []; - fixture.components[0]!.artifacts.panel = artifact( - ".voidhash/.build/components/product-option/panel.js", - "f", - "text/javascript; charset=utf-8", - ) as never; + const component = fixture.components[0]!; + const withPanel = { + ...fixture, + components: [ + { + ...component, + artifacts: { + ...component.artifacts, + panel: artifact( + ".voidhash/.build/components/product-option/panel.js", + "f", + "text/javascript; charset=utf-8", + ), + }, + }, + ], + paywalls: [], + }; - const manifest = decode(fixture); + const manifest = decode(withPanel); expect(manifest.components[0]?.artifacts.panel?.sha256).toBe(hash("f")); }); @@ -129,10 +141,11 @@ describe("DeployManifestSchema", () => { it("rejects non-scalar variable values", () => { const fixture = validManifest(); - fixture.paywalls[0]!.variables = { - accentColor: { hex: "#16a34a" }, - } as never; - expect(() => decode(fixture)).toThrow(); + const withObjectVariable = { + ...fixture, + paywalls: [{ ...fixture.paywalls[0]!, variables: { accentColor: { hex: "#16a34a" } } }], + }; + expect(() => decode(withObjectVariable)).toThrow(); }); it("rejects malformed sha256 digests", () => { diff --git a/apps/cli/tests/domain/services/paywall-closed-imports.test.ts b/apps/cli/tests/domain/services/paywall-closed-imports.test.ts index 0af19d135..ba0fc79d4 100644 --- a/apps/cli/tests/domain/services/paywall-closed-imports.test.ts +++ b/apps/cli/tests/domain/services/paywall-closed-imports.test.ts @@ -1,15 +1,10 @@ -import { promises as fsp } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; import * as esbuild from "esbuild"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { closedImportsPlugin } from "../../../src/domain/services/paywall-closed-imports"; -let projectRoot: string; -let voidhashDir: string; - /** Bare modules marked external so "allowed" imports need no node_modules. */ const EXTERNALS = [ "react", @@ -19,116 +14,177 @@ const EXTERNALS = [ "@voidhash/paywalls/*", ]; -const writeSource = async (relPath: string, contents: string) => { - const abs = join(projectRoot, relPath); - await fsp.mkdir(join(abs, ".."), { recursive: true }); - await fsp.writeFile(abs, contents); - return abs; +interface Fixture { + readonly projectRoot: string; + readonly voidhashDir: string; +} + +const writeSource = ( + projectRoot: string, + relPath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* writeSource() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const abs = path.join(projectRoot, relPath); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }); + yield* fs.writeFileString(abs, contents); + return abs; + }).pipe(Effect.orDie); + +/** The esbuild error texts of a failed build ([] when it was not a build failure). */ +const esbuildErrorTexts = (cause: unknown): Array => { + if (typeof cause !== "object" || cause === null || !("errors" in cause)) return []; + const errors = cause.errors; + if (!Array.isArray(errors)) return []; + return errors.map((error: esbuild.Message) => error.text); }; /** Bundles `entry` with the plugin; returns esbuild error texts ([] = ok). */ -const buildErrors = async ( +const buildErrors = ( + voidhashDir: string, entry: string, options: esbuild.BuildOptions = {}, -): Promise => { - try { - await esbuild.build({ - bundle: true, - external: EXTERNALS, - format: "esm", - logLevel: "silent", - plugins: [closedImportsPlugin(voidhashDir)], - write: false, - ...options, - entryPoints: [entry], - }); - return []; - } catch (error) { - return ((error as esbuild.BuildFailure).errors ?? []).map((e) => e.text); - } -}; - -beforeAll(async () => { - projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-closed-imports-")); - voidhashDir = join(projectRoot, ".voidhash"); - await writeSource(".voidhash/components/helper.ts", "export const helper = 1;\n"); - await fsp.writeFile(join(projectRoot, "app-code.ts"), "export const y = 1;\n"); -}); - -afterAll(async () => { - await fsp.rm(projectRoot, { force: true, recursive: true }); -}); - -describe("closedImportsPlugin", () => { - it("allows the allowlist plus relative imports within .voidhash", async () => { - const entry = await writeSource( - ".voidhash/components/allowed.ts", - [ - 'import "react";', - 'import "react/jsx-runtime";', - 'import "react/jsx-dev-runtime";', - 'import "@voidhash/paywalls";', - 'import "@voidhash/paywalls/dom";', - 'import "@voidhash/paywalls/panel";', - 'import { helper } from "./helper";', - "export const ok = helper;", - ].join("\n"), - ); - - expect(await buildErrors(entry)).toEqual([]); - }); - - it("rejects react-dom, naming the importing file", async () => { - const entry = await writeSource( - ".voidhash/components/uses-react-dom.ts", - 'import "react-dom";\nexport {};\n', +): Effect.Effect> => + Effect.tryPromise({ + try: () => + esbuild.build({ + bundle: true, + external: EXTERNALS, + format: "esm", + logLevel: "silent", + plugins: [closedImportsPlugin(voidhashDir)], + write: false, + ...options, + entryPoints: [entry], + }), + catch: esbuildErrorTexts, + }).pipe(Effect.match({ onFailure: (texts) => texts, onSuccess: () => [] })); + +/** + * Runs `use` against a fresh temporary project holding the shared sources the + * suite bundles against, removed again once the test finishes — the fixture + * lifecycle `beforeAll`/`afterAll` used to own. + */ +const withFixture = ( + use: (fixture: Fixture) => Effect.Effect, +): Promise => + Effect.gen(function* withFixture() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* fs + .makeTempDirectory({ prefix: "voidhash-closed-imports-" }) + .pipe(Effect.orDie); + const voidhashDir = path.join(projectRoot, ".voidhash"); + + return yield* Effect.gen(function* runFixture() { + yield* writeSource(projectRoot, ".voidhash/components/helper.ts", "export const helper = 1;\n"); + yield* fs + .writeFileString(path.join(projectRoot, "app-code.ts"), "export const y = 1;\n") + .pipe(Effect.orDie); + return yield* use({ projectRoot, voidhashDir }); + }).pipe( + Effect.ensuring(fs.remove(projectRoot, { force: true, recursive: true }).pipe(Effect.orDie)), ); + }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); - const errors = await buildErrors(entry, { - external: [...EXTERNALS, "react-dom"], - }); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain('"react-dom"'); - expect(errors[0]).toContain("uses-react-dom.ts"); - }); - - it("rejects arbitrary packages", async () => { - const entry = await writeSource( - ".voidhash/components/uses-lodash.ts", - 'import "lodash";\nexport {};\n', - ); - - const errors = await buildErrors(entry); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain('"lodash"'); - }); - - it("rejects the Node-only @voidhash/paywalls/tree entry", async () => { - const entry = await writeSource( - ".voidhash/components/uses-tree.ts", - 'import "@voidhash/paywalls/tree";\nexport {};\n', - ); - - const errors = await buildErrors(entry); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain('"@voidhash/paywalls/tree"'); - }); - - it("rejects relative imports escaping .voidhash", async () => { - const entry = await writeSource( - ".voidhash/components/escapes.ts", - 'import "../../app-code";\nexport {};\n', - ); - - const errors = await buildErrors(entry); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("escapes the .voidhash directory"); - }); - - it("does not constrain imports made outside .voidhash (node_modules)", async () => { - const entry = join(projectRoot, "vendor-entry.ts"); - await fsp.writeFile(entry, 'import "react-dom";\nexport {};\n'); - - expect(await buildErrors(entry, { external: [...EXTERNALS, "react-dom"] })).toEqual([]); - }); +describe("closedImportsPlugin", () => { + it("allows the allowlist plus relative imports within .voidhash", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* allowsAllowlist() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/allowed.ts", + [ + 'import "react";', + 'import "react/jsx-runtime";', + 'import "react/jsx-dev-runtime";', + 'import "@voidhash/paywalls";', + 'import "@voidhash/paywalls/dom";', + 'import "@voidhash/paywalls/panel";', + 'import { helper } from "./helper";', + "export const ok = helper;", + ].join("\n"), + ); + + expect(yield* buildErrors(voidhashDir, entry)).toEqual([]); + }), + )); + + it("rejects react-dom, naming the importing file", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* rejectsReactDom() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/uses-react-dom.ts", + 'import "react-dom";\nexport {};\n', + ); + + const errors = yield* buildErrors(voidhashDir, entry, { + external: [...EXTERNALS, "react-dom"], + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"react-dom"'); + expect(errors[0]).toContain("uses-react-dom.ts"); + }), + )); + + it("rejects arbitrary packages", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* rejectsArbitraryPackages() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/uses-lodash.ts", + 'import "lodash";\nexport {};\n', + ); + + const errors = yield* buildErrors(voidhashDir, entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"lodash"'); + }), + )); + + it("rejects the Node-only @voidhash/paywalls/tree entry", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* rejectsTreeEntry() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/uses-tree.ts", + 'import "@voidhash/paywalls/tree";\nexport {};\n', + ); + + const errors = yield* buildErrors(voidhashDir, entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"@voidhash/paywalls/tree"'); + }), + )); + + it("rejects relative imports escaping .voidhash", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* rejectsEscapingImports() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/escapes.ts", + 'import "../../app-code";\nexport {};\n', + ); + + const errors = yield* buildErrors(voidhashDir, entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("escapes the .voidhash directory"); + }), + )); + + it("does not constrain imports made outside .voidhash (node_modules)", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* allowsOutsideVoidhash() { + const path = yield* Path.Path; + const entry = path.join(projectRoot, "vendor-entry.ts"); + yield* writeSource(projectRoot, "vendor-entry.ts", 'import "react-dom";\nexport {};\n'); + + expect( + yield* buildErrors(voidhashDir, entry, { external: [...EXTERNALS, "react-dom"] }), + ).toEqual([]); + }), + )); }); diff --git a/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts b/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts index cc297bce8..f41b662cc 100644 --- a/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts +++ b/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts @@ -1,11 +1,10 @@ import { createHash } from "node:crypto"; -import { promises as fsp } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Schema } from "effect"; +import { NodeServices } from "@effect/platform-node"; +import { constant } from "@voidhash/lib/lang"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { type DeployManifest, @@ -18,12 +17,12 @@ import { uploadPaywallDeploy, } from "../../../src/domain/services/paywall-deploy-upload"; -let projectRoot: string; -let manifest: DeployManifest; - const sha256Hex = (data: string): string => createHash("sha256").update(data).digest("hex"); -const FILES = { +/** Serializes a stub response body exactly as the server would. */ +const jsonBody = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +const FILES = constant({ config: { contents: "export default {};\n", path: "voidhash.config.ts" }, html: { contents: "\n", @@ -37,7 +36,7 @@ const FILES = { contents: "export default null;\n", path: ".voidhash/paywalls/onboarding.tsx", }, -} as const; +}); const hashOf = (file: { contents: string }): string => sha256Hex(file.contents); @@ -81,6 +80,8 @@ const buildManifest = (): DeployManifest => team: "voidhash-dev-sro", }); +const manifest: DeployManifest = buildManifest(); + interface RecordedRequest { readonly method: string; readonly path: string; @@ -102,7 +103,7 @@ const makeStubClient = (options: { Effect.succeed( HttpClientResponse.fromWeb( request, - new Response(JSON.stringify(body), { + new Response(jsonBody(body), { headers: { "content-type": "application/json" }, status, }), @@ -141,21 +142,52 @@ const cliConfigStub: typeof CliConfig.Service = { writeToConfig: () => Effect.void, }; -const runUpload = (client: HttpClient.HttpClient): Promise => - Effect.runPromise( - uploadPaywallDeploy({ manifest, projectRoot }).pipe( - Effect.provideService(HttpClient.HttpClient, client), - Effect.provideService(CliConfig, cliConfigStub), - ), +/** + * Runs `use` against a fresh temporary project holding the manifest's files, + * removed again once the test finishes — the fixture lifecycle + * `beforeAll`/`afterAll` used to own. + */ +const withProjectRoot = ( + use: (projectRoot: string) => Effect.Effect, +): Promise => + Effect.gen(function* withProjectRoot() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* fs + .makeTempDirectory({ prefix: "voidhash-deploy-upload-" }) + .pipe(Effect.orDie); + + return yield* Effect.gen(function* runFixture() { + for (const file of Object.values(FILES)) { + const abs = path.join(projectRoot, file.path); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }).pipe(Effect.orDie); + yield* fs.writeFileString(abs, file.contents).pipe(Effect.orDie); + } + return yield* use(projectRoot); + }).pipe( + Effect.ensuring(fs.remove(projectRoot, { force: true, recursive: true }).pipe(Effect.orDie)), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); + +const runUpload = ( + client: HttpClient.HttpClient, + projectRoot: string, +): Effect.Effect => + uploadPaywallDeploy({ manifest, projectRoot }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(CliConfig, cliConfigStub), + Effect.orDie, ); -const runUploadError = (client: HttpClient.HttpClient): Promise => - Effect.runPromise( - uploadPaywallDeploy({ manifest, projectRoot }).pipe( - Effect.flip, - Effect.provideService(HttpClient.HttpClient, client), - Effect.provideService(CliConfig, cliConfigStub), - ), +const runUploadError = ( + client: HttpClient.HttpClient, + projectRoot: string, +): Effect.Effect => + uploadPaywallDeploy({ manifest, projectRoot }).pipe( + Effect.flip, + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(CliConfig, cliConfigStub), + Effect.orDie, ); const readyFinalizeBody = { @@ -165,91 +197,93 @@ const readyFinalizeBody = { status: "ready", }; -beforeAll(async () => { - projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-deploy-upload-")); - for (const file of Object.values(FILES)) { - const abs = join(projectRoot, file.path); - await fsp.mkdir(join(abs, ".."), { recursive: true }); - await fsp.writeFile(abs, file.contents); - } - manifest = buildManifest(); -}); - -afterAll(async () => { - await fsp.rm(projectRoot, { force: true, recursive: true }); -}); - describe("uploadPaywallDeploy finalize-409 retry", () => { - it("uploads the 409 missing blobs and retries finalize once", async () => { - const requests: RecordedRequest[] = []; - const result = await runUpload( - makeStubClient({ - createMissing: [hashOf(FILES.js)], - finalizeResponses: [ - { body: { missing: [hashOf(FILES.html)] }, status: 409 }, - { body: readyFinalizeBody, status: 200 }, - ], - requests, + it("uploads the 409 missing blobs and retries finalize once", () => + withProjectRoot((projectRoot) => + Effect.gen(function* retriesFinalizeOnce() { + const requests: RecordedRequest[] = []; + const result = yield* runUpload( + makeStubClient({ + createMissing: [hashOf(FILES.js)], + finalizeResponses: [ + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + { body: readyFinalizeBody, status: 200 }, + ], + requests, + }), + projectRoot, + ); + + expect(result.finalize.status).toBe("ready"); + // One blob from create's missing list + one re-uploaded after the 409. + expect(result.uploadedCount).toBe(2); + const puts = requests.filter((r) => r.method === "PUT"); + expect(puts.map((r) => r.path)).toEqual([ + `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.js)}`, + `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.html)}`, + ]); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); }), - ); + )); - expect(result.finalize.status).toBe("ready"); - // One blob from create's missing list + one re-uploaded after the 409. - expect(result.uploadedCount).toBe(2); - const puts = requests.filter((r) => r.method === "PUT"); - expect(puts.map((r) => r.path)).toEqual([ - `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.js)}`, - `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.html)}`, - ]); - expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); - }); + it("retries at most once and fails readably when finalize stays 409", () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsAfterOneRetry() { + const requests: RecordedRequest[] = []; + const error = yield* runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [ + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + ], + requests, + }), + projectRoot, + ); - it("retries at most once and fails readably when finalize stays 409", async () => { - const requests: RecordedRequest[] = []; - const error = await runUploadError( - makeStubClient({ - createMissing: [], - finalizeResponses: [ - { body: { missing: [hashOf(FILES.html)] }, status: 409 }, - { body: { missing: [hashOf(FILES.html)] }, status: 409 }, - ], - requests, + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(error.message).toContain("Finalizing the deploy failed"); + expect(error.message).toContain(hashOf(FILES.html)); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); }), - ); + )); - expect(error._tag).toBe("PaywallDeployUploadError"); - expect(error.message).toContain("Finalizing the deploy failed"); - expect(error.message).toContain(hashOf(FILES.html)); - expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); - }); + it("fails without retrying when the 409 carries no usable missing list", () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsWithoutUsableMissingList() { + const requests: RecordedRequest[] = []; + const error = yield* runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [{ body: { error: "incomplete" }, status: 409 }], + requests, + }), + projectRoot, + ); - it("fails without retrying when the 409 carries no usable missing list", async () => { - const requests: RecordedRequest[] = []; - const error = await runUploadError( - makeStubClient({ - createMissing: [], - finalizeResponses: [{ body: { error: "incomplete" }, status: 409 }], - requests, + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); + expect(requests.filter((r) => r.method === "PUT")).toHaveLength(0); }), - ); + )); - expect(error._tag).toBe("PaywallDeployUploadError"); - expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); - expect(requests.filter((r) => r.method === "PUT")).toHaveLength(0); - }); + it("fails without retrying when a 409 hash is not part of the manifest", () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsOnForeignHash() { + const requests: RecordedRequest[] = []; + const error = yield* runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [{ body: { missing: ["f".repeat(64)] }, status: 409 }], + requests, + }), + projectRoot, + ); - it("fails without retrying when a 409 hash is not part of the manifest", async () => { - const requests: RecordedRequest[] = []; - const error = await runUploadError( - makeStubClient({ - createMissing: [], - finalizeResponses: [{ body: { missing: ["f".repeat(64)] }, status: 409 }], - requests, + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(error.message).toContain("f".repeat(64)); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); }), - ); - - expect(error._tag).toBe("PaywallDeployUploadError"); - expect(error.message).toContain("f".repeat(64)); - expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); - }); + )); }); diff --git a/apps/cli/tests/domain/services/paywall-typecheck.test.ts b/apps/cli/tests/domain/services/paywall-typecheck.test.ts index 20d2b3771..5051b5788 100644 --- a/apps/cli/tests/domain/services/paywall-typecheck.test.ts +++ b/apps/cli/tests/domain/services/paywall-typecheck.test.ts @@ -1,9 +1,6 @@ -import { promises as fsp } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Effect } from "effect"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; +import { describe, expect, it } from "vitest"; import { PAYWALL_ASSET_EXTENSIONS, @@ -11,91 +8,124 @@ import { typecheckPaywallSources, } from "../../../src/domain/services/paywall-typecheck"; -let projectRoot: string; const compilerTestTimeout = 60_000; -const writeSource = async (relPath: string, contents: string) => { - const abs = join(projectRoot, relPath); - await fsp.mkdir(join(abs, ".."), { recursive: true }); - await fsp.writeFile(abs, contents); - return abs; -}; - -beforeAll(async () => { - projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-typecheck-")); -}); +const writeSource = ( + projectRoot: string, + relPath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* writeSource() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const abs = path.join(projectRoot, relPath); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }); + yield* fs.writeFileString(abs, contents); + return abs; + }).pipe(Effect.orDie); -afterAll(async () => { - await fsp.rm(projectRoot, { force: true, recursive: true }); -}); +/** + * Runs `use` against a fresh temporary project root, removed again once the + * test finishes — the fixture lifecycle `beforeAll`/`afterAll` used to own. + */ +const withProjectRoot = ( + use: (projectRoot: string) => Effect.Effect, +): Promise => + Effect.gen(function* withProjectRoot() { + const fs = yield* FileSystem.FileSystem; + const projectRoot = yield* fs.makeTempDirectory({ prefix: "voidhash-typecheck-" }).pipe( + Effect.orDie, + ); + return yield* use(projectRoot).pipe( + Effect.ensuring(fs.remove(projectRoot, { force: true, recursive: true }).pipe(Effect.orDie)), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); describe("typecheckPaywallSources", () => { it( "passes a source importing a .png via the injected asset declarations", - async () => { - const entry = await writeSource( - ".voidhash/paywalls/with-asset.ts", - ['import hero from "./hero.png";', "export const heroUrl: string = hero;", ""].join("\n"), - ); + () => + withProjectRoot((projectRoot) => + Effect.gen(function* passesAssetImport() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/paywalls/with-asset.ts", + ['import hero from "./hero.png";', "export const heroUrl: string = hero;", ""].join( + "\n", + ), + ); - await expect( - Effect.runPromise(typecheckPaywallSources({ files: [entry], projectRoot })), - ).resolves.toBeUndefined(); - }, + const result = yield* typecheckPaywallSources({ files: [entry], projectRoot }); + expect(result).toBeUndefined(); + }), + ), compilerTestTimeout, ); it( "covers every esbuild-supported asset extension", - async () => { - const imports = PAYWALL_ASSET_EXTENSIONS.map( - (ext, i) => `import asset${i} from "./asset.${ext}";`, - ); - const uses = PAYWALL_ASSET_EXTENSIONS.map( - (_, i) => `export const url${i}: string = asset${i};`, - ); - const entry = await writeSource( - ".voidhash/paywalls/all-assets.ts", - [...imports, ...uses, ""].join("\n"), - ); + () => + withProjectRoot((projectRoot) => + Effect.gen(function* coversEveryExtension() { + const imports = PAYWALL_ASSET_EXTENSIONS.map( + (ext, i) => `import asset${i} from "./asset.${ext}";`, + ); + const uses = PAYWALL_ASSET_EXTENSIONS.map( + (_, i) => `export const url${i}: string = asset${i};`, + ); + const entry = yield* writeSource( + projectRoot, + ".voidhash/paywalls/all-assets.ts", + [...imports, ...uses, ""].join("\n"), + ); - await expect( - Effect.runPromise(typecheckPaywallSources({ files: [entry], projectRoot })), - ).resolves.toBeUndefined(); - }, + const result = yield* typecheckPaywallSources({ files: [entry], projectRoot }); + expect(result).toBeUndefined(); + }), + ), compilerTestTimeout, ); it( "still fails a genuinely type-broken source", - async () => { - const entry = await writeSource( - ".voidhash/paywalls/broken.ts", - ['import hero from "./hero.png";', "export const broken: number = hero;", ""].join("\n"), - ); + () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsBrokenSource() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/paywalls/broken.ts", + ['import hero from "./hero.png";', "export const broken: number = hero;", ""].join( + "\n", + ), + ); - const error = await Effect.runPromise( - typecheckPaywallSources({ files: [entry], projectRoot }).pipe(Effect.flip), - ); - expect(error).toBeInstanceOf(PaywallTypecheckError); - expect(error.message).toContain("broken.ts"); - }, + const error = yield* Effect.flip( + typecheckPaywallSources({ files: [entry], projectRoot }), + ); + expect(error).toBeInstanceOf(PaywallTypecheckError); + expect(error.message).toContain("broken.ts"); + }), + ), compilerTestTimeout, ); it( "still fails an import of an undeclared module kind", - async () => { - const entry = await writeSource( - ".voidhash/paywalls/bad-import.ts", - ['import data from "./data.bin";', "export const d = data;", ""].join("\n"), - ); + () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsUndeclaredModule() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/paywalls/bad-import.ts", + ['import data from "./data.bin";', "export const d = data;", ""].join("\n"), + ); - const error = await Effect.runPromise( - typecheckPaywallSources({ files: [entry], projectRoot }).pipe(Effect.flip), - ); - expect(error).toBeInstanceOf(PaywallTypecheckError); - }, + const error = yield* Effect.flip( + typecheckPaywallSources({ files: [entry], projectRoot }), + ); + expect(error).toBeInstanceOf(PaywallTypecheckError); + }), + ), compilerTestTimeout, ); }); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 8ab8c48ba..957a2a2f4 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src"], "exclude": ["**/node_modules/**"], "compilerOptions": { + "rootDir": "./src", "plugins": [ { "name": "@effect/language-service" diff --git a/apps/mimic-admin/package.json b/apps/mimic-admin/package.json index 23e072d21..a88758e7d 100644 --- a/apps/mimic-admin/package.json +++ b/apps/mimic-admin/package.json @@ -22,7 +22,8 @@ }, "type": "module", "scripts": { - "dev": "vp dev --config vite.config.ts", + "dev": "portless mimic-admin.voidhash --app-port 3003 pnpm run dev:app", + "dev:app": "vp dev --config vite.config.ts", "build": "vp build --config vite.config.ts", "preview": "vp preview --config vite.config.ts", "typecheck": "tsc --noEmit -p tsconfig.json" @@ -41,10 +42,12 @@ "@tanstack/react-query": "^5.80.7", "@tanstack/react-query-devtools": "^5.80.7", "@tanstack/react-router": "1.163.3", + "@voidhash/lib": "workspace:*", "@voidhash/mimic-core": "workspace:*", "@voidhash/mimic-server": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "effect": "catalog:", "lucide-react": "^0.513.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/apps/mimic-admin/src/components/app-sidebar.tsx b/apps/mimic-admin/src/components/app-sidebar.tsx index ba2431180..acc874355 100644 --- a/apps/mimic-admin/src/components/app-sidebar.tsx +++ b/apps/mimic-admin/src/components/app-sidebar.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { Link, useMatchRoute } from "@tanstack/react-router"; import { Activity, Database, FileText, LogOut, Users } from "lucide-react"; +import { constant } from "@voidhash/lib/lang"; import { useAuth } from "@/components/auth-context"; import { useDatabase } from "@/components/database-context"; @@ -16,6 +17,13 @@ import { } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { collectionsQuery, databasesQuery } from "@/lib/queries"; +import { cn } from "@/lib/utils"; + +const NAV_ITEMS = constant([ + { to: "/databases", label: "Databases", icon: Database }, + { to: "/users", label: "Users", icon: Users }, + { to: "/observability", label: "Observability", icon: Activity }, +]); export function AppSidebar() { const { credentials, logout } = useAuth(); @@ -26,12 +34,6 @@ export function AppSidebar() { const { data: databases } = useQuery(databasesQuery(sdk)); const { data: collections } = useQuery(collectionsQuery(sdk, selectedDatabaseId ?? "")); - const navItems = [ - { to: "/databases" as const, label: "Databases", icon: Database }, - { to: "/users" as const, label: "Users", icon: Users }, - { to: "/observability" as const, label: "Observability", icon: Activity }, - ]; - return (
@@ -62,17 +64,17 @@ export function AppSidebar() {
@@ -212,9 +240,7 @@ function DocumentPage() { setGrantPerm(v as "read" | "write" | "admin")} + onValueChange={(v) => setGrantPerm(decodePermission(v))} > diff --git a/apps/mimic-admin/src/routes/_app/route.tsx b/apps/mimic-admin/src/routes/_app/route.tsx index ca39a84b2..cc417912d 100644 --- a/apps/mimic-admin/src/routes/_app/route.tsx +++ b/apps/mimic-admin/src/routes/_app/route.tsx @@ -1,4 +1,5 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { Effect } from "effect"; import { AuthProvider } from "@/components/auth-context"; import { MimicSdkProvider } from "@/components/sdk-context"; @@ -8,7 +9,9 @@ export const Route = createFileRoute("/_app")({ beforeLoad: () => { const credentials = getCredentials(); if (!credentials) { - throw redirect({ to: "/login" }); + // TanStack Router signals navigation by a thrown redirect; `runSync` on a + // defect rethrows the redirect object verbatim so the router still sees it. + return Effect.runSync(Effect.die(redirect({ to: "/login" }))); } return { credentials }; }, diff --git a/apps/mimic-admin/src/routes/login.tsx b/apps/mimic-admin/src/routes/login.tsx index 3bb809e79..9ddfe86a1 100644 --- a/apps/mimic-admin/src/routes/login.tsx +++ b/apps/mimic-admin/src/routes/login.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { MimicSDK } from "@voidhash/mimic-server"; +import { Data, Effect } from "effect"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -13,14 +14,30 @@ export const Route = createFileRoute("/login")({ component: LoginPage, }); +class ConnectionFailedError extends Data.TaggedError("ConnectionFailedError")<{ + readonly message: string; +}> {} + +/** Extracts the operator-facing reason from an unknown connection failure. */ +function connectionFailureReason(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return "Unknown error"; +} + +/** Label for the connect submit button. */ +function connectLabel(isLoading: boolean): string { + if (isLoading) return "Connecting..."; + return "Connect"; +} + function LoginPage() { const navigate = useNavigate(); - const [serverUrl, setServerUrl] = useState("http://localhost:5001"); + const [serverUrl, setServerUrl] = useState("https://mimic.voidhash.localhost"); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); - async function handleSubmit(e: React.FormEvent) { + function handleSubmit(e: React.FormEvent) { e.preventDefault(); setLoading(true); @@ -34,16 +51,33 @@ function LoginPage() { password: creds.password, }); - try { - await sdk.listDatabases(); - setCredentials(creds); - navigate({ to: "/" }); - } catch (err) { - toast.error(`Connection failed: ${err instanceof Error ? err.message : "Unknown error"}`); - } finally { - void sdk.dispose(); - setLoading(false); - } + const connect = Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => sdk.listDatabases(), + catch: (cause) => new ConnectionFailedError({ message: connectionFailureReason(cause) }), + }); + yield* Effect.try({ + try: () => { + setCredentials(creds); + void navigate({ to: "/" }); + }, + catch: (cause) => new ConnectionFailedError({ message: connectionFailureReason(cause) }), + }); + }).pipe( + Effect.catchTag("ConnectionFailedError", (error) => + Effect.sync(() => { + toast.error(`Connection failed: ${error.message}`); + }), + ), + Effect.ensuring( + Effect.sync(() => { + void sdk.dispose(); + setLoading(false); + }), + ), + ); + + void Effect.runPromise(connect); } return ( @@ -63,7 +97,7 @@ function LoginPage() { id="serverUrl" value={serverUrl} onChange={(e) => setServerUrl(e.target.value)} - placeholder="http://localhost:5001" + placeholder="https://mimic.voidhash.localhost" />
@@ -86,7 +120,7 @@ function LoginPage() { />
diff --git a/apps/mimic-admin/vite.config.ts b/apps/mimic-admin/vite.config.ts index 4517e3b57..adc298438 100644 --- a/apps/mimic-admin/vite.config.ts +++ b/apps/mimic-admin/vite.config.ts @@ -6,6 +6,11 @@ export default defineConfig({ plugins: [tailwindcss(), viteReact()], resolve: { tsconfigPaths: true }, root: "src", + server: { + host: "127.0.0.1", + port: 3003, + strictPort: true, + }, build: { outDir: "../dist", emptyOutDir: true, diff --git a/apps/mimic-db/.env.example b/apps/mimic-db/.env.example index 276eac59e..1d005cb23 100644 --- a/apps/mimic-db/.env.example +++ b/apps/mimic-db/.env.example @@ -6,10 +6,10 @@ ROOT_USERNAME=root ROOT_PASSWORD=password # Public base URL used when building document-token connection URLs. -MIMIC_PUBLIC_BASE_URL=http://localhost:5001 +MIMIC_PUBLIC_BASE_URL=https://mimic.voidhash.localhost # Comma-separated CORS origins for the RPC + WebSocket endpoints. -# CORS_ORIGINS=http://localhost:5173 +# CORS_ORIGINS=https://mimic-admin.voidhash.localhost,https://mimic-example.voidhash.localhost,http://localhost:3003,http://localhost:5173 # Document behavior. # MIMIC_DOCUMENT_SNAPSHOT_EVERY_COMMANDS=100 diff --git a/apps/mimic-db/package.json b/apps/mimic-db/package.json index a66e8f2be..0df333645 100644 --- a/apps/mimic-db/package.json +++ b/apps/mimic-db/package.json @@ -15,7 +15,8 @@ "./ws/*": "./src/ws/*.ts" }, "scripts": { - "dev": "tsx watch src/entrypoints/standalone/main.ts", + "dev": "portless mimic.voidhash --app-port 5001 pnpm run dev:app", + "dev:app": "tsx watch src/entrypoints/standalone/main.ts", "start": "tsx src/entrypoints/standalone/main.ts", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vp test run -c vitest.mts", @@ -24,6 +25,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@effect/sql-pg": "catalog:", + "@voidhash/lib": "workspace:*", "@voidhash/mimic-core": "workspace:*", "@voidhash/mimic-server": "workspace:*", "@voidhash/platform": "workspace:*", diff --git a/apps/mimic-db/src/api/handlers/databases.ts b/apps/mimic-db/src/api/handlers/databases.ts index 46936bf00..e72a373e2 100644 --- a/apps/mimic-db/src/api/handlers/databases.ts +++ b/apps/mimic-db/src/api/handlers/databases.ts @@ -3,15 +3,15 @@ import { CurrentUser, DatabasesRpcs, ForbiddenError } from "@voidhash/mimic-serv import { HostServiceTag } from "../../app/hostService.ts"; -const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => - user.isSuperuser - ? Effect.void - : Effect.fail( - new ForbiddenError({ - code: "forbidden", - message: `Superuser permission required for ${action}`, - }), - ); +const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => { + if (user.isSuperuser) return Effect.void; + return Effect.fail( + new ForbiddenError({ + code: "forbidden", + message: `Superuser permission required for ${action}`, + }), + ); +}; export const DatabasesHandlersLive = DatabasesRpcs.toLayer( Effect.gen(function* () { diff --git a/apps/mimic-db/src/api/handlers/document-auth.ts b/apps/mimic-db/src/api/handlers/document-auth.ts index 82b9cacf0..3385369c5 100644 --- a/apps/mimic-db/src/api/handlers/document-auth.ts +++ b/apps/mimic-db/src/api/handlers/document-auth.ts @@ -21,8 +21,15 @@ const parseAbsoluteUrl = (value: string) => { }; }; +const websocketProtocol = (protocol: string): string => { + if (protocol === "https") return "wss"; + return "ws"; +}; + /** - * Builds the absolute `ws(s)://` URL a client connects to for a document. + * Builds the absolute `ws(s)://` URL a client connects to for a document, or + * `undefined` when neither the configured base URL nor the request identifies + * a host (a defect the caller reports). * * `publicBaseUrl` (the `MIMIC_PUBLIC_BASE_URL` config) is the primary * authority: requests arriving through a service-binding fetch carry no @@ -37,14 +44,15 @@ export const buildDocumentConnectionUrl = ( databaseId: string, collectionId: string, documentId: string, -) => { +): string | undefined => { const path = `/ws/v1/databases/${encodeURIComponent( databaseId, )}/collections/${encodeURIComponent(collectionId)}/documents/${encodeURIComponent(documentId)}`; - const base = publicBaseUrl ? parseAbsoluteUrl(publicBaseUrl) : null; - if (base) { - const wsProtocol = base.protocol === "https" ? "wss" : "ws"; - return `${wsProtocol}://${base.host}${path}`; + if (publicBaseUrl) { + const base = parseAbsoluteUrl(publicBaseUrl); + if (base) { + return `${websocketProtocol(base.protocol)}://${base.host}${path}`; + } } const forwardedProto = getHeader(request.headers, "x-forwarded-proto"); const forwardedHost = getHeader(request.headers, "x-forwarded-host"); @@ -53,10 +61,9 @@ export const buildDocumentConnectionUrl = ( const protocol = absoluteUrl?.protocol ?? forwardedProto ?? "http"; const authority = absoluteUrl?.host ?? host; if (!authority) { - throw new Error("Failed to determine request host for document connection URL"); + return undefined; } - const wsProtocol = protocol === "https" ? "wss" : "ws"; - return `${wsProtocol}://${authority}${path}`; + return `${websocketProtocol(protocol)}://${authority}${path}`; }; export const DocumentAuthHandlersLive = DocumentAuthRpcs.toLayer( @@ -82,16 +89,19 @@ export const DocumentAuthHandlersLive = DocumentAuthRpcs.toLayer( origins, expiresInSeconds, ); - return { - token: result.token, - url: buildDocumentConnectionUrl( - getConfig().publicBaseUrl, - request, - databaseId, - collectionId, - documentId, - ), - }; + const url = buildDocumentConnectionUrl( + getConfig().publicBaseUrl, + request, + databaseId, + collectionId, + documentId, + ); + if (url === undefined) { + return yield* Effect.die( + new Error("Failed to determine request host for document connection URL"), + ); + } + return { token: result.token, url }; }), }; }), diff --git a/apps/mimic-db/src/api/handlers/documents.ts b/apps/mimic-db/src/api/handlers/documents.ts index 02b749370..e31a08cc3 100644 --- a/apps/mimic-db/src/api/handlers/documents.ts +++ b/apps/mimic-db/src/api/handlers/documents.ts @@ -2,7 +2,10 @@ import { Effect } from "effect"; import { CurrentUser, DocumentsRpcs } from "@voidhash/mimic-server/rpc"; import { HostServiceTag } from "../../app/hostService.ts"; -import type { TransactionEnvelope } from "../../document/transaction.ts"; +import { + decodeDocumentValue, + decodeTransactionEnvelope, +} from "../../document/transaction.ts"; export const DocumentsHandlersLive = DocumentsRpcs.toLayer( Effect.gen(function* () { @@ -34,14 +37,13 @@ export const DocumentsHandlersLive = DocumentsRpcs.toLayer( const user = yield* CurrentUser; const databaseId = yield* host.databaseIdForCollection(collectionId); yield* host.ensureDatabasePermission(user.userId, user.isSuperuser, databaseId, "write"); - // The wire schema treats commands as `Schema.Unknown[]`; the host - // service is typed against the structured `Command[]` shape from - // mimic-core. The host validates the command shape internally as - // it applies them, so casting here is safe. + // The wire schema treats commands as opaque JSON; the host service is + // typed against the structured `Command[]` shape from mimic-core and + // validates the command shape internally as it applies them. return yield* host.submitTransaction( collectionId, documentId, - transaction as TransactionEnvelope, + decodeTransactionEnvelope(transaction), ); }), OpenDocumentConnection: ({ collectionId, documentId, connectionId, presence, leaseMs }) => @@ -55,7 +57,7 @@ export const DocumentsHandlersLive = DocumentsRpcs.toLayer( connectionId, "write", user.userId, - presence as never, + decodeDocumentValue(presence), leaseMs, ); return { id: documentId, collectionId, value: snapshot.value, version: snapshot.version }; @@ -96,7 +98,7 @@ export const DocumentsHandlersLive = DocumentsRpcs.toLayer( collectionId, documentId, connectionId, - transaction as TransactionEnvelope, + decodeTransactionEnvelope(transaction), leaseMs, ); }), diff --git a/apps/mimic-db/src/api/handlers/grants.ts b/apps/mimic-db/src/api/handlers/grants.ts index 822fdd3b8..54a7117f9 100644 --- a/apps/mimic-db/src/api/handlers/grants.ts +++ b/apps/mimic-db/src/api/handlers/grants.ts @@ -3,15 +3,15 @@ import { CurrentUser, ForbiddenError, GrantsRpcs } from "@voidhash/mimic-server/ import { HostServiceTag } from "../../app/hostService.ts"; -const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => - user.isSuperuser - ? Effect.void - : Effect.fail( - new ForbiddenError({ - code: "forbidden", - message: `Superuser permission required for ${action}`, - }), - ); +const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => { + if (user.isSuperuser) return Effect.void; + return Effect.fail( + new ForbiddenError({ + code: "forbidden", + message: `Superuser permission required for ${action}`, + }), + ); +}; export const GrantsHandlersLive = GrantsRpcs.toLayer( Effect.gen(function* () { diff --git a/apps/mimic-db/src/api/handlers/users.ts b/apps/mimic-db/src/api/handlers/users.ts index 247d1f716..54c16cfcd 100644 --- a/apps/mimic-db/src/api/handlers/users.ts +++ b/apps/mimic-db/src/api/handlers/users.ts @@ -3,15 +3,15 @@ import { CurrentUser, ForbiddenError, UsersRpcs } from "@voidhash/mimic-server/r import { HostServiceTag } from "../../app/hostService.ts"; -const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => - user.isSuperuser - ? Effect.void - : Effect.fail( - new ForbiddenError({ - code: "forbidden", - message: `Superuser permission required for ${action}`, - }), - ); +const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => { + if (user.isSuperuser) return Effect.void; + return Effect.fail( + new ForbiddenError({ + code: "forbidden", + message: `Superuser permission required for ${action}`, + }), + ); +}; export const UsersHandlersLive = UsersRpcs.toLayer( Effect.gen(function* () { diff --git a/apps/mimic-db/src/api/middleware/auth.ts b/apps/mimic-db/src/api/middleware/auth.ts index 1a7b6e69f..89587c9c3 100644 --- a/apps/mimic-db/src/api/middleware/auth.ts +++ b/apps/mimic-db/src/api/middleware/auth.ts @@ -10,28 +10,31 @@ interface BasicCredentials { readonly password: string; } -const parseBasicAuth = (header: string | undefined): BasicCredentials => { - if (!header?.startsWith("Basic ")) { - throw new UnauthorizedError({ - code: "unauthorized", - message: "Authentication required. Provide Authorization: Basic header.", - }); - } - - const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); - const separator = decoded.indexOf(":"); - if (separator <= 0) { - throw new UnauthorizedError({ - code: "unauthorized", - message: "Invalid Basic auth header format", - }); - } - - return { - username: decoded.slice(0, separator), - password: decoded.slice(separator + 1), - }; -}; +const parseBasicAuth = ( + header: string | undefined, +): Effect.Effect => + Effect.gen(function* () { + if (!header?.startsWith("Basic ")) { + return yield* new UnauthorizedError({ + code: "unauthorized", + message: "Authentication required. Provide Authorization: Basic header.", + }); + } + + const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + if (separator <= 0) { + return yield* new UnauthorizedError({ + code: "unauthorized", + message: "Invalid Basic auth header format", + }); + } + + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; + }); /** * Server-side implementation of `AuthMiddleware`. @@ -48,10 +51,8 @@ export const AuthMiddlewareLive = Layer.effect(AuthMiddleware)( return (effect, { headers }) => Effect.gen(function* () { - const auth = - (headers as Record)["authorization"] ?? - (headers as Record)["Authorization"]; - const { username, password } = yield* Effect.sync(() => parseBasicAuth(auth)); + const auth = headers["authorization"] ?? headers["Authorization"]; + const { username, password } = yield* parseBasicAuth(auth); const user = yield* host.authenticateBasic(username, password); return yield* Effect.provideService(effect, CurrentUser, user); }); diff --git a/apps/mimic-db/src/config.ts b/apps/mimic-db/src/config.ts index 134e7212f..f26d0181a 100644 --- a/apps/mimic-db/src/config.ts +++ b/apps/mimic-db/src/config.ts @@ -1,3 +1,14 @@ +/* + * This whole module is mimic-db's synchronous `process.env` configuration + * adapter: `getConfig()` / `getCorsAllowedOrigins()` are plain functions called + * from synchronous platform entry points (including the pre-runtime bootstrap + * path) before any Effect runtime exists, so `Config` is not reachable here. + * Every `process.env` read in the file is that one deliberate choice, hence a + * single file-scoped directive rather than eight identical line directives. + */ +// oxlint-disable effect/noGlobals -- synchronous process.env config adapter; callers read it from synchronous positions before any Effect runtime exists (see block comment above). +import { constant } from "@voidhash/lib/lang"; + /** * Runtime configuration for mimic-db. * @@ -33,15 +44,18 @@ export interface MimicConfig { const positiveInt = (value: string | undefined, fallback: number): number => { if (!value || value.trim() === "") return fallback; const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; }; -const DEFAULT_CORS_ORIGINS = [ +const DEFAULT_CORS_ORIGINS = constant([ + "https://mimic-admin.voidhash.localhost", + "https://mimic-example.voidhash.localhost", "http://localhost:5173", "http://localhost:4173", "http://localhost:4460", "http://localhost:3003", -] as const; +]); export const getCorsAllowedOrigins = (): readonly string[] => { const env = process.env.CORS_ORIGINS?.trim(); diff --git a/apps/mimic-db/src/core/control-engine.ts b/apps/mimic-db/src/core/control-engine.ts index c16704e56..4b26169bc 100644 --- a/apps/mimic-db/src/core/control-engine.ts +++ b/apps/mimic-db/src/core/control-engine.ts @@ -8,7 +8,7 @@ import { type DatabasePermission, type DocumentPermission, } from "@voidhash/mimic-server/rpc"; -import { Effect } from "effect"; +import { Clock, Effect } from "effect"; import { normalizeSchemaObject, sanitizeValueForSchema } from "../document/schema.ts"; import { hashHex, randomId } from "./ids.ts"; @@ -24,8 +24,11 @@ const unauthorized = (message: string): UnauthorizedError => const forbidden = (message: string): ForbiddenError => new ForbiddenError({ code: "forbidden", message }); -const permissionRank = (permission: DatabasePermission): number => - permission === "read" ? 1 : permission === "write" ? 2 : 3; +const permissionRank = (permission: DatabasePermission): number => { + if (permission === "read") return 1; + if (permission === "write") return 2; + return 3; +}; interface CollectionView { readonly id: string; @@ -180,15 +183,17 @@ export const makeControlEngine = ( registry: MigrationRegistry = EmptyMigrationRegistry, ): ControlEngineApi => { const findCollection: ControlEngineApi["findCollection"] = (collectionId) => - store - .findCollectionById(collectionId) - .pipe( - Effect.flatMap((record) => - record - ? Effect.succeed(record) - : Effect.fail(notFound(`Collection not found: ${collectionId}`)), - ), - ); + store.findCollectionById(collectionId).pipe( + Effect.flatMap((record) => { + if (!record) return Effect.fail(notFound(`Collection not found: ${collectionId}`)); + return Effect.succeed(record); + }), + ); + + const listGrantRows = (userId: string | undefined) => { + if (!userId) return store.listGrants(); + return store.listGrantsByUser(userId); + }; return { store, @@ -207,33 +212,35 @@ export const makeControlEngine = ( authenticateBasic: (username, password) => store.findUserByUsername(username).pipe( - Effect.flatMap((user) => - !user || user.passwordHash !== hashHex(password) - ? Effect.fail(unauthorized("Invalid credentials")) - : Effect.succeed({ - userId: user.id, - username: user.username, - isSuperuser: user.isSuperuser, - }), - ), + Effect.flatMap((user) => { + if (!user || user.passwordHash !== hashHex(password)) { + return Effect.fail(unauthorized("Invalid credentials")); + } + return Effect.succeed({ + userId: user.id, + username: user.username, + isSuperuser: user.isSuperuser, + }); + }), ), authenticateDocumentToken: (token, collectionId, documentId, origin) => Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; const record = yield* store.findTokenByHash(hashHex(token)); if ( !record || record.collectionId !== collectionId || record.documentId !== documentId || record.usedAt !== null || - record.expiresAtMs < Date.now() + record.expiresAtMs < now ) { return yield* Effect.fail(unauthorized("Invalid document token")); } if (record.origins.length > 0 && origin !== null && !record.origins.includes(origin)) { return yield* Effect.fail(unauthorized("Document token origin is not allowed")); } - yield* store.markTokenUsed(record.id, Date.now()); + yield* store.markTokenUsed(record.id, now); return { tokenId: record.id, permission: record.permission }; }), @@ -337,13 +344,12 @@ export const makeControlEngine = ( ), deleteUser: (userId) => - store - .findUserById(userId) - .pipe( - Effect.flatMap((user) => - user ? store.deleteUser(userId) : Effect.fail(notFound(`User not found: ${userId}`)), - ), - ), + store.findUserById(userId).pipe( + Effect.flatMap((user) => { + if (!user) return Effect.fail(notFound(`User not found: ${userId}`)); + return store.deleteUser(userId); + }), + ), grantPermission: (userId, databaseId, permission) => Effect.gen(function* () { @@ -355,20 +361,17 @@ export const makeControlEngine = ( }), revokePermission: (userId, databaseId) => - store - .findGrant(userId, databaseId) - .pipe( - Effect.flatMap((grant) => - grant - ? store.removeGrant(userId, databaseId) - : Effect.fail( - notFound(`Grant not found for user ${userId} on database ${databaseId}`), - ), - ), - ), + store.findGrant(userId, databaseId).pipe( + Effect.flatMap((grant) => { + if (!grant) { + return Effect.fail(notFound(`Grant not found for user ${userId} on database ${databaseId}`)); + } + return store.removeGrant(userId, databaseId); + }), + ), listGrants: (userId) => - (userId ? store.listGrantsByUser(userId) : store.listGrants()).pipe( + listGrantRows(userId).pipe( Effect.map((rows) => rows.map((row) => ({ id: row.id, @@ -386,6 +389,7 @@ export const makeControlEngine = ( if (!index || index.collectionId !== collectionId || index.deletedAt !== null) { return yield* Effect.fail(notFound(`Document not found: ${documentId}`)); } + const now = yield* Clock.currentTimeMillis; const token = randomId(); yield* store.createToken({ id: randomId(), @@ -394,7 +398,7 @@ export const makeControlEngine = ( documentId, permission, origins, - expiresAtMs: Date.now() + (expiresInSeconds ?? 300) * 1000, + expiresAtMs: now + (expiresInSeconds ?? 300) * 1000, usedAt: null, }); return { token }; @@ -432,8 +436,10 @@ export const makeControlEngine = ( // When the caller can prove the object is unmaterialized, re-seed // it (fall through to registerDocument, which the caller pairs with // a fresh document-object create) instead of conflicting. - const materialized = - isMaterialized === undefined ? true : yield* isMaterialized(documentId); + if (isMaterialized === undefined) { + return yield* Effect.fail(conflict(`Document '${documentId}' already exists`)); + } + const materialized = yield* isMaterialized(documentId); if (materialized) { return yield* Effect.fail(conflict(`Document '${documentId}' already exists`)); } @@ -464,15 +470,14 @@ export const makeControlEngine = ( }), findDocument: (collectionId, documentId) => - store - .findDocumentIndex(documentId) - .pipe( - Effect.flatMap((index) => - index && index.collectionId === collectionId && index.deletedAt === null - ? Effect.void - : Effect.fail(notFound(`Document not found: ${documentId}`)), - ), - ), + store.findDocumentIndex(documentId).pipe( + Effect.flatMap((index) => { + if (index && index.collectionId === collectionId && index.deletedAt === null) { + return Effect.void; + } + return Effect.fail(notFound(`Document not found: ${documentId}`)); + }), + ), listDocumentIds: (collectionId) => findCollection(collectionId).pipe( @@ -480,6 +485,10 @@ export const makeControlEngine = ( Effect.map((rows) => rows.map((row) => row.documentId)), ), - markDocumentDeleted: (documentId) => store.markDocumentDeleted(documentId, Date.now()), + markDocumentDeleted: (documentId) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* store.markDocumentDeleted(documentId, now); + }), }; }; diff --git a/apps/mimic-db/src/core/document-engine.ts b/apps/mimic-db/src/core/document-engine.ts index fd5e765ca..45b9ab3b2 100644 --- a/apps/mimic-db/src/core/document-engine.ts +++ b/apps/mimic-db/src/core/document-engine.ts @@ -2,7 +2,6 @@ import { applyBatch, cloneValue, parseSchema, - type Command, type SchemaObject, type Value, } from "@voidhash/mimic-core"; @@ -12,7 +11,8 @@ import { runDirectMigration, type MigrationRegistry, } from "@voidhash/mimic-server/migrate"; -import { Effect, Result } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Clock, Effect, Result } from "effect"; import { sanitizeValueForSchema } from "../document/schema.ts"; import type { SubmitTransactionResponse, TransactionEnvelope } from "../document/transaction.ts"; @@ -103,10 +103,11 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi newSchema: parseSchema(target.schemaJson), value: current, }); - if (!result.ok || result.value === undefined) { - return yield* Effect.fail( - migrationFailed(result.ok ? "Migration produced an empty value" : result.error.message), - ); + if (!result.ok) { + return yield* Effect.fail(migrationFailed(result.error.message)); + } + if (result.value === undefined) { + return yield* Effect.fail(migrationFailed("Migration produced an empty value")); } current = result.value; current = yield* sanitize(target.schemaJson, current); @@ -120,7 +121,7 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi ): Effect.Effect => Effect.try({ try: () => sanitizeValueForSchema(schemaJson, value), - catch: (error) => migrationFailed(error instanceof Error ? error.message : String(error)), + catch: (error) => migrationFailed(causeMessage(error)), }); const load: DocumentEngineApi["load"] = () => @@ -168,8 +169,7 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi for (const migration of definition.migrations.slice(currentMigrationVersion)) { value = yield* Effect.try({ try: () => runDirectMigration(migration, value), - catch: (error) => - migrationFailed(error instanceof Error ? error.message : String(error)), + catch: (error) => migrationFailed(causeMessage(error)), }); migrationVersion = migration.version; changed = true; @@ -217,15 +217,16 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi const ctx = yield* deps.schema.getCollectionContext(loaded.collectionId); const schemaJson = ctx?.schemaJson; - const commands = envelope.commands as readonly Command[]; + const commands = envelope.commands; const applied = yield* Effect.result( Effect.try({ try: () => { const next = applyBatch(loaded.value, commands); - return schemaJson ? sanitizeValueForSchema(schemaJson, next) : next; + if (!schemaJson) return next; + return sanitizeValueForSchema(schemaJson, next); }, - catch: (error) => (error instanceof Error ? error : new Error(String(error))), + catch: causeMessage, }), ); @@ -234,7 +235,7 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi accepted: false, version: loaded.version, transactionId, - reason: applied.failure.message, + reason: applied.failure, }; } @@ -254,7 +255,10 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi const remove: DocumentEngineApi["remove"] = () => Effect.gen(function* () { const meta = yield* store.readMeta(); - if (meta) yield* store.setMeta({ deletedAt: Date.now() }); + if (meta) { + const deletedAt = yield* Clock.currentTimeMillis; + yield* store.setMeta({ deletedAt }); + } }); return { create, load, submit, remove }; diff --git a/apps/mimic-db/src/core/local-entity-host.ts b/apps/mimic-db/src/core/local-entity-host.ts index 00a03d4d6..367ec71a4 100644 --- a/apps/mimic-db/src/core/local-entity-host.ts +++ b/apps/mimic-db/src/core/local-entity-host.ts @@ -52,7 +52,7 @@ export const makeMemoryDurableEntityHost = (): DurableEntityHostShape => { alarm: { get: Effect.sync(() => state.alarm), set: (scheduledTime) => Effect.sync(() => void (state.alarm = scheduledTime)), - delete: Effect.sync(() => void (state.alarm = undefined)), + delete: Effect.sync(() => (state.alarm = undefined)), }, sessions: { get: (sessionId) => Effect.sync(() => state.sessions.get(sessionId)), diff --git a/apps/mimic-db/src/core/local-host-service.ts b/apps/mimic-db/src/core/local-host-service.ts index 8258880e9..4b82ef274 100644 --- a/apps/mimic-db/src/core/local-host-service.ts +++ b/apps/mimic-db/src/core/local-host-service.ts @@ -3,9 +3,10 @@ import { DurableEntityHost, makeDurableEntityAddress, } from "@voidhash/platform/DurableEntity"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer, Predicate } from "effect"; import type { MigrationRegistry } from "@voidhash/mimic-server/migrate"; import { NotFoundError } from "@voidhash/mimic-server/rpc"; +import { constant } from "@voidhash/lib/lang"; import { HostServiceTag, type HostService, type PresenceEntry } from "../app/hostService.ts"; import { getConfig, type MimicConfig } from "../config.ts"; @@ -39,6 +40,22 @@ import { randomId } from "./ids.ts"; const docKeyOf = (collectionId: string, documentId: string): string => `${collectionId}:${documentId}`; +/** + * Whether a websocket session attachment belongs to an authenticated + * collaborator. The entity host types attachments as `unknown`, so the shape is + * narrowed here instead of at every broadcast site. + */ +const isAuthenticatedSession = (attachment: unknown): attachment is SessionAttachment => { + if (!Predicate.hasProperty(attachment, "authenticated")) return false; + return attachment.authenticated === true; +}; + +/** Spreads `userId` into a presence entry only when the connection has one. */ +const optionalUserId = (userId: string | undefined): { readonly userId?: string } => { + if (userId === undefined) return {}; + return { userId }; +}; + interface StoredPresence { readonly entry: PresenceEntry; readonly expiresAt?: number; @@ -124,8 +141,8 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => sessions, (session) => Effect.gen(function* () { - const attachment = (yield* session.getAttachment) as SessionAttachment | undefined; - if (attachment?.authenticated !== true) return; + const attachment = yield* session.getAttachment; + if (!isAuthenticatedSession(attachment)) return; yield* session.send(encodeServerMessage(message)); }), { discard: true }, @@ -144,12 +161,13 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => entries: Map, entity: DurableEntityContext, ) => { - const expirations = [...entries.values()].flatMap(({ expiresAt }) => - expiresAt === undefined ? [] : [expiresAt], - ); - return expirations.length === 0 - ? Effect.void - : scheduleAlarmAt(entity, Math.min(...expirations)); + const expirations: number[] = []; + for (const { expiresAt } of entries.values()) { + if (expiresAt === undefined) continue; + expirations.push(expiresAt); + } + if (expirations.length === 0) return Effect.void; + return scheduleAlarmAt(entity, Math.min(...expirations)); }; const prunePresence = ( @@ -159,7 +177,7 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => ): Effect.Effect => Effect.gen(function* () { const entries = presenceOf(collectionId, documentId); - const now = Date.now(); + const now = yield* Clock.currentTimeMillis; for (const [connectionId, stored] of entries) { if (stored.expiresAt === undefined || stored.expiresAt > now) continue; entries.delete(connectionId); @@ -182,7 +200,8 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => if (current?.expiresAt === undefined) { return yield* Effect.fail(connectionNotFound(connectionId)); } - const next = { ...current, expiresAt: Date.now() + leaseMs }; + const now = yield* Clock.currentTimeMillis; + const next = { ...current, expiresAt: now + leaseMs }; entries.set(connectionId, next); yield* scheduleAlarmAt(entity, next.expiresAt); return next; @@ -276,14 +295,13 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => getDoc(collectionId, documentId) .load() .pipe( - Effect.map( - (loaded) => - ({ - id: documentId, - collectionId, - value: loaded.value, - version: loaded.version, - }) as const, + Effect.map((loaded) => + constant({ + id: documentId, + collectionId, + value: loaded.value, + version: loaded.version, + }), ), ), ), @@ -335,11 +353,12 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => const loaded = yield* getDoc(collectionId, documentId).load(); const entry: PresenceEntry = { data: connectionPresence, - ...(userId === undefined ? {} : { userId }), + ...optionalUserId(userId), }; + const now = yield* Clock.currentTimeMillis; const stored = { entry, - expiresAt: Date.now() + leaseMs, + expiresAt: now + leaseMs, }; presenceOf(collectionId, documentId).set(connectionId, stored); yield* scheduleAlarmAt(entity, stored.expiresAt); @@ -391,7 +410,7 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => ...transaction, actor: { connectionId, - ...(connection.entry.userId === undefined ? {} : { userId: connection.entry.userId }), + ...optionalUserId(connection.entry.userId), }, }; const result = yield* getDoc(collectionId, documentId).submit(envelope); @@ -408,7 +427,8 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => const removed = presenceOf(collectionId, documentId).delete(connectionId); if (removed) yield* broadcast(entity, presenceRemoveMessage(connectionId)); if (removed && presenceOf(collectionId, documentId).size === 0) { - yield* scheduleAlarmAt(entity, Date.now() + config.idleNotifyDebounceMs); + const now = yield* Clock.currentTimeMillis; + yield* scheduleAlarmAt(entity, now + config.idleNotifyDebounceMs); } }), ), diff --git a/apps/mimic-db/src/core/memory-store.ts b/apps/mimic-db/src/core/memory-store.ts index 271ac1f93..e8385fdb0 100644 --- a/apps/mimic-db/src/core/memory-store.ts +++ b/apps/mimic-db/src/core/memory-store.ts @@ -149,11 +149,13 @@ export const makeMemoryDocumentStore = (): DocumentStoreApi => { snapshots.push({ seq: 0, value: cloneValue(value), schemaVersion }); }), loadLatestSnapshot: () => - sync(() => - snapshots.length === 0 - ? undefined - : snapshots.reduce((best, row) => (row.seq >= best.seq ? row : best)), - ), + sync(() => { + if (snapshots.length === 0) return undefined; + return snapshots.reduce((best, row) => { + if (row.seq >= best.seq) return row; + return best; + }); + }), listCommandsAfter: (seq) => sync(() => commands.filter((row) => row.seq > seq).sort((a, b) => a.seq - b.seq)), appendCommands: (fromSeq, cmds: readonly Command[], txId) => diff --git a/apps/mimic-db/src/core/migration-registry.ts b/apps/mimic-db/src/core/migration-registry.ts index 01555b095..cb31c05b6 100644 --- a/apps/mimic-db/src/core/migration-registry.ts +++ b/apps/mimic-db/src/core/migration-registry.ts @@ -21,19 +21,28 @@ export const EmptyMigrationRegistryLive = Layer.succeed( EmptyMigrationRegistry, ); -const canonicalize = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(canonicalize); - if (typeof value !== "object" || value === null) return value; - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entry]) => [key, canonicalize(entry)]), - ); +/** + * Structural, key-order-independent equality for the JSON shapes serialized + * schemas are made of. Replaces a canonicalize-then-`JSON.stringify` compare. + */ +const schemasEqual = (left: unknown, right: unknown): boolean => { + if (left === right) return true; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + return left.every((entry, index) => schemasEqual(entry, right[index])); + } + if (typeof left !== "object" || typeof right !== "object") return false; + if (left === null || right === null) return false; + const leftEntries = Object.entries(left); + const rightEntries = new Map(Object.entries(right)); + if (leftEntries.length !== rightEntries.size) return false; + return leftEntries.every(([key, entry]) => { + if (!rightEntries.has(key)) return false; + return schemasEqual(entry, rightEntries.get(key)); + }); }; -const schemasEqual = (left: unknown, right: unknown): boolean => - JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); - /** Ensures every registry-owned database and collection is present and current. */ export const ensureMigrationRegistry = ( store: ControlStoreApi, diff --git a/apps/mimic-db/src/core/pg-store.ts b/apps/mimic-db/src/core/pg-store.ts index 7169c7194..1cf76ada1 100644 --- a/apps/mimic-db/src/core/pg-store.ts +++ b/apps/mimic-db/src/core/pg-store.ts @@ -1,6 +1,6 @@ import * as PgClient from "@effect/sql-pg/PgClient"; import { validateValue, type Command, type Value } from "@voidhash/mimic-core"; -import { Effect, Predicate, Redacted } from "effect"; +import { Effect, Predicate, Redacted, Schema } from "effect"; import { SqlClient, SqlError } from "effect/unstable/sql"; import type { CommandRow, DocumentMeta, DocumentStoreApi, SnapshotRow } from "./store.ts"; @@ -39,14 +39,31 @@ const clientLayer = (config: PgDocumentConfig) => // `@effect/sql-pg` runs statements through node-postgres's prepared path. +const JsonText = Schema.fromJsonString(Schema.Any); +const parseJsonText = Schema.decodeUnknownSync(JsonText); +const formatJsonText = Schema.encodeSync(JsonText); +const acceptAny = Schema.decodeUnknownSync(Schema.Any); + +/** + * Reads a `jsonb` column. node-postgres hands back either an already-parsed + * object or the raw JSON text depending on the driver's type parsers, so both + * shapes are normalised here through the same JSON codec. + */ +const decodeJsonColumn =
(input: unknown): A => { + if (Predicate.isString(input)) return parseJsonText(input); + return acceptAny(input); +}; + +/** Renders a value as the JSON text bound to a `jsonb` parameter. */ +const encodeJsonColumn = (value: unknown): string => formatJsonText(value); + const decodeValue = (input: unknown): Value => { - const decoded = typeof input === "string" ? (JSON.parse(input) as Value) : (input as Value); + const decoded = decodeJsonColumn(input); validateValue(decoded); return decoded; }; -const decodeCommand = (input: unknown): Command => - (typeof input === "string" ? JSON.parse(input) : input) as Command; +const decodeCommand = (input: unknown): Command => decodeJsonColumn(input); interface MetaSqlRow { readonly collectionId: string; @@ -56,6 +73,11 @@ interface MetaSqlRow { readonly snapshotSeq: number | string; readonly deletedAt: number | string | null; } +const nullableNumber = (value: number | string | null): number | null => { + if (value === null) return null; + return Number(value); +}; + interface SnapshotSqlRow { readonly seq: number | string; readonly schemaVersion: number; @@ -74,8 +96,10 @@ const UNDEFINED_COLUMN = "42703"; /** Postgres SQLSTATE for `insufficient_privilege`. */ const INSUFFICIENT_PRIVILEGE = "42501"; -const sqlErrorCauseProperty = (error: SqlError.SqlError, property: string): unknown => - Predicate.hasProperty(error.reason.cause, property) ? error.reason.cause[property] : undefined; +const sqlErrorCauseProperty = (error: SqlError.SqlError, property: string): unknown => { + if (!Predicate.hasProperty(error.reason.cause, property)) return undefined; + return error.reason.cause[property]; +}; /** Whether a `SqlError` is Postgres's `undefined_table` — the queried table is missing. */ export const isMissingTableError = (error: SqlError.SqlError): boolean => @@ -126,17 +150,16 @@ export const ensureDocumentTables = (config: PgDocumentConfig): Effect.Effect - isDdlDeniedError(createError) - ? Effect.die( - new Error( - `mimic document table "${table}" does not exist and the database denies runtime DDL ` + - `(the connected Postgres role cannot CREATE TABLE). Apply the ` + - `mimic_document_tables migration in packages/db/src/alchemy-migrations before serving traffic.`, - ), - ) - : Effect.fail(createError), - ), + Effect.catch((createError) => { + if (!isDdlDeniedError(createError)) return Effect.fail(createError); + return Effect.die( + new Error( + `mimic document table "${table}" does not exist and the database denies runtime DDL ` + + `(the connected Postgres role cannot CREATE TABLE). Apply the ` + + `mimic_document_tables migration in packages/db/src/alchemy-migrations before serving traffic.`, + ), + ); + }), ); }), ); @@ -162,15 +185,14 @@ export const ensureDocumentTables = (config: PgDocumentConfig): Effect.Effect - isDdlDeniedError(alterError) - ? Effect.die( - new Error( - "mimic_documents.migration_version is missing and the database denies runtime DDL. Apply the current database migrations before serving traffic.", - ), - ) - : Effect.fail(alterError), - ), + Effect.catch((alterError) => { + if (!isDdlDeniedError(alterError)) return Effect.fail(alterError); + return Effect.die( + new Error( + "mimic_documents.migration_version is missing and the database denies runtime DDL. Apply the current database migrations before serving traffic.", + ), + ); + }), ); }), ); @@ -251,7 +273,7 @@ export const makePgDocumentStore = ( migrationVersion: row.migrationVersion, currentSeq: Number(row.currentSeq), snapshotSeq: Number(row.snapshotSeq), - deletedAt: row.deletedAt === null ? null : Number(row.deletedAt), + deletedAt: nullableNumber(row.deletedAt), } satisfies DocumentMeta; }), ), @@ -269,7 +291,7 @@ export const makePgDocumentStore = ( `; yield* sql` INSERT INTO mimic_document_snapshots (document_id, seq, schema_version, state_json) - VALUES (${documentId}, 0, ${schemaVersion}, ${JSON.stringify(value)}::jsonb) + VALUES (${documentId}, 0, ${schemaVersion}, ${encodeJsonColumn(value)}::jsonb) `; }), ), @@ -284,13 +306,12 @@ export const makePgDocumentStore = ( ORDER BY seq DESC LIMIT 1 `; const row = rows[0]; - return row - ? ({ - seq: Number(row.seq), - value: decodeValue(row.stateJson), - schemaVersion: row.schemaVersion, - } satisfies SnapshotRow) - : undefined; + if (!row) return undefined; + return { + seq: Number(row.seq), + value: decodeValue(row.stateJson), + schemaVersion: row.schemaVersion, + } satisfies SnapshotRow; }), ), @@ -323,7 +344,7 @@ export const makePgDocumentStore = ( (command, index) => sql` INSERT INTO mimic_document_commands (document_id, seq, command_json, tx_id) - VALUES (${documentId}, ${fromSeq + 1 + index}, ${JSON.stringify(command)}::jsonb, ${txId}) + VALUES (${documentId}, ${fromSeq + 1 + index}, ${encodeJsonColumn(command)}::jsonb, ${txId}) `, { discard: true }, ); @@ -338,7 +359,7 @@ export const makePgDocumentStore = ( // (e.g. seq 0) with the migrated value + new schema version. yield* sql` INSERT INTO mimic_document_snapshots (document_id, seq, schema_version, state_json) - VALUES (${documentId}, ${seq}, ${schemaVersion}, ${JSON.stringify(value)}::jsonb) + VALUES (${documentId}, ${seq}, ${schemaVersion}, ${encodeJsonColumn(value)}::jsonb) ON CONFLICT (document_id, seq) DO UPDATE SET state_json = EXCLUDED.state_json, schema_version = EXCLUDED.schema_version `; @@ -353,7 +374,7 @@ export const makePgDocumentStore = ( Effect.gen(function* () { yield* sql` INSERT INTO mimic_document_snapshots (document_id, seq, schema_version, state_json) - VALUES (${documentId}, ${seq}, ${schemaVersion}, ${JSON.stringify(value)}::jsonb) + VALUES (${documentId}, ${seq}, ${schemaVersion}, ${encodeJsonColumn(value)}::jsonb) ON CONFLICT (document_id, seq) DO UPDATE SET state_json = EXCLUDED.state_json, schema_version = EXCLUDED.schema_version `; diff --git a/apps/mimic-db/src/document/schema.ts b/apps/mimic-db/src/document/schema.ts index e5fafee68..e515861fe 100644 --- a/apps/mimic-db/src/document/schema.ts +++ b/apps/mimic-db/src/document/schema.ts @@ -1,3 +1,4 @@ +import { causeMessage } from "@voidhash/lib/lang"; import { parseSchema, serializeSchema, @@ -7,27 +8,47 @@ import { type Value, } from "@voidhash/mimic-core"; import { InvalidSchemaError, InvalidValueError } from "@voidhash/mimic-server/rpc"; +import { Effect } from "effect"; -export const normalizeSchemaObject = (input: unknown): SchemaObject => { - try { - return serializeSchema(parseSchema(input)); - } catch (error) { - throw new InvalidSchemaError({ - code: "invalid_schema", - message: error instanceof Error ? error.message : String(error), - }); - } -}; +import { decodeDocumentValue } from "./transaction.ts"; -export const sanitizeValueForSchema = (schemaObject: SchemaObject, input: unknown): Value => { - try { - validateValue(input as Value); - const schema = parseSchema(schemaObject); - return validateSchemaValue(schema, input as Value) as Value; - } catch (error) { - throw new InvalidValueError({ - code: "invalid_value", - message: error instanceof Error ? error.message : String(error), - }); - } -}; +const normalizeSchemaObjectEffect = ( + input: unknown, +): Effect.Effect => + Effect.try({ + try: () => serializeSchema(parseSchema(input)), + catch: (error) => + new InvalidSchemaError({ code: "invalid_schema", message: causeMessage(error) }), + }); + +/** + * Parses and re-serializes a collection schema, failing with `InvalidSchemaError`. + * + * Stays synchronous — the control engine builds schema objects inside plain + * `Effect.try` blocks — so the tagged failure is surfaced by `Effect.runSync`, + * which rethrows the very error the effect failed with. + */ +export const normalizeSchemaObject = (input: unknown): SchemaObject => + Effect.runSync(normalizeSchemaObjectEffect(input)); + +const sanitizeValueForSchemaEffect = ( + schemaObject: SchemaObject, + input: unknown, +): Effect.Effect => + Effect.try({ + try: () => { + const value = decodeDocumentValue(input); + validateValue(value); + const schema = parseSchema(schemaObject); + // `validate` is typed `Value | undefined` for the default-materialization + // path it shares with absent values; a provided value always validates to + // a value. + return decodeDocumentValue(validateSchemaValue(schema, value)); + }, + catch: (error) => + new InvalidValueError({ code: "invalid_value", message: causeMessage(error) }), + }); + +/** Validates a value against a collection schema, failing with `InvalidValueError`. */ +export const sanitizeValueForSchema = (schemaObject: SchemaObject, input: unknown): Value => + Effect.runSync(sanitizeValueForSchemaEffect(schemaObject, input)); diff --git a/apps/mimic-db/src/document/transaction.ts b/apps/mimic-db/src/document/transaction.ts index 7fa58ef40..c3f883687 100644 --- a/apps/mimic-db/src/document/transaction.ts +++ b/apps/mimic-db/src/document/transaction.ts @@ -1,4 +1,4 @@ -import type { Command } from "@voidhash/mimic-core"; +import type { Command, Value } from "@voidhash/mimic-core"; import { Schema } from "effect"; export interface TransactionActor { @@ -21,10 +21,18 @@ export interface SubmitTransactionResponse { readonly reason?: string; } +/** + * Commands cross the wire as opaque JSON. Their shape is dynamic (nine command + * kinds over user-defined paths) and the document engine validates every one as + * it applies it, so decoding here stays lossless and accepts anything — the + * declaration only carries the structured type across the boundary. + */ +const CommandFromWire = Schema.declare((_value): _value is Command => true); + export const TransactionEnvelopeSchema = Schema.Struct({ id: Schema.String, baseVersion: Schema.Number, - commands: Schema.Array(Schema.Unknown), + commands: Schema.Array(CommandFromWire), submittedAt: Schema.optional(Schema.String), actor: Schema.optional( Schema.Struct({ @@ -42,4 +50,15 @@ export const SubmitTransactionResponseSchema = Schema.Struct({ }); export const decodeTransactionEnvelope = (input: unknown): TransactionEnvelope => - Schema.decodeUnknownSync(TransactionEnvelopeSchema)(input) as TransactionEnvelope; + Schema.decodeUnknownSync(TransactionEnvelopeSchema)(input); + +/** + * Same rationale as {@link CommandFromWire} for document and presence values: + * the RPC layer carries them as opaque JSON (`Schema.Unknown`) because their + * shape follows a runtime-defined collection schema, and the host validates + * them against that schema. + */ +const ValueFromWire = Schema.declare((_value): _value is Value => true); + +/** Carries an opaque wire value into the structured `Value` type. */ +export const decodeDocumentValue = Schema.decodeUnknownSync(ValueFromWire); diff --git a/apps/mimic-db/src/entrypoints/standalone/main.ts b/apps/mimic-db/src/entrypoints/standalone/main.ts index 17c1b9a2d..b49a070e5 100644 --- a/apps/mimic-db/src/entrypoints/standalone/main.ts +++ b/apps/mimic-db/src/entrypoints/standalone/main.ts @@ -1,7 +1,8 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- the created server is handed to NodeHttpServer.layer, which requires a real http.Server instance. import { createServer } from "node:http"; import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; -import { Layer } from "effect"; +import { Config, Effect, Layer } from "effect"; import { LocalHostServiceDefault } from "../../core/local-host-service.ts"; import { makeHttpApp } from "../../http/rpc-app.ts"; @@ -11,11 +12,15 @@ import { makeHttpApp } from "../../http/rpc-app.ts"; * in-memory `HostService`. Production entry points provide persistent platform * adapters over the same application. */ -const port = Number(process.env.PORT ?? "5001"); - NodeRuntime.runMain( - makeHttpApp(LocalHostServiceDefault).pipe( - Layer.provide(NodeHttpServer.layer(() => createServer(), { port })), - Layer.launch, - ) as never, + Effect.gen(function* () { + const port = yield* Config.number("PORT").pipe(Config.withDefault(5001)); + // `HttpServerRequest` leaks out of the RPC handler layer (handlers read the + // incoming request); the RPC server supplies it per call at runtime. + // oxlint-disable-next-line effect/noAs -- see the comment above: HttpServerRequest leaks out of the RPC handler layer into the launched program's requirements even though the RPC server supplies it per call; the assertion is the upstream typing escape hatch. + return yield* (makeHttpApp(LocalHostServiceDefault).pipe( + Layer.provide(NodeHttpServer.layer(() => createServer(), { port })), + Layer.launch, + ) as Effect.Effect); + }), ); diff --git a/apps/mimic-db/src/worker/durable-host-service.ts b/apps/mimic-db/src/worker/durable-host-service.ts index 711106900..046ace5b2 100644 --- a/apps/mimic-db/src/worker/durable-host-service.ts +++ b/apps/mimic-db/src/worker/durable-host-service.ts @@ -70,6 +70,15 @@ export interface DurableHostServiceDeps { const notFound = (message: string): NotFoundError => new NotFoundError({ code: "not_found", message }); +/** Builds the presence entry for a headless connection, omitting an absent `userId`. */ +const presenceEntry = ( + data: Value, + userId: string | undefined, +): { readonly data: Value; readonly userId?: string } => { + if (userId === undefined) return { data }; + return { data, userId }; +}; + const isSubmitResponse = ( value: SubmitTransactionResponse | { notFound: true }, ): value is SubmitTransactionResponse => !("notFound" in value); @@ -161,16 +170,15 @@ export const makeDurableHostService = (deps: DurableHostServiceDeps): HostServic docStub(collectionId, documentId) .getSnapshot() .pipe( - Effect.map((snapshot) => - snapshot.found - ? ({ - id: documentId, - collectionId, - value: snapshot.value, - version: snapshot.version, - } satisfies DocumentSnapshotResponse) - : undefined, - ), + Effect.map((snapshot) => { + if (!snapshot.found) return undefined; + return { + id: documentId, + collectionId, + value: snapshot.value, + version: snapshot.version, + } satisfies DocumentSnapshotResponse; + }), ), ); return snapshots.filter((entry): entry is DocumentSnapshotResponse => entry !== undefined); @@ -209,7 +217,7 @@ export const makeDurableHostService = (deps: DurableHostServiceDeps): HostServic } const snapshot = yield* docStub(collectionId, documentId).openConnection( connectionId, - { data: presence, ...(userId === undefined ? {} : { userId }) }, + presenceEntry(presence, userId), connectionLeaseMs(leaseMs), ); if (!("found" in snapshot)) { @@ -221,34 +229,37 @@ export const makeDurableHostService = (deps: DurableHostServiceDeps): HostServic docStub(collectionId, documentId) .heartbeatConnection(connectionId, connectionLeaseMs(leaseMs)) .pipe( - Effect.flatMap((found) => - found ? Effect.void : Effect.fail(notFound(`Connection not found: ${connectionId}`)), - ), + Effect.flatMap((found) => { + if (found) return Effect.void; + return Effect.fail(notFound(`Connection not found: ${connectionId}`)); + }), ), getConnectionDocument: (collectionId, documentId, connectionId, leaseMs) => docStub(collectionId, documentId) .getConnectionSnapshot(connectionId, connectionLeaseMs(leaseMs)) .pipe( - Effect.flatMap((snapshot) => - "found" in snapshot - ? Effect.succeed({ - id: documentId, - collectionId, - value: snapshot.value, - version: snapshot.version, - }) - : Effect.fail(notFound(`Connection not found: ${connectionId}`)), - ), + Effect.flatMap((snapshot) => { + if (!("found" in snapshot)) { + return Effect.fail(notFound(`Connection not found: ${connectionId}`)); + } + return Effect.succeed({ + id: documentId, + collectionId, + value: snapshot.value, + version: snapshot.version, + }); + }), ), submitConnectionTransaction: (collectionId, documentId, connectionId, transaction, leaseMs) => docStub(collectionId, documentId) .submitConnection(connectionId, connectionLeaseMs(leaseMs), transaction) .pipe( - Effect.flatMap((result) => - "notFound" in result - ? Effect.fail(notFound(`Connection not found: ${connectionId}`)) - : Effect.succeed(result), - ), + Effect.flatMap((result) => { + if ("notFound" in result) { + return Effect.fail(notFound(`Connection not found: ${connectionId}`)); + } + return Effect.succeed(result); + }), ), detachConnection: (collectionId, documentId, connectionId) => docStub(collectionId, documentId).closeConnection(connectionId).pipe(Effect.asVoid), diff --git a/apps/mimic-db/src/ws/document-session.ts b/apps/mimic-db/src/ws/document-session.ts index 62e01f857..a816129f5 100644 --- a/apps/mimic-db/src/ws/document-session.ts +++ b/apps/mimic-db/src/ws/document-session.ts @@ -1,3 +1,4 @@ +import { causeMessage } from "@voidhash/lib/lang"; import type { Value } from "@voidhash/mimic-core"; import { Effect } from "effect"; @@ -229,9 +230,7 @@ export const handleDocumentSocketMessage = ( } } }).pipe( - Effect.catch((error) => - ctx.send(socket, errorMessage(error instanceof Error ? error.message : String(error))), - ), + Effect.catch((error) => ctx.send(socket, errorMessage(causeMessage(error)))), ); /** diff --git a/apps/mimic-db/src/ws/protocol.ts b/apps/mimic-db/src/ws/protocol.ts index ee4497ba9..a04e8d88f 100644 --- a/apps/mimic-db/src/ws/protocol.ts +++ b/apps/mimic-db/src/ws/protocol.ts @@ -1,4 +1,5 @@ -import { Effect } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Data, Effect, Schema } from "effect"; import type { Value } from "@voidhash/mimic-core"; import type { PresenceEntry } from "../app/hostService.ts"; @@ -103,15 +104,35 @@ export type ServerMessage = | PresenceRemoveMessage | PresenceSnapshotMessage; +/** A client frame that is not valid JSON. Never leaves the socket handler. */ +export class MalformedClientMessageError extends Data.TaggedError("MalformedClientMessageError")<{ + readonly message: string; +}> {} + +/** + * The wire codec for client frames. Message shape is *not* validated here: + * `handleDocumentSocketMessage` dispatches on `type` and rejects anything it + * does not recognize, so decoding stays lossless for forward-compatible fields. + */ +const ClientMessageFromJson = Schema.fromJsonString( + Schema.declare((_value): _value is ClientMessage => true), +); + +const ServerMessageToJson = Schema.fromJsonString( + Schema.declare((_value): _value is ServerMessage => true), +); + +const decodeText = (data: string | Uint8Array): string => { + if (typeof data === "string") return data; + return new TextDecoder().decode(data); +}; + export const parseClientMessage = ( data: string | Uint8Array, -): Effect.Effect => - Effect.try({ - try: () => { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); - return JSON.parse(text) as ClientMessage; - }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); - -export const encodeServerMessage = (message: ServerMessage): string => JSON.stringify(message); +): Effect.Effect => + Schema.decodeUnknownEffect(ClientMessageFromJson)(decodeText(data)).pipe( + Effect.mapError((issue) => new MalformedClientMessageError({ message: causeMessage(issue) })), + ); + +export const encodeServerMessage = (message: ServerMessage): string => + Schema.encodeSync(ServerMessageToJson)(message); diff --git a/apps/mimic-db/src/ws/session-registry.ts b/apps/mimic-db/src/ws/session-registry.ts index 2581e598b..ef93896a4 100644 --- a/apps/mimic-db/src/ws/session-registry.ts +++ b/apps/mimic-db/src/ws/session-registry.ts @@ -1,3 +1,5 @@ +import { Clock, Effect } from "effect"; + /** Timer seam so tests can drive the auth deadline deterministically. */ export interface SessionRegistryTimers { readonly now: () => number; @@ -44,13 +46,23 @@ export interface SessionRegistry { } const defaultTimers: SessionRegistryTimers = { - now: () => Date.now(), + now: () => Effect.runSync(Clock.currentTimeMillis), schedule: (fn, ms) => { - const handle = setTimeout(fn, ms); - return () => clearTimeout(handle); + const fiber = Effect.runFork( + Effect.gen(function* () { + yield* Effect.sleep(ms); + fn(); + }), + ); + return () => fiber.interruptUnsafe(); }, }; +const elapsedSince = (timers: SessionRegistryTimers, connectedAt: number | undefined): number => { + if (connectedAt === undefined) return 0; + return timers.now() - connectedAt; +}; + export const makeSessionRegistry = ( options: SessionRegistryOptions, ): SessionRegistry => { @@ -80,8 +92,7 @@ export const makeSessionRegistry = ( sessions.set(connectionId, socket); return; } - const elapsed = connectedAt === undefined ? 0 : timers.now() - connectedAt; - const remaining = options.authDeadlineMs - elapsed; + const remaining = options.authDeadlineMs - elapsedSince(timers, connectedAt); if (remaining <= 0) { options.close(socket); return; diff --git a/apps/mimic-db/tests/durable-entity-host.test.ts b/apps/mimic-db/tests/durable-entity-host.test.ts index 14d6ffb82..9de8f8be0 100644 --- a/apps/mimic-db/tests/durable-entity-host.test.ts +++ b/apps/mimic-db/tests/durable-entity-host.test.ts @@ -5,89 +5,92 @@ import { describe, expect, test } from "vitest"; import { makeMemoryDurableEntityHost } from "../src/core/local-entity-host.ts"; describe("memory DurableEntity host", () => { - test("serializes operations for the same address in submission order", async () => { - const host = makeMemoryDurableEntityHost(); - const address = makeDurableEntityAddress("document", "one"); - const events: Array = []; - let active = 0; - let overlap = false; + test("serializes operations for the same address in submission order", () => + Effect.runPromise( + Effect.gen(function* () { + const host = makeMemoryDurableEntityHost(); + const address = makeDurableEntityAddress("document", "one"); + const events: Array = []; + let active = 0; + let overlap = false; - const operation = (name: string) => - host.run(address, () => - Effect.gen(function* () { - active += 1; - overlap ||= active > 1; - events.push(`${name}:start`); - yield* Effect.sleep("20 millis"); - events.push(`${name}:end`); - active -= 1; - }), - ); + const operation = (name: string) => + host.run(address, () => + Effect.gen(function* () { + active += 1; + overlap ||= active > 1; + events.push(`${name}:start`); + yield* Effect.sleep("20 millis"); + events.push(`${name}:end`); + active -= 1; + }), + ); - await Effect.runPromise( - Effect.all([operation("first"), operation("second")], { concurrency: "unbounded" }), - ); + yield* Effect.all([operation("first"), operation("second")], { + concurrency: "unbounded", + }); - expect(overlap).toBe(false); - expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); - }); + expect(overlap).toBe(false); + expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); + }), + )); - test("allows different entity addresses to run concurrently", async () => { - const host = makeMemoryDurableEntityHost(); - let active = 0; - let maxActive = 0; + test("allows different entity addresses to run concurrently", () => + Effect.runPromise( + Effect.gen(function* () { + const host = makeMemoryDurableEntityHost(); + let active = 0; + let maxActive = 0; - const operation = (id: string) => - host.run(makeDurableEntityAddress("document", id), () => - Effect.gen(function* () { - active += 1; - maxActive = Math.max(maxActive, active); - yield* Effect.sleep("20 millis"); - active -= 1; - }), - ); + const operation = (id: string) => + host.run(makeDurableEntityAddress("document", id), () => + Effect.gen(function* () { + active += 1; + maxActive = Math.max(maxActive, active); + yield* Effect.sleep("20 millis"); + active -= 1; + }), + ); - await Effect.runPromise( - Effect.all([operation("one"), operation("two")], { concurrency: "unbounded" }), - ); + yield* Effect.all([operation("one"), operation("two")], { concurrency: "unbounded" }); - expect(maxActive).toBe(2); - }); + expect(maxActive).toBe(2); + }), + )); - test("retains entity-local key-value, alarm, and session state", async () => { - const host = makeMemoryDurableEntityHost(); - const address = makeDurableEntityAddress("document", "one"); - let attachment: unknown; - const session: DurableEntitySession = { - id: "session-1", - send: () => Effect.void, - close: () => Effect.void, - getAttachment: Effect.sync(() => attachment), - setAttachment: (value) => Effect.sync(() => void (attachment = value)), - }; + test("retains entity-local key-value, alarm, and session state", () => + Effect.runPromise( + Effect.gen(function* () { + const host = makeMemoryDurableEntityHost(); + const address = makeDurableEntityAddress("document", "one"); + let attachment: unknown; + const session: DurableEntitySession = { + id: "session-1", + send: () => Effect.void, + close: () => Effect.void, + getAttachment: Effect.sync(() => attachment), + setAttachment: (value) => Effect.sync(() => void (attachment = value)), + }; - await Effect.runPromise( - host.run(address, (entity) => - Effect.gen(function* () { - yield* entity.keyValue.put("seq", 7); - yield* entity.alarm.set(1234); - yield* entity.sessions.attach(session); - }), - ), - ); + yield* host.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.put("seq", 7); + yield* entity.alarm.set(1234); + yield* entity.sessions.attach(session); + }), + ); - const state = await Effect.runPromise( - host.run(address, (entity) => - Effect.all({ - seq: entity.keyValue.get("seq"), - alarm: entity.alarm.get, - sessions: entity.sessions.list, - }), - ), - ); + const state = yield* host.run(address, (entity) => + Effect.all({ + seq: entity.keyValue.get("seq"), + alarm: entity.alarm.get, + sessions: entity.sessions.list, + }), + ); - expect(state.seq).toBe(7); - expect(state.alarm).toBe(1234); - expect(state.sessions.map(({ id }) => id)).toEqual(["session-1"]); - }); + expect(state.seq).toBe(7); + expect(state.alarm).toBe(1234); + expect(state.sessions.map(({ id }) => id)).toEqual(["session-1"]); + }), + )); }); diff --git a/apps/mimic-db/tests/helpers.ts b/apps/mimic-db/tests/helpers.ts index c394e87f1..f1492fcef 100644 --- a/apps/mimic-db/tests/helpers.ts +++ b/apps/mimic-db/tests/helpers.ts @@ -1,8 +1,14 @@ import { Effect } from "effect"; -import type { Value } from "@voidhash/mimic-core"; +import type { + NumberValue, + ObjectSchema, + ObjectValue, + StringValue, + Value, +} from "@voidhash/mimic-core"; import type { MigrationRegistry } from "@voidhash/mimic-server/migrate"; -import type { HostService } from "../src/app/hostService.ts"; +import type { HostService, HostServiceTag } from "../src/app/hostService.ts"; import { getConfig } from "../src/config.ts"; import { makeControlEngine, type ControlEngineApi } from "../src/core/control-engine.ts"; import { @@ -19,8 +25,8 @@ import { EmptyMigrationRegistry, ensureMigrationRegistry } from "../src/core/mig * backend. Compose the whole flow into a single program so control + document * state persists across the steps. */ -export const runHost = (program: Effect.Effect): Promise => - Effect.runPromise(program.pipe(Effect.provide(LocalHostServiceDefault)) as Effect.Effect); +export const runHost = (program: Effect.Effect): Promise => + Effect.runPromise(program.pipe(Effect.provide(LocalHostServiceDefault))); /** * Run `program` against a fresh in-memory host, giving it BOTH the host service @@ -33,7 +39,7 @@ export const runHostWithControl = ( program: (deps: { readonly host: HostService; readonly control: ControlEngineApi; - }) => Effect.Effect, + }) => Effect.Effect, ): Promise => Effect.runPromise( Effect.gen(function* () { @@ -49,13 +55,13 @@ export const runHostWithControl = ( config, }); return yield* program({ control, host }); - }).pipe(Effect.provide(MemoryDocumentStoreFactoryLive)) as Effect.Effect, + }).pipe(Effect.provide(MemoryDocumentStoreFactoryLive)), ); /** Runs a program against an in-memory host configured with deployed migrations. */ export const runHostWithRegistry = ( migrations: MigrationRegistry, - program: (host: HostService) => Effect.Effect, + program: (host: HostService) => Effect.Effect, ): Promise => Effect.runPromise( Effect.gen(function* () { @@ -73,27 +79,27 @@ export const runHostWithRegistry = ( config, }); return yield* program(host); - }).pipe(Effect.provide(MemoryDocumentStoreFactoryLive)) as Effect.Effect, + }).pipe(Effect.provide(MemoryDocumentStoreFactoryLive)), ); /** A minimal mimic-core object schema with a single string field. */ -export const titleSchema = { - kind: "object" as const, - fields: { title: { kind: "string" as const, default: { kind: "string" as const, value: "" } } }, +export const titleSchema: ObjectSchema = { + kind: "object", + fields: { title: { kind: "string", default: { kind: "string", value: "" } } }, }; /** v2 of {@link titleSchema}: adds a defaulted `count` number field. */ -export const titleCountSchema = { - kind: "object" as const, +export const titleCountSchema: ObjectSchema = { + kind: "object", fields: { - title: { kind: "string" as const, default: { kind: "string" as const, value: "" } }, - count: { kind: "number" as const, default: { kind: "number" as const, value: 0 } }, + title: { kind: "string", default: { kind: "string", value: "" } }, + count: { kind: "number", default: { kind: "number", value: 0 } }, }, }; -export const objectValue = (fields: Record) => ({ - kind: "object" as const, +export const objectValue = (fields: Record): ObjectValue => ({ + kind: "object", fields, }); -export const stringValue = (value: string) => ({ kind: "string" as const, value }); -export const numberValue = (value: number) => ({ kind: "number" as const, value }); +export const stringValue = (value: string): StringValue => ({ kind: "string", value }); +export const numberValue = (value: number): NumberValue => ({ kind: "number", value }); diff --git a/apps/mimic-db/tests/integration/host-flow.test.ts b/apps/mimic-db/tests/integration/host-flow.test.ts index 5168f9e77..9f5cf941e 100644 --- a/apps/mimic-db/tests/integration/host-flow.test.ts +++ b/apps/mimic-db/tests/integration/host-flow.test.ts @@ -1,3 +1,4 @@ +import type { Value } from "@voidhash/mimic-core"; import { Effect, Result } from "effect"; import { describe, expect, it } from "vitest"; @@ -5,6 +6,14 @@ import { HostServiceTag } from "../../src/app/hostService.ts"; import type { TransactionEnvelope } from "../../src/document/transaction.ts"; import { objectValue, runHost, runHostWithControl, stringValue, titleSchema } from "../helpers.ts"; +/** Reads the `title` string field out of a document value. */ +const titleOf = (value: Value): string | undefined => { + if (value.kind !== "object") return undefined; + const title = value.fields["title"]; + if (title?.kind !== "string") return undefined; + return title.value; +}; + describe("mimic-db host flow (durable-entity engine, in-memory)", () => { it("bootstraps the root user and authenticates", () => runHost( @@ -31,10 +40,10 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { ); expect(created.id).toBe("doc-1"); expect(created.version).toBe(1); - expect((created.value as any).fields.title.value).toBe("Hello"); + expect(titleOf(created.value)).toBe("Hello"); const fetched = yield* host.getDocument(collection.id, "doc-1"); - expect((fetched.value as any).fields.title.value).toBe("Hello"); + expect(titleOf(fetched.value)).toBe("Hello"); expect(fetched.version).toBe(1); }), )); @@ -68,7 +77,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { expect(created.id).toBe("doc-1"); const fetched = yield* host.getDocument(recreated.id, "doc-1"); - expect((fetched.value as any).fields.title.value).toBe("Fresh"); + expect(titleOf(fetched.value)).toBe("Fresh"); }), )); @@ -98,7 +107,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { expect(created.id).toBe("doc-1"); const fetched = yield* host.getDocument(collection.id, "doc-1"); - expect((fetched.value as any).fields.title.value).toBe("Fresh"); + expect(titleOf(fetched.value)).toBe("Fresh"); }), )); @@ -143,7 +152,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { id: "tx-1", baseVersion: 1, commands: [ - { kind: "object.set", path: [], key: "title", value: stringValue("Updated") } as any, + { kind: "object.set", path: [], key: "title", value: stringValue("Updated") }, ], }; const result = yield* host.submitTransaction(collection.id, "doc-1", tx); @@ -151,7 +160,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { expect(result.version).toBe(2); const fetched = yield* host.getDocument(collection.id, "doc-1"); - expect((fetched.value as any).fields.title.value).toBe("Updated"); + expect(titleOf(fetched.value)).toBe("Updated"); expect(fetched.version).toBe(2); }), )); @@ -183,13 +192,13 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { id: "tx-connected", baseVersion: 1, commands: [ - { kind: "object.set", path: [], key: "title", value: stringValue("Updated") } as any, + { kind: "object.set", path: [], key: "title", value: stringValue("Updated") }, ], }); expect(result).toMatchObject({ accepted: true, version: 2 }); const connected = yield* host.getConnectionDocument(collection.id, "doc-1", "edit-1"); - expect((connected.value as any).fields.title.value).toBe("Updated"); + expect(titleOf(connected.value)).toBe("Updated"); yield* host.detachConnection(collection.id, "doc-1", "edit-1"); expect((yield* host.getPresenceSnapshot(collection.id, "doc-1")).presences).toEqual({}); @@ -216,7 +225,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { id: "tx-stale", baseVersion: 99, commands: [ - { kind: "object.set", path: [], key: "title", value: stringValue("Nope") } as any, + { kind: "object.set", path: [], key: "title", value: stringValue("Nope") }, ], }; const result = yield* host.submitTransaction(collection.id, "doc-1", tx); diff --git a/apps/mimic-db/tests/unit/direct-migration.test.ts b/apps/mimic-db/tests/unit/direct-migration.test.ts index 2af9647cc..33e8d5418 100644 --- a/apps/mimic-db/tests/unit/direct-migration.test.ts +++ b/apps/mimic-db/tests/unit/direct-migration.test.ts @@ -5,7 +5,7 @@ import { type AnyDirectMigration, } from "@voidhash/mimic-server/migrate"; import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; -import { Effect } from "effect"; +import { Cause, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { makeDocumentEngine } from "../../src/core/document-engine.ts"; @@ -61,158 +61,175 @@ const schema = { }; describe("document direct migrations", () => { - it("commits a pending migration once before returning the document", async () => { - const store = makeMemoryDocumentStore(); - let commits = 0; - const trackedStore = { - ...store, - commitMigration: (...args: Parameters) => { - commits += 1; - return store.commitMigration(...args); - }, - }; - const engine = makeDocumentEngine({ - store: trackedStore, - migrations: registryWith(addCount), - schema, - snapshotEveryCommands: 100, - }); - - await Effect.runPromise( - engine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0), - ); - const first = await Effect.runPromise(engine.load()); - const second = await Effect.runPromise(engine.load()); - - expect(Current.decode(first.value)).toEqual({ title: "Hello", count: 7 }); - expect(Current.decode(second.value)).toEqual({ title: "Hello", count: 7 }); - expect(first.migrationVersion).toBe(1); - expect(commits).toBe(1); - }); - - it("leaves persistence unchanged when migration code fails", async () => { - const store = makeMemoryDocumentStore(); - const failing = registryWith(() => - defineMigration({ - version: 1, - name: "fail", - from: Original, - to: Current, - migrate: () => { - throw new Error("boom"); - }, + it("commits a pending migration once before returning the document", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + let commits = 0; + const trackedStore = { + ...store, + commitMigration: (...args: Parameters) => { + commits += 1; + return store.commitMigration(...args); + }, + }; + const engine = makeDocumentEngine({ + store: trackedStore, + migrations: registryWith(addCount), + schema, + snapshotEveryCommands: 100, + }); + + yield* engine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0); + const first = yield* engine.load(); + const second = yield* engine.load(); + + expect(Current.decode(first.value)).toEqual({ title: "Hello", count: 7 }); + expect(Current.decode(second.value)).toEqual({ title: "Hello", count: 7 }); + expect(first.migrationVersion).toBe(1); + expect(commits).toBe(1); }), - ); - const failingEngine = makeDocumentEngine({ - store, - migrations: failing, - schema, - snapshotEveryCommands: 100, - }); - await Effect.runPromise( - failingEngine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0), - ); - - await expect(Effect.runPromise(failingEngine.load())).rejects.toThrow("boom"); - - const fixedEngine = makeDocumentEngine({ - store, - migrations: registryWith(addCount), - schema, - snapshotEveryCommands: 100, - }); - const loaded = await Effect.runPromise(fixedEngine.load()); - expect(Current.decode(loaded.value)).toEqual({ title: "Hello", count: 7 }); - }); - - it("serializes concurrent opens so a migration commits once", async () => { - const store = makeMemoryDocumentStore(); - let commits = 0; - const engine = makeDocumentEngine({ - store: { - ...store, - commitMigration: (...args: Parameters) => { - commits += 1; - return store.commitMigration(...args); - }, - }, - migrations: registryWith(addCount), - schema, - snapshotEveryCommands: 100, - }); - await Effect.runPromise( - engine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0), - ); - - const entities = makeMemoryDurableEntityHost(); - const address = makeDurableEntityAddress("mimic-document", "doc-1"); - const [first, second] = await Promise.all([ - Effect.runPromise(entities.run(address, engine.load)), - Effect.runPromise(entities.run(address, engine.load)), - ]); - - expect(Current.decode(first.value)).toEqual({ title: "Hello", count: 7 }); - expect(Current.decode(second.value)).toEqual({ title: "Hello", count: 7 }); - expect(commits).toBe(1); - }); - - it("rejects legacy executable source without changing persistence", async () => { - const store = makeMemoryDocumentStore(); - const engine = makeDocumentEngine({ - store, - migrations: EmptyMigrationRegistry, - schema: { - getCollectionContext: () => - Effect.succeed({ - collectionId: "collection-1", - databaseName: "example", - collectionName: "documents", - schemaJson: serializeSchema(Current.schema), - schemaVersion: 2, - versions: [ - { - collectionId: "collection-1", - version: 1, - schemaJson: serializeSchema(Original.schema), - dataMigrationSource: null, - }, - { + )); + + it("leaves persistence unchanged when migration code fails", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + const failing = registryWith(() => + defineMigration({ + version: 1, + name: "fail", + from: Original, + to: Current, + // `migrate` is a synchronous callback, so the simulated failure is + // raised as a defect through `Effect.die` instead of a `throw`. + migrate: () => Effect.runSync(Effect.die(new Error("boom"))), + }), + ); + const failingEngine = makeDocumentEngine({ + store, + migrations: failing, + schema, + snapshotEveryCommands: 100, + }); + yield* failingEngine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0); + + const failure = yield* failingEngine.load().pipe( + Effect.as("loaded"), + Effect.catchCause((cause) => Effect.succeed(Cause.pretty(cause))), + ); + expect(failure).toContain("boom"); + + const fixedEngine = makeDocumentEngine({ + store, + migrations: registryWith(addCount), + schema, + snapshotEveryCommands: 100, + }); + const loaded = yield* fixedEngine.load(); + expect(Current.decode(loaded.value)).toEqual({ title: "Hello", count: 7 }); + }), + )); + + it("serializes concurrent opens so a migration commits once", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + let commits = 0; + const engine = makeDocumentEngine({ + store: { + ...store, + commitMigration: (...args: Parameters) => { + commits += 1; + return store.commitMigration(...args); + }, + }, + migrations: registryWith(addCount), + schema, + snapshotEveryCommands: 100, + }); + yield* engine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0); + + const entities = makeMemoryDurableEntityHost(); + const address = makeDurableEntityAddress("mimic-document", "doc-1"); + const [first, second] = yield* Effect.all( + [entities.run(address, engine.load), entities.run(address, engine.load)], + { concurrency: "unbounded" }, + ); + + expect(Current.decode(first.value)).toEqual({ title: "Hello", count: 7 }); + expect(Current.decode(second.value)).toEqual({ title: "Hello", count: 7 }); + expect(commits).toBe(1); + }), + )); + + it("rejects legacy executable source without changing persistence", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + const engine = makeDocumentEngine({ + store, + migrations: EmptyMigrationRegistry, + schema: { + getCollectionContext: () => + Effect.succeed({ collectionId: "collection-1", - version: 2, + databaseName: "example", + collectionName: "documents", schemaJson: serializeSchema(Current.schema), - dataMigrationSource: "return value", - }, - ], - }), - }, - snapshotEveryCommands: 100, - }); - const original = Original.encode({ title: "Hello" }); - await Effect.runPromise(engine.create("collection-1", original, 1, null)); - - await expect(Effect.runPromise(engine.load())).rejects.toThrow( - "executable source, which is no longer supported", - ); - - const meta = await Effect.runPromise(store.readMeta()); - const snapshot = await Effect.runPromise(store.loadLatestSnapshot()); - expect(meta?.schemaVersion).toBe(1); - expect(meta?.migrationVersion).toBeNull(); - expect(snapshot?.value).toEqual(original); - }); - - it("rejects documents newer than the deployed migration registry", async () => { - const store = makeMemoryDocumentStore(); - const engine = makeDocumentEngine({ - store, - migrations: registryWith(addCount), - schema, - snapshotEveryCommands: 100, - }); - await Effect.runPromise( - engine.create("collection-1", Current.encode({ title: "Hello", count: 7 }), 1, 2), - ); - - await expect(Effect.runPromise(engine.load())).rejects.toThrow("newer than deployed version"); - }); + schemaVersion: 2, + versions: [ + { + collectionId: "collection-1", + version: 1, + schemaJson: serializeSchema(Original.schema), + dataMigrationSource: null, + }, + { + collectionId: "collection-1", + version: 2, + schemaJson: serializeSchema(Current.schema), + dataMigrationSource: "return value", + }, + ], + }), + }, + snapshotEveryCommands: 100, + }); + const original = Original.encode({ title: "Hello" }); + yield* engine.create("collection-1", original, 1, null); + + const failure = yield* engine.load().pipe( + Effect.as("loaded"), + Effect.catchCause((cause) => Effect.succeed(Cause.pretty(cause))), + ); + expect(failure).toContain("executable source, which is no longer supported"); + + const meta = yield* store.readMeta(); + const snapshot = yield* store.loadLatestSnapshot(); + expect(meta?.schemaVersion).toBe(1); + expect(meta?.migrationVersion).toBeNull(); + expect(snapshot?.value).toEqual(original); + }), + )); + + it("rejects documents newer than the deployed migration registry", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + const engine = makeDocumentEngine({ + store, + migrations: registryWith(addCount), + schema, + snapshotEveryCommands: 100, + }); + yield* engine.create("collection-1", Current.encode({ title: "Hello", count: 7 }), 1, 2); + + const failure = yield* engine.load().pipe( + Effect.as("loaded"), + Effect.catchCause((cause) => Effect.succeed(Cause.pretty(cause))), + ); + expect(failure).toContain("newer than deployed version"); + }), + )); }); diff --git a/apps/mimic-db/tests/unit/document-auth.test.ts b/apps/mimic-db/tests/unit/document-auth.test.ts index 3974d04c6..c785cd042 100644 --- a/apps/mimic-db/tests/unit/document-auth.test.ts +++ b/apps/mimic-db/tests/unit/document-auth.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildDocumentConnectionUrl } from "../../src/api/handlers/document-auth.ts"; import { getConfig } from "../../src/config.ts"; @@ -84,25 +84,19 @@ describe("buildDocumentConnectionUrl", () => { }); describe("publicBaseUrl config", () => { - const original = process.env.MIMIC_PUBLIC_BASE_URL; - - afterEach(() => { - if (original === undefined) { - delete process.env.MIMIC_PUBLIC_BASE_URL; - } else { - process.env.MIMIC_PUBLIC_BASE_URL = original; - } - }); - + // `vi.stubEnv` + `vi.unstubAllEnvs` replaces the save/restore `afterEach`: + // each test restores the environment it stubbed before it returns. it("reads MIMIC_PUBLIC_BASE_URL when set", () => { - process.env.MIMIC_PUBLIC_BASE_URL = "https://mimic-db.example.workers.dev"; + vi.stubEnv("MIMIC_PUBLIC_BASE_URL", "https://mimic-db.example.workers.dev"); expect(getConfig().publicBaseUrl).toBe("https://mimic-db.example.workers.dev"); + vi.unstubAllEnvs(); }); it("treats unset and blank values as undefined", () => { - delete process.env.MIMIC_PUBLIC_BASE_URL; + vi.stubEnv("MIMIC_PUBLIC_BASE_URL", undefined); expect(getConfig().publicBaseUrl).toBeUndefined(); - process.env.MIMIC_PUBLIC_BASE_URL = " "; + vi.stubEnv("MIMIC_PUBLIC_BASE_URL", " "); expect(getConfig().publicBaseUrl).toBeUndefined(); + vi.unstubAllEnvs(); }); }); diff --git a/apps/mimic-db/tests/unit/document-session.test.ts b/apps/mimic-db/tests/unit/document-session.test.ts index 0610e11dc..4e4d9eae3 100644 --- a/apps/mimic-db/tests/unit/document-session.test.ts +++ b/apps/mimic-db/tests/unit/document-session.test.ts @@ -1,5 +1,5 @@ import { objectValue, stringValue } from "@voidhash/mimic-core"; -import { Effect } from "effect"; +import { Data, Effect, Schema } from "effect"; import { describe, expect, it } from "vitest"; import type { PresenceEntry } from "../../src/app/hostService.ts"; @@ -8,6 +8,7 @@ import { handleDocumentSocketClose, handleDocumentSocketMessage, isolateSessionHook, + type DocumentSessionAuth, type DocumentSessionContext, type SessionAttachment, } from "../../src/ws/document-session.ts"; @@ -22,6 +23,16 @@ interface FakeSocket { const docValue = objectValue({ title: stringValue("Hello") }); +/** Renders a client frame as the JSON text the socket handler receives. */ +const encodeFrame = Schema.encodeSync(Schema.fromJsonString(Schema.Any)); + +/** Rejection raised by the harness for an unrecognised document token. */ +class InvalidTokenError extends Data.TaggedError("InvalidTokenError")<{ + readonly message: string; +}> {} + +const goodTokenAuth: DocumentSessionAuth = { tokenId: "tok-1", permission: "write" }; + const makeManualTimers = () => { let currentNow = 0; const scheduled: Array<{ at: number; fn: () => void; cancelled: boolean }> = []; @@ -82,10 +93,12 @@ const makeHarness = (options?: { Effect.sync(() => { socket.closed = { code, reason }; }), - authenticate: (token) => - token === "good-token" - ? Effect.succeed({ tokenId: "tok-1", permission: "write" as const }) - : Effect.fail(new Error("invalid token")), + authenticate: (token) => { + if (token !== "good-token") { + return Effect.fail(new InvalidTokenError({ message: "invalid token" })); + } + return Effect.succeed(goodTokenAuth); + }, loadDocument: options?.loadDocument ?? (() => Effect.succeed({ value: docValue, version: 1 })), submitTransaction: (envelope) => Effect.succeed({ accepted: true, version: 2, transactionId: envelope.id }), @@ -117,15 +130,16 @@ const makeHarness = (options?: { return socket; }; - const message = (socket: FakeSocket, frame: unknown): Promise => - Effect.runPromise(handleDocumentSocketMessage(ctx, socket, JSON.stringify(frame))); + const message = (socket: FakeSocket, frame: unknown): Effect.Effect => + handleDocumentSocketMessage(ctx, socket, encodeFrame(frame)); - const authenticateSocket = async (connectionId: string): Promise => { - const socket = connectSocket(connectionId); - await message(socket, { type: "auth", token: "good-token" }); - socket.sent.length = 0; - return socket; - }; + const authenticateSocket = (connectionId: string): Effect.Effect => + Effect.gen(function* () { + const socket = connectSocket(connectionId); + yield* message(socket, { type: "auth", token: "good-token" }); + socket.sent.length = 0; + return socket; + }); return { ctx, @@ -141,98 +155,113 @@ const makeHarness = (options?: { }; describe("document session protocol", () => { - it("answers a successful auth with auth_result, snapshot, and presence snapshot", async () => { - const harness = makeHarness(); - const socket = harness.connectSocket("conn-1"); - - await harness.message(socket, { type: "auth", token: "good-token" }); - - expect(socket.sent).toEqual([ - { type: "auth_result", success: true, tokenId: "tok-1", permission: "write" }, - { type: "snapshot", value: docValue, version: 1 }, - { type: "presence_snapshot", selfId: "conn-1", presences: {} }, - ]); - expect(socket.attachment?.authenticated).toBe(true); - expect(harness.registry.authenticated()).toEqual([socket]); - }); - - it("rejects an invalid token without granting the session", async () => { - const harness = makeHarness(); - const socket = harness.connectSocket("conn-1"); - - await harness.message(socket, { type: "auth", token: "wrong" }); - - expect(socket.sent).toEqual([ - { type: "auth_result", success: false, error: "Invalid document token" }, - ]); - expect(socket.attachment?.authenticated).toBe(false); - expect(harness.registry.authenticated()).toEqual([]); - }); - - it("never broadcasts to sockets that have not authenticated", async () => { - const harness = makeHarness(); - const writer = await harness.authenticateSocket("writer"); - const peer = await harness.authenticateSocket("peer"); - const lurker = harness.connectSocket("lurker"); - - await harness.message(writer, { - type: "submit", - transaction: { id: "tx-1", baseVersion: 1, commands: [] }, - }); - await harness.message(writer, { - type: "presence_set", - data: objectValue({ name: stringValue("w") }), - }); - - expect(lurker.sent).toEqual([]); - expect(peer.sent.map((m) => m.type)).toEqual(["transaction", "presence_update"]); - expect(writer.sent.map((m) => m.type)).toEqual(["transaction", "presence_update"]); - }); - - it("sends an error frame and closes the socket when the snapshot load fails after auth", async () => { - const harness = makeHarness({ - loadDocument: () => Effect.fail({ message: "database unreachable" }), - }); - const socket = harness.connectSocket("conn-1"); - - await harness.message(socket, { type: "auth", token: "good-token" }); - - expect(socket.sent).toEqual([ - { - type: "error", - transactionId: undefined, - reason: "Failed to load document: database unreachable", - }, - ]); - expect(socket.closed).toEqual({ code: 1011, reason: "Document load failed" }); - expect(harness.registry.authenticated()).toEqual([]); - }); - - it("broadcasts presence_remove to peers when a socket with presence closes", async () => { - const harness = makeHarness(); - const leaver = await harness.authenticateSocket("leaver"); - const peer = await harness.authenticateSocket("peer"); - - await harness.message(leaver, { - type: "presence_set", - data: objectValue({ name: stringValue("l") }), - }); - peer.sent.length = 0; + it("answers a successful auth with auth_result, snapshot, and presence snapshot", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const socket = harness.connectSocket("conn-1"); + + yield* harness.message(socket, { type: "auth", token: "good-token" }); + + expect(socket.sent).toEqual([ + { type: "auth_result", success: true, tokenId: "tok-1", permission: "write" }, + { type: "snapshot", value: docValue, version: 1 }, + { type: "presence_snapshot", selfId: "conn-1", presences: {} }, + ]); + expect(socket.attachment?.authenticated).toBe(true); + expect(harness.registry.authenticated()).toEqual([socket]); + }), + )); - await Effect.runPromise(handleDocumentSocketClose(harness.ctx, leaver)); + it("rejects an invalid token without granting the session", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const socket = harness.connectSocket("conn-1"); - expect(peer.sent).toEqual([{ type: "presence_remove", id: "leaver" }]); - expect(harness.presence.has("leaver")).toBe(false); - expect(harness.registry.authenticated()).toEqual([peer]); + yield* harness.message(socket, { type: "auth", token: "wrong" }); - // Closing a socket without presence broadcasts nothing. - peer.sent.length = 0; - const quiet = await harness.authenticateSocket("quiet"); - await Effect.runPromise(handleDocumentSocketClose(harness.ctx, quiet)); - expect(peer.sent).toEqual([]); - }); + expect(socket.sent).toEqual([ + { type: "auth_result", success: false, error: "Invalid document token" }, + ]); + expect(socket.attachment?.authenticated).toBe(false); + expect(harness.registry.authenticated()).toEqual([]); + }), + )); + + it("never broadcasts to sockets that have not authenticated", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const writer = yield* harness.authenticateSocket("writer"); + const peer = yield* harness.authenticateSocket("peer"); + const lurker = harness.connectSocket("lurker"); + + yield* harness.message(writer, { + type: "submit", + transaction: { id: "tx-1", baseVersion: 1, commands: [] }, + }); + yield* harness.message(writer, { + type: "presence_set", + data: objectValue({ name: stringValue("w") }), + }); + + expect(lurker.sent).toEqual([]); + expect(peer.sent.map((m) => m.type)).toEqual(["transaction", "presence_update"]); + expect(writer.sent.map((m) => m.type)).toEqual(["transaction", "presence_update"]); + }), + )); + + it("sends an error frame and closes the socket when the snapshot load fails after auth", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness({ + loadDocument: () => Effect.fail({ message: "database unreachable" }), + }); + const socket = harness.connectSocket("conn-1"); + + yield* harness.message(socket, { type: "auth", token: "good-token" }); + + expect(socket.sent).toEqual([ + { + type: "error", + transactionId: undefined, + reason: "Failed to load document: database unreachable", + }, + ]); + expect(socket.closed).toEqual({ code: 1011, reason: "Document load failed" }); + expect(harness.registry.authenticated()).toEqual([]); + }), + )); + + it("broadcasts presence_remove to peers when a socket with presence closes", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const leaver = yield* harness.authenticateSocket("leaver"); + const peer = yield* harness.authenticateSocket("peer"); + + yield* harness.message(leaver, { + type: "presence_set", + data: objectValue({ name: stringValue("l") }), + }); + peer.sent.length = 0; + + yield* handleDocumentSocketClose(harness.ctx, leaver); + + expect(peer.sent).toEqual([{ type: "presence_remove", id: "leaver" }]); + expect(harness.presence.has("leaver")).toBe(false); + expect(harness.registry.authenticated()).toEqual([peer]); + + // Closing a socket without presence broadcasts nothing. + peer.sent.length = 0; + const quiet = yield* harness.authenticateSocket("quiet"); + yield* handleDocumentSocketClose(harness.ctx, quiet); + expect(peer.sent).toEqual([]); + }), + )); - it("closes sockets that never authenticate once the deadline passes", async () => { + it("closes sockets that never authenticate once the deadline passes", () => { const harness = makeHarness(); const socket = harness.connectSocket("conn-1"); @@ -242,88 +271,105 @@ describe("document session protocol", () => { expect(socket.closed).toEqual({ code: 1008, reason: "Authentication deadline exceeded" }); }); - it("keeps authenticated sockets alive past the auth deadline", async () => { - const harness = makeHarness(); - const socket = await harness.authenticateSocket("conn-1"); - - harness.advance(AUTH_DEADLINE_MS * 10); - expect(socket.closed).toBeNull(); - expect(harness.registry.authenticated()).toEqual([socket]); - }); + it("keeps authenticated sockets alive past the auth deadline", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const socket = yield* harness.authenticateSocket("conn-1"); - it("reports the accepted sequence to the idle-notify host on submit", async () => { - const harness = makeHarness(); - const writer = await harness.authenticateSocket("writer"); + harness.advance(AUTH_DEADLINE_MS * 10); + expect(socket.closed).toBeNull(); + expect(harness.registry.authenticated()).toEqual([socket]); + }), + )); - await harness.message(writer, { - type: "submit", - transaction: { id: "tx-1", baseVersion: 1, commands: [] }, - }); + it("reports the accepted sequence to the idle-notify host on submit", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const writer = yield* harness.authenticateSocket("writer"); - // The stub submit returns version 2, so the current sequence is 1. - expect(harness.acceptedSeqs).toEqual([1]); - }); + yield* harness.message(writer, { + type: "submit", + transaction: { id: "tx-1", baseVersion: 1, commands: [] }, + }); - it("signals the idle-notify host only when the last authenticated socket closes", async () => { - const harness = makeHarness(); - const first = await harness.authenticateSocket("first"); - const second = await harness.authenticateSocket("second"); + // The stub submit returns version 2, so the current sequence is 1. + expect(harness.acceptedSeqs).toEqual([1]); + }), + )); - await Effect.runPromise(handleDocumentSocketClose(harness.ctx, first)); - expect(harness.lastAuthenticatedCloses()).toBe(0); + it("signals the idle-notify host only when the last authenticated socket closes", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const first = yield* harness.authenticateSocket("first"); + const second = yield* harness.authenticateSocket("second"); - await Effect.runPromise(handleDocumentSocketClose(harness.ctx, second)); - expect(harness.lastAuthenticatedCloses()).toBe(1); - }); + yield* handleDocumentSocketClose(harness.ctx, first); + expect(harness.lastAuthenticatedCloses()).toBe(0); - it("completes close cleanup even when an isolated onLastAuthenticatedClose hook dies", async () => { - const harness = makeHarness(); - // The DO wires the storage-backed hook through `isolateSessionHook`; model a - // hook whose underlying storage effect DIES (a defect, not a typed failure). - const dyingCtx: DocumentSessionContext = { - ...harness.ctx, - onLastAuthenticatedClose: () => - isolateSessionHook( - Effect.die(new Error("storage unavailable")), - "onLastAuthenticatedClose", - ), - }; - const leaver = await harness.authenticateSocket("leaver"); - await Effect.runPromise( - handleDocumentSocketMessage( - harness.ctx, - leaver, - JSON.stringify({ type: "presence_set", data: objectValue({ name: stringValue("l") }) }), - ), - ); - - // Must resolve (not reject) — the die is swallowed by the isolation wrapper — - // and the registry/presence cleanup still runs. - await Effect.runPromise(handleDocumentSocketClose(dyingCtx, leaver)); - - expect(harness.presence.has("leaver")).toBe(false); - expect(harness.registry.authenticated()).toEqual([]); - }); + yield* handleDocumentSocketClose(harness.ctx, second); + expect(harness.lastAuthenticatedCloses()).toBe(1); + }), + )); + + it("completes close cleanup even when an isolated onLastAuthenticatedClose hook dies", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + // The DO wires the storage-backed hook through `isolateSessionHook`; model a + // hook whose underlying storage effect DIES (a defect, not a typed failure). + const dyingCtx: DocumentSessionContext = { + ...harness.ctx, + onLastAuthenticatedClose: () => + isolateSessionHook( + Effect.die(new Error("storage unavailable")), + "onLastAuthenticatedClose", + ), + }; + const leaver = yield* harness.authenticateSocket("leaver"); + yield* handleDocumentSocketMessage( + harness.ctx, + leaver, + encodeFrame({ type: "presence_set", data: objectValue({ name: stringValue("l") }) }), + ); + + // Must complete (not fail) — the die is swallowed by the isolation wrapper — + // and the registry/presence cleanup still runs. + yield* handleDocumentSocketClose(dyingCtx, leaver); + + expect(harness.presence.has("leaver")).toBe(false); + expect(harness.registry.authenticated()).toEqual([]); + }), + )); }); describe("isolateSessionHook", () => { - it("swallows a die and returns void so the caller proceeds", async () => { - let ran = false; - const result = await Effect.runPromise( - isolateSessionHook(Effect.die(new Error("boom")), "recordDirty").pipe( - Effect.tap(() => - Effect.sync(() => { - ran = true; - }), - ), - ), - ); - expect(ran).toBe(true); - expect(result).toBeUndefined(); - }); + it("swallows a die and returns void so the caller proceeds", () => + Effect.runPromise( + Effect.gen(function* () { + let ran = false; + const result = yield* isolateSessionHook( + Effect.die(new Error("boom")), + "recordDirty", + ).pipe( + Effect.tap(() => + Effect.sync(() => { + ran = true; + }), + ), + ); + expect(ran).toBe(true); + expect(result).toBeUndefined(); + }), + )); - it("passes a succeeding hook through untouched", async () => { - const result = await Effect.runPromise(isolateSessionHook(Effect.succeed(42), "recordDirty")); - expect(result).toBe(42); - }); + it("passes a succeeding hook through untouched", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* isolateSessionHook(Effect.succeed(42), "recordDirty"); + expect(result).toBe(42); + }), + )); }); diff --git a/apps/mimic-db/tests/unit/idle-notify.test.ts b/apps/mimic-db/tests/unit/idle-notify.test.ts index 82c31eb0d..01d82e57f 100644 --- a/apps/mimic-db/tests/unit/idle-notify.test.ts +++ b/apps/mimic-db/tests/unit/idle-notify.test.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Data, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -6,6 +6,7 @@ import { IDLE_NOTIFIED_SEQ_KEY, makeIdleNotifier, type IdleNotifyStorage, + type MimicDocumentIdleMessageType, } from "../../src/ws/idle-notify.ts"; interface Published { @@ -14,6 +15,11 @@ interface Published { readonly seq: number; } +/** Simulated queue outage used by the publish-failure case. */ +class PublishFailedError extends Data.TaggedError("PublishFailedError")<{ + readonly message: string; +}> {} + const makeHarness = (options?: { readonly authenticatedCount?: () => number; readonly publishFails?: boolean; @@ -33,6 +39,16 @@ const makeHarness = (options?: { }), }; let now = 1_000; + const publish = ( + message: MimicDocumentIdleMessageType, + ): Effect.Effect => { + if (options?.publishFails) { + return Effect.fail(new PublishFailedError({ message: "queue down" })); + } + return Effect.sync(() => { + published.push(message); + }); + }; const notifier = makeIdleNotifier({ collectionId: "col-1", documentId: "doc-1", @@ -40,12 +56,7 @@ const makeHarness = (options?: { debounceMs: 15_000, now: () => now, authenticatedCount: options?.authenticatedCount ?? (() => 0), - publish: (message) => - options?.publishFails - ? Effect.fail(new Error("queue down")) - : Effect.sync(() => { - published.push(message); - }), + publish, }); return { notifier, @@ -59,64 +70,88 @@ const makeHarness = (options?: { }; describe("idle notifier", () => { - it("records the dirty sequence on an accepted transaction", async () => { - const h = makeHarness(); - await Effect.runPromise(h.notifier.recordDirty(7)); - expect(h.store.get(IDLE_DIRTY_SEQ_KEY)).toBe(7); - }); + it("records the dirty sequence on an accepted transaction", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + yield* h.notifier.recordDirty(7); + expect(h.store.get(IDLE_DIRTY_SEQ_KEY)).toBe(7); + }), + )); - it("arms the debounce alarm when the last socket leaves a dirty document", async () => { - const h = makeHarness(); - await Effect.runPromise(h.notifier.recordDirty(3)); - await Effect.runPromise(h.notifier.onLastAuthenticatedClose()); - expect(h.alarms).toEqual([1_000 + 15_000]); - }); + it("arms the debounce alarm when the last socket leaves a dirty document", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + yield* h.notifier.recordDirty(3); + yield* h.notifier.onLastAuthenticatedClose(); + expect(h.alarms).toEqual([1_000 + 15_000]); + }), + )); - it("does not arm an alarm when nothing new has been edited", async () => { - const h = makeHarness(); - h.store.set(IDLE_DIRTY_SEQ_KEY, 5); - h.store.set(IDLE_NOTIFIED_SEQ_KEY, 5); - await Effect.runPromise(h.notifier.onLastAuthenticatedClose()); - expect(h.alarms).toEqual([]); - }); + it("does not arm an alarm when nothing new has been edited", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + h.store.set(IDLE_DIRTY_SEQ_KEY, 5); + h.store.set(IDLE_NOTIFIED_SEQ_KEY, 5); + yield* h.notifier.onLastAuthenticatedClose(); + expect(h.alarms).toEqual([]); + }), + )); - it("does not arm an alarm while authenticated sockets remain", async () => { - const h = makeHarness({ authenticatedCount: () => 1 }); - await Effect.runPromise(h.notifier.recordDirty(9)); - await Effect.runPromise(h.notifier.onLastAuthenticatedClose()); - expect(h.alarms).toEqual([]); - }); + it("does not arm an alarm while authenticated sockets remain", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness({ authenticatedCount: () => 1 }); + yield* h.notifier.recordDirty(9); + yield* h.notifier.onLastAuthenticatedClose(); + expect(h.alarms).toEqual([]); + }), + )); - it("publishes the dirty sequence and records it as notified on alarm", async () => { - const h = makeHarness(); - await Effect.runPromise(h.notifier.recordDirty(4)); - await Effect.runPromise(h.notifier.onAlarm()); - expect(h.published).toEqual([{ collectionId: "col-1", documentId: "doc-1", seq: 4 }]); - expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBe(4); - }); + it("publishes the dirty sequence and records it as notified on alarm", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + yield* h.notifier.recordDirty(4); + yield* h.notifier.onAlarm(); + expect(h.published).toEqual([{ collectionId: "col-1", documentId: "doc-1", seq: 4 }]); + expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBe(4); + }), + )); - it("skips publishing on alarm when a socket reconnected", async () => { - const h = makeHarness({ authenticatedCount: () => 1 }); - await Effect.runPromise(h.notifier.recordDirty(4)); - await Effect.runPromise(h.notifier.onAlarm()); - expect(h.published).toEqual([]); - expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBeUndefined(); - }); + it("skips publishing on alarm when a socket reconnected", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness({ authenticatedCount: () => 1 }); + yield* h.notifier.recordDirty(4); + yield* h.notifier.onAlarm(); + expect(h.published).toEqual([]); + expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBeUndefined(); + }), + )); - it("does not re-notify an already-notified sequence", async () => { - const h = makeHarness(); - h.store.set(IDLE_DIRTY_SEQ_KEY, 6); - h.store.set(IDLE_NOTIFIED_SEQ_KEY, 6); - await Effect.runPromise(h.notifier.onAlarm()); - expect(h.published).toEqual([]); - }); + it("does not re-notify an already-notified sequence", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + h.store.set(IDLE_DIRTY_SEQ_KEY, 6); + h.store.set(IDLE_NOTIFIED_SEQ_KEY, 6); + yield* h.notifier.onAlarm(); + expect(h.published).toEqual([]); + }), + )); - it("leaves notifiedSeq unpersisted when the publish fails", async () => { - const h = makeHarness({ publishFails: true }); - await Effect.runPromise(h.notifier.recordDirty(8)); - await Effect.runPromise(h.notifier.onAlarm()); - expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBeUndefined(); - // The dirty seq is untouched, so the next disconnect re-triggers. - expect(h.store.get(IDLE_DIRTY_SEQ_KEY)).toBe(8); - }); + it("leaves notifiedSeq unpersisted when the publish fails", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness({ publishFails: true }); + yield* h.notifier.recordDirty(8); + yield* h.notifier.onAlarm(); + expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBeUndefined(); + // The dirty seq is untouched, so the next disconnect re-triggers. + expect(h.store.get(IDLE_DIRTY_SEQ_KEY)).toBe(8); + }), + )); }); diff --git a/apps/mimic-db/tests/unit/migration-registry.test.ts b/apps/mimic-db/tests/unit/migration-registry.test.ts index 202641435..8924a4ce4 100644 --- a/apps/mimic-db/tests/unit/migration-registry.test.ts +++ b/apps/mimic-db/tests/unit/migration-registry.test.ts @@ -1,6 +1,6 @@ import { Primitive, serializeSchema } from "@voidhash/mimic-core"; import { defineMigration, defineMigrationRegistry } from "@voidhash/mimic-server/migrate"; -import { Effect } from "effect"; +import { Cause, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { makeControlEngine } from "../../src/core/control-engine.ts"; @@ -30,24 +30,25 @@ const registry = defineMigrationRegistry([ ]); describe("migration registry provisioning", () => { - it("creates registry resources directly at the latest deployed version", async () => { - const store = makeMemoryControlStore(); - await Effect.runPromise(ensureMigrationRegistry(store, registry)); + it("creates registry resources directly at the latest deployed version", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryControlStore(); + yield* ensureMigrationRegistry(store, registry); - const database = await Effect.runPromise(store.findDatabaseByName("example")); - const collection = await Effect.runPromise( - store.findCollectionByName(database!.id, "documents"), - ); + const database = yield* store.findDatabaseByName("example"); + const collection = yield* store.findCollectionByName(database!.id, "documents"); - expect(collection?.schemaJson).toEqual(serializeSchema(Current.schema)); - expect(collection?.schemaVersion).toBe(1); - expect(collection?.migrationVersion).toBe(1); - }); + expect(collection?.schemaJson).toEqual(serializeSchema(Current.schema)); + expect(collection?.schemaVersion).toBe(1); + expect(collection?.migrationVersion).toBe(1); + }), + )); - it("captures a final source-free baseline for an existing collection", async () => { - const store = makeMemoryControlStore(); - await Effect.runPromise( + it("captures a final source-free baseline for an existing collection", () => + Effect.runPromise( Effect.gen(function* () { + const store = makeMemoryControlStore(); yield* store.createDatabase({ id: "db", name: "example", description: "" }); yield* store.createCollection({ id: "collection", @@ -64,41 +65,37 @@ describe("migration registry provisioning", () => { dataMigrationSource: null, }); yield* ensureMigrationRegistry(store, registry); - }), - ); - const collection = await Effect.runPromise(store.findCollectionById("collection")); - const baseline = await Effect.runPromise(store.findSchemaVersion("collection", 5)); - expect(collection?.schemaVersion).toBe(5); - expect(collection?.migrationVersion).toBe(1); - expect(baseline?.schemaJson).toEqual(serializeSchema(Baseline.schema)); - expect(baseline?.dataMigrationSource).toBeNull(); - }); + const collection = yield* store.findCollectionById("collection"); + const baseline = yield* store.findSchemaVersion("collection", 5); + expect(collection?.schemaVersion).toBe(5); + expect(collection?.migrationVersion).toBe(1); + expect(baseline?.schemaJson).toEqual(serializeSchema(Baseline.schema)); + expect(baseline?.dataMigrationSource).toBeNull(); + }), + )); - it("prevents public deletion of registry-owned resources", async () => { - const store = makeMemoryControlStore(); - await Effect.runPromise(ensureMigrationRegistry(store, registry)); - const database = await Effect.runPromise(store.findDatabaseByName("example")); - const collection = await Effect.runPromise( - store.findCollectionByName(database!.id, "documents"), - ); - const control = makeControlEngine(store, registry); + it("prevents public deletion of registry-owned resources", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryControlStore(); + yield* ensureMigrationRegistry(store, registry); + const database = yield* store.findDatabaseByName("example"); + const collection = yield* store.findCollectionByName(database!.id, "documents"); + const control = makeControlEngine(store, registry); - const databaseResult = await Effect.runPromise( - Effect.result(control.deleteDatabase(database!.id)), - ); - const collectionResult = await Effect.runPromise( - Effect.result(control.deleteCollection(collection!.id)), - ); + const databaseResult = yield* Effect.result(control.deleteDatabase(database!.id)); + const collectionResult = yield* Effect.result(control.deleteCollection(collection!.id)); - expect(databaseResult._tag).toBe("Failure"); - expect(collectionResult._tag).toBe("Failure"); - }); + expect(databaseResult._tag).toBe("Failure"); + expect(collectionResult._tag).toBe("Failure"); + }), + )); - it("refuses to bootstrap with an older deployed registry", async () => { - const store = makeMemoryControlStore(); - await Effect.runPromise( + it("refuses to bootstrap with an older deployed registry", () => + Effect.runPromise( Effect.gen(function* () { + const store = makeMemoryControlStore(); yield* store.createDatabase({ id: "db", name: "example", description: "" }); yield* store.createCollection({ id: "collection", @@ -108,11 +105,12 @@ describe("migration registry provisioning", () => { schemaVersion: 1, migrationVersion: 2, }); - }), - ); - await expect(Effect.runPromise(ensureMigrationRegistry(store, registry))).rejects.toThrow( - "newer than deployed version", - ); - }); + const outcome = yield* ensureMigrationRegistry(store, registry).pipe( + Effect.as("provisioned"), + Effect.catchCause((cause) => Effect.succeed(Cause.pretty(cause))), + ); + expect(outcome).toContain("newer than deployed version"); + }), + )); }); diff --git a/apps/mimic-db/tests/unit/pg-store.test.ts b/apps/mimic-db/tests/unit/pg-store.test.ts index aa7fd7cd6..e15874dd6 100644 --- a/apps/mimic-db/tests/unit/pg-store.test.ts +++ b/apps/mimic-db/tests/unit/pg-store.test.ts @@ -14,11 +14,17 @@ const sqlErrorWithCause = (cause: unknown): SqlError.SqlError => }), }); -const pgError = (code: string, message: string): Error => { - const error = new Error(message); - (error as Error & { code: string }).code = code; - return error; -}; +/** A stand-in for the driver error node-postgres raises: an `Error` with a SQLSTATE `code`. */ +class PgDriverError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} + +const pgError = (code: string, message: string): Error => new PgDriverError(code, message); describe("pg-store error classification", () => { it("detects undefined_table (42P01) as a missing table", () => { diff --git a/apps/mimic-db/vitest.mts b/apps/mimic-db/vitest.mts index d8c77b4e5..3a0cf7062 100644 --- a/apps/mimic-db/vitest.mts +++ b/apps/mimic-db/vitest.mts @@ -1,18 +1,16 @@ import { defineConfig } from "vite-plus"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; -const rootDir = dirname(fileURLToPath(import.meta.url)); +const mimicCoreEntry = fileURLToPath( + new URL("../../packages/mimic-core/src/index.ts", import.meta.url), +); // Unit tier: every `*.test.ts` except the stack-backed integration files. // `scripts/check-test-tiers.mjs` enforces this split across the repository. export default defineConfig({ resolve: { alias: { - "@voidhash/mimic-core": resolve( - rootDir, - "../../packages/mimic-core/src/index.ts", - ), + "@voidhash/mimic-core": mimicCoreEntry, }, }, test: { diff --git a/apps/studio/package.json b/apps/studio/package.json index 1db409aa1..55c31647b 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -17,15 +17,19 @@ } }, "scripts": { - "dev": "vp dev", + "dev": "portless studio.voidhash --app-port 4830 pnpm run dev:app", + "dev:app": "vp dev", "build": "vp build", "preview": "vp preview", "typecheck": "tsgo --noEmit" }, "dependencies": { + "@effect/platform-node": "catalog:", "@tailwindcss/vite": "^4.1.13", "@vitejs/plugin-react": "^5.2.0", + "@voidhash/lib": "workspace:*", "@voidhash/paywalls": "workspace:*", + "effect": "catalog:", "react": "catalog:", "react-dom": "catalog:", "tailwindcss": "^4.1.13", diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index b0b9c4074..28e796f56 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -33,6 +33,26 @@ export const App = (): ReactNode => { setEvents([]); }; + const renderPreview = (): ReactNode => { + if (!selected) { + return ( +
+ Create a paywall in{" "} + .voidhash/paywalls to + preview it here. +
+ ); + } + return ( + + ); + }; + return (
@@ -51,22 +71,7 @@ export const App = (): ReactNode => {
-
- {selected ? ( - - ) : ( -
- Create a paywall in{" "} - .voidhash/paywalls{" "} - to preview it here. -
- )} -
+
{renderPreview()}
(
{children} @@ -26,10 +30,11 @@ const envelopeDetail = (envelope: PaywallOutboundEnvelope): string | null => { return envelope.payload?.source ?? null; case "openExternal": return envelope.payload.url; - case "event": - return envelope.payload.properties - ? `${envelope.payload.name} ${JSON.stringify(envelope.payload.properties)}` - : envelope.payload.name; + case "event": { + const { name, properties } = envelope.payload; + if (!properties) return name; + return `${name} ${encodeJson(properties)}`; + } case "log": return `${envelope.payload.level}: ${envelope.payload.message}`; default: @@ -37,6 +42,62 @@ const envelopeDetail = (envelope: PaywallOutboundEnvelope): string | null => { } }; +/** A component's static preview, or a hint when the file exports no definition. */ +const ComponentBody = ({ + entry, + profile, +}: { + entry: ComponentEntry; + profile: PreviewDeviceProfile; +}): ReactNode => { + if (!entry.definition) { + return ( +

+ No defineComponent(...) export found. +

+ ); + } + return ( +
+ +
+ ); +}; + +/** The live log of bridge envelopes, newest first. */ +const EventLog = ({ events }: { events: ReadonlyArray }): ReactNode => { + if (events.length === 0) { + return ( +

+ Envelopes posted by the paywall (ready, purchase, …) show up here. +

+ ); + } + return ( +
    + {events + .slice() + .reverse() + .map((event) => { + const detail = envelopeDetail(event.envelope); + return ( +
  • + {event.envelope.type} + {detail && ( + + {detail} + + )} +
  • + ); + })} +
+ ); +}; + export interface SidebarProps { paywalls: ReadonlyArray; components: ReadonlyArray; @@ -83,9 +144,8 @@ export const Sidebar = ({
- {c.definition ? ( -
- -
- ) : ( -

- No defineComponent(...) export found. -

- )} +
))}
@@ -162,33 +214,7 @@ export const Sidebar = ({ )}
- {events.length === 0 ? ( -

- Envelopes posted by the paywall (ready, purchase, …) show up here. -

- ) : ( -
    - {events - .slice() - .reverse() - .map((event) => { - const detail = envelopeDetail(event.envelope); - return ( -
  • - {event.envelope.type} - {detail && ( - - {detail} - - )} -
  • - ); - })} -
- )} +
diff --git a/apps/studio/src/main.tsx b/apps/studio/src/main.tsx index a1aa34dea..0c3ec7819 100644 --- a/apps/studio/src/main.tsx +++ b/apps/studio/src/main.tsx @@ -1,14 +1,23 @@ +import { Effect } from "effect"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; import "./index.css"; -const container = document.getElementById("root"); -if (!container) throw new Error("Studio root element #root not found"); +const mount = Effect.gen(function* () { + const container = document.getElementById("root"); + if (!container) { + return yield* Effect.die(new Error("Studio root element #root not found")); + } -createRoot(container).render( - - - , -); + yield* Effect.sync(() => + createRoot(container).render( + + + , + ), + ); +}); + +Effect.runSync(mount); diff --git a/apps/studio/src/server/config.ts b/apps/studio/src/server/config.ts index 5f6bb58d8..edb91eb3c 100644 --- a/apps/studio/src/server/config.ts +++ b/apps/studio/src/server/config.ts @@ -1,4 +1,3 @@ -import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; @@ -7,8 +6,15 @@ import type { InlineConfig } from "vite"; import { voidhashPaywallsPlugin } from "./virtual-paywalls-plugin"; -/** Absolute path to the Studio app root (the folder containing `index.html`). */ -export const STUDIO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +/** + * Absolute path to the Studio app root (the folder containing `index.html`). + * Resolved through `URL` rather than `node:path`; the trailing separator a + * directory URL carries is trimmed so the value stays a plain directory path. + */ +export const STUDIO_ROOT = fileURLToPath(new URL("../..", import.meta.url)).replace(/[/\\]$/, ""); + +/** Fixed port used by both standalone and CLI-launched Studio dev servers. */ +export const STUDIO_DEV_PORT = 4830; export interface StudioViteConfigOptions { /** The user's project root (folder containing `.voidhash`). */ @@ -33,7 +39,7 @@ export interface StudioViteConfigOptions { export const createStudioViteConfig = ({ projectRoot, studioRoot = STUDIO_ROOT, - port, + port = STUDIO_DEV_PORT, }: StudioViteConfigOptions): InlineConfig => ({ configFile: false, root: studioRoot, @@ -45,7 +51,9 @@ export const createStudioViteConfig = ({ include: ["react", "react-dom", "react-dom/client"], }, server: { + host: "127.0.0.1", port, + strictPort: true, fs: { allow: [studioRoot, projectRoot] }, }, }); diff --git a/apps/studio/src/server/index.ts b/apps/studio/src/server/index.ts index 067c75096..9302ac106 100644 --- a/apps/studio/src/server/index.ts +++ b/apps/studio/src/server/index.ts @@ -1,13 +1,15 @@ +import { causeMessage } from "@voidhash/lib/lang"; +import { Data, Effect } from "effect"; import { createServer, type ViteDevServer } from "vite"; -import { createStudioViteConfig } from "./config"; +import { createStudioViteConfig, STUDIO_DEV_PORT } from "./config"; -export { createStudioViteConfig, STUDIO_ROOT } from "./config"; +export { createStudioViteConfig, STUDIO_DEV_PORT, STUDIO_ROOT } from "./config"; export interface StartStudioOptions { /** The user's project root (folder containing `.voidhash`). */ projectRoot: string; - /** Preferred port; Vite picks the next free port if taken. Defaults to 4830. */ + /** Dev server port. Defaults to 4830 and fails if the port is unavailable. */ port?: number; } @@ -22,27 +24,37 @@ export interface StudioHandle { readonly server: ViteDevServer; } -const DEFAULT_PORT = 4830; +/** Raised when the Studio Vite dev server cannot be created or bound. */ +export class StudioStartError extends Data.TaggedError("StudioStartError")<{ + readonly message: string; +}> {} /** * Boots the Studio Vite dev server for a given project and returns a handle. * This is the programmatic entry point the CLI's `studio` command calls. */ -export const startStudio = async ({ +export const startStudio = ({ projectRoot, - port = DEFAULT_PORT, -}: StartStudioOptions): Promise => { - const server = await createServer(createStudioViteConfig({ projectRoot, port })); - - await server.listen(); - - const resolvedPort = server.config.server.port ?? port; - const url = `http://localhost:${resolvedPort}`; - - return { - close: () => server.close(), - port: resolvedPort, - server, - url, - }; -}; + port = STUDIO_DEV_PORT, +}: StartStudioOptions): Effect.Effect => + Effect.gen(function* () { + const server = yield* Effect.tryPromise({ + try: () => createServer(createStudioViteConfig({ projectRoot, port })), + catch: (cause) => new StudioStartError({ message: causeMessage(cause) }), + }); + + yield* Effect.tryPromise({ + try: () => server.listen(), + catch: (cause) => new StudioStartError({ message: causeMessage(cause) }), + }); + + const resolvedPort = server.config.server.port ?? port; + const url = `http://localhost:${resolvedPort}`; + + return { + close: () => server.close(), + port: resolvedPort, + server, + url, + }; + }); diff --git a/apps/studio/src/server/virtual-paywalls-plugin.ts b/apps/studio/src/server/virtual-paywalls-plugin.ts index 7bbb678c8..c0d80ea2a 100644 --- a/apps/studio/src/server/virtual-paywalls-plugin.ts +++ b/apps/studio/src/server/virtual-paywalls-plugin.ts @@ -1,8 +1,14 @@ -import { existsSync, readdirSync, statSync } from "node:fs"; -import { basename, join } from "node:path"; - +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path, type PlatformError, Schema } from "effect"; import type { Plugin, ViteDevServer } from "vite"; +// The POSIX `Path` service, resolved once so Vite's synchronous plugin hooks +// (`config`, watcher callbacks) can join paths without running an Effect. +const path = Effect.runSync(Effect.provide(Path.Path, Path.layer)); + +/** Serializes a value as a JSON literal for the generated virtual module. */ +const jsonLiteral = Schema.encodeSync(Schema.UnknownFromJsonString); + /** * The id Studio imports to discover the user's paywalls and components. Resolved * by {@link voidhashPaywallsPlugin} into a module of eager imports so titles and @@ -29,29 +35,43 @@ interface SourceEntry { const isSourceFile = (name: string): boolean => SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !name.endsWith(".d.ts"); -const idFromFile = (file: string): string => basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); +const idFromFile = (file: string): string => path.basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); /** Recursively lists files under a directory (absolute paths). */ -const listFilesRecursive = (dir: string): string[] => { - if (!existsSync(dir)) return []; - const out: string[] = []; - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) { - out.push(...listFilesRecursive(full)); - } else { - out.push(full); +const listFilesRecursive = ( + dir: string, +): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(dir); + if (!exists) return []; + + const out: Array = []; + for (const entry of yield* fs.readDirectory(dir)) { + const full = path.join(dir, entry); + const info = yield* fs.stat(full); + if (info.type === "Directory") { + out.push(...(yield* listFilesRecursive(full))); + } else { + out.push(full); + } } - } - return out; -}; + return out; + }); /** Lists the source files under a `.voidhash/
` tree, sorted by id. */ -const scanDir = (voidhashDir: string, dir: string): SourceEntry[] => - listFilesRecursive(join(voidhashDir, dir)) - .filter((file) => isSourceFile(basename(file))) - .map((file) => ({ file, id: idFromFile(file) })) - .sort((a, b) => a.id.localeCompare(b.id)); +const scanDir = ( + voidhashDir: string, + dir: string, +): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem> => + listFilesRecursive(path.join(voidhashDir, dir)).pipe( + Effect.map((files) => + files + .filter((file) => isSourceFile(path.basename(file))) + .map((file) => ({ file, id: idFromFile(file) })) + .sort((a, b) => a.id.localeCompare(b.id)), + ), + ); /** * Vite reference to a filesystem-absolute path. Files live in the *user's* @@ -63,28 +83,28 @@ const fsImportSpecifier = (absPath: string): string => `/@fs${absPath}`; /** Generates the source of the virtual module from the discovered files. */ const generateModule = ( projectRoot: string, - paywalls: SourceEntry[], - components: SourceEntry[], + paywalls: ReadonlyArray, + components: ReadonlyArray, ): string => { const lines: string[] = []; const paywallRefs: string[] = []; const componentRefs: string[] = []; paywalls.forEach((entry, i) => { - lines.push(`import * as __pw${i} from ${JSON.stringify(fsImportSpecifier(entry.file))};`); + lines.push(`import * as __pw${i} from ${jsonLiteral(fsImportSpecifier(entry.file))};`); paywallRefs.push( - `{ id: ${JSON.stringify(entry.id)}, file: ${JSON.stringify(entry.file)}, module: __pw${i} }`, + `{ id: ${jsonLiteral(entry.id)}, file: ${jsonLiteral(entry.file)}, module: __pw${i} }`, ); }); components.forEach((entry, i) => { - lines.push(`import * as __cmp${i} from ${JSON.stringify(fsImportSpecifier(entry.file))};`); + lines.push(`import * as __cmp${i} from ${jsonLiteral(fsImportSpecifier(entry.file))};`); componentRefs.push( - `{ id: ${JSON.stringify(entry.id)}, file: ${JSON.stringify(entry.file)}, module: __cmp${i} }`, + `{ id: ${jsonLiteral(entry.id)}, file: ${jsonLiteral(entry.file)}, module: __cmp${i} }`, ); }); - lines.push(`export const projectRoot = ${JSON.stringify(projectRoot)};`); + lines.push(`export const projectRoot = ${jsonLiteral(projectRoot)};`); lines.push(`export const paywalls = [${paywallRefs.join(", ")}];`); lines.push(`export const components = [${componentRefs.join(", ")}];`); return lines.join("\n"); @@ -102,7 +122,7 @@ export interface VoidhashPaywallsPluginOptions { * file invalidates the virtual module and reloads so the sidebar stays in sync. */ export const voidhashPaywallsPlugin = ({ projectRoot }: VoidhashPaywallsPluginOptions): Plugin => { - const voidhashDir = join(projectRoot, ".voidhash"); + const voidhashDir = path.join(projectRoot, ".voidhash"); let server: ViteDevServer | undefined; const invalidate = () => { @@ -113,8 +133,8 @@ export const voidhashPaywallsPlugin = ({ projectRoot }: VoidhashPaywallsPluginOp }; const isPaywallSource = (file: string): boolean => - file.startsWith(join(voidhashDir, PAYWALLS_DIR)) || - file.startsWith(join(voidhashDir, COMPONENTS_DIR)); + file.startsWith(path.join(voidhashDir, PAYWALLS_DIR)) || + file.startsWith(path.join(voidhashDir, COMPONENTS_DIR)); return { name: "voidhash:paywalls", @@ -143,9 +163,13 @@ export const voidhashPaywallsPlugin = ({ projectRoot }: VoidhashPaywallsPluginOp load(id) { if (id !== RESOLVED_VIRTUAL_ID) return null; - const paywalls = scanDir(voidhashDir, PAYWALLS_DIR); - const components = scanDir(voidhashDir, COMPONENTS_DIR); - return generateModule(projectRoot, paywalls, components); + return Effect.runPromise( + Effect.gen(function* () { + const paywalls = yield* scanDir(voidhashDir, PAYWALLS_DIR); + const components = yield* scanDir(voidhashDir, COMPONENTS_DIR); + return generateModule(projectRoot, paywalls, components); + }).pipe(Effect.provide(NodeServices.layer), Effect.orDie), + ); }, }; }; diff --git a/apps/studio/src/voidhash/paywalls.ts b/apps/studio/src/voidhash/paywalls.ts index bbc819b53..1d9309a0a 100644 --- a/apps/studio/src/voidhash/paywalls.ts +++ b/apps/studio/src/voidhash/paywalls.ts @@ -3,6 +3,7 @@ import { paywalls as rawPaywalls, projectRoot as rawProjectRoot, } from "virtual:voidhash-paywalls"; +import { causeMessage } from "@voidhash/lib/lang"; import { type ActionMap, type ComponentDefinition, @@ -13,6 +14,12 @@ import { type PaywallDefinition, type PropMap, } from "@voidhash/paywalls"; +import { Data, Effect } from "effect"; + +/** Raised when a component definition's §2 manifest cannot be extracted. */ +class ManifestExtractionError extends Data.TaggedError("ManifestExtractionError")<{ + readonly message: string; +}> {} /** * A component definition with its prop/action generics erased — the shape of @@ -71,15 +78,15 @@ const findComponentDefinition = ( return null; }; -const safeManifest = (definition: AnyComponentDefinition): ComponentManifest | null => { - try { - return extractComponentManifest(definition); - } catch { - // A structurally valid definition with hand-rolled (non-builder) props can - // still blow up extraction; the sidebar then just omits the metadata. - return null; - } -}; +// A structurally valid definition with hand-rolled (non-builder) props can +// still blow up extraction; the sidebar then just omits the metadata. +const safeManifest = (definition: AnyComponentDefinition): ComponentManifest | null => + Effect.runSync( + Effect.try({ + try: () => extractComponentManifest(definition), + catch: (cause) => new ManifestExtractionError({ message: causeMessage(cause) }), + }).pipe(Effect.orElseSucceed(() => null)), + ); /** * Normalizes the raw `virtual:voidhash-paywalls` module into typed, validated @@ -112,12 +119,10 @@ export const loadProjectContent = (): ProjectContent => { const components: ComponentEntry[] = rawComponents.map((entry) => { const definition = findComponentDefinition(entry.module); - return { - definition, - file: entry.file, - id: entry.id, - manifest: definition ? safeManifest(definition) : null, - }; + if (!definition) { + return { definition: null, file: entry.file, id: entry.id, manifest: null }; + } + return { definition, file: entry.file, id: entry.id, manifest: safeManifest(definition) }; }); return { diff --git a/apps/studio/src/voidhash/preview-runtime.ts b/apps/studio/src/voidhash/preview-runtime.ts index af980b9cf..fda69e6f9 100644 --- a/apps/studio/src/voidhash/preview-runtime.ts +++ b/apps/studio/src/voidhash/preview-runtime.ts @@ -1,4 +1,5 @@ import type { PaywallBridge, PaywallOutboundEnvelope } from "@voidhash/paywalls"; +import { Clock, Effect } from "effect"; import { DEFAULT_PREVIEW_DEVICE_PROFILE, previewConfigForDevice } from "./preview-devices"; @@ -33,7 +34,7 @@ let eventCounter = 0; export const createStudioBridge = (onEvent: (event: PreviewEvent) => void): PaywallBridge => ({ post: (envelope) => { eventCounter += 1; - onEvent({ at: Date.now(), envelope, key: eventCounter }); + onEvent({ at: Effect.runSync(Clock.currentTimeMillis), envelope, key: eventCounter }); }, subscribe: () => () => {}, }); diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 31782d607..a19060e9e 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { Config, Effect, Path } from "effect"; import { createStudioViteConfig } from "./src/server/config"; @@ -9,9 +9,13 @@ import { createStudioViteConfig } from "./src/server/config"; * falls back to the bundled React Native example so Studio can be developed in * isolation. */ -const projectRoot = - process.env.VOIDHASH_PROJECT_ROOT ?? - resolve(import.meta.dirname, "../../examples/react-native-example"); +const projectRoot = Effect.runSync( + Effect.gen(function* () { + const path = yield* Path.Path; + const fallback = path.resolve(import.meta.dirname, "../../examples/react-native-example"); + return yield* Config.string("VOIDHASH_PROJECT_ROOT").pipe(Config.withDefault(fallback)); + }).pipe(Effect.provide(Path.layer), Effect.orDie), +); export default createStudioViteConfig({ projectRoot, diff --git a/apps/www/.env.example b/apps/www/.env.example index 292af2b9a..1ba65861c 100644 --- a/apps/www/.env.example +++ b/apps/www/.env.example @@ -1,18 +1,18 @@ -# VITE_APP_API_URL=http://localhost:5001 +VITE_APP_API_URL=https://mimic.voidhash.localhost # VITE_APP_ENV=development # # WorkOS AuthKit / User Management # # Dashboard Redirect URIs: -# # - http://localhost:3000/api/auth/callback -# # - http://localhost:3000/api/auth/oauth/callback +# # - https://voidhash.localhost/api/auth/callback +# # - https://voidhash.localhost/api/auth/oauth/callback # # Dashboard password reset URL: -# # - http://localhost:3000/auth/reset-password +# # - https://voidhash.localhost/auth/reset-password # WORKOS_API_KEY=sk_test_your-workos-api-key # WORKOS_CLIENT_ID=client_your-workos-client-id # WORKOS_COOKIE_PASSWORD=your-32-character-cookie-password # # Optional override for the AuthKit hosted callback. The app otherwise derives # # request-specific callback URLs for first-party auth routes. -# WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback +# WORKOS_REDIRECT_URI=https://voidhash.localhost/api/auth/callback # GITHUB_CLIENT_ID=your-github-client-id # GITHUB_CLIENT_SECRET=your-github-client-secret diff --git a/apps/www/.source/browser.ts b/apps/www/.source/browser.ts index f8c66bc1b..e689473cf 100644 --- a/apps/www/.source/browser.ts +++ b/apps/www/.source/browser.ts @@ -9,7 +9,7 @@ const create = browser(); const browserCollections = { docs: create.doc("docs", import.meta.glob(["./**/*.{mdx,md}"], { - "base": "./../src/features/docs/content/docs", + "base": "./../../../packages/web-app/src/features/docs/content/docs", "query": { "collection": "docs" }, diff --git a/apps/www/.source/server.ts b/apps/www/.source/server.ts index 0b72d6d61..09889fb14 100644 --- a/apps/www/.source/server.ts +++ b/apps/www/.source/server.ts @@ -8,15 +8,15 @@ const create = server(); -export const docs = await create.docs("docs", "src/features/docs/content/docs", import.meta.glob(["./**/*.{json,yaml}"], { - "base": "./../src/features/docs/content/docs", +export const docs = await create.docs("docs", "../../packages/web-app/src/features/docs/content/docs", import.meta.glob(["./**/*.{json,yaml}"], { + "base": "./../../../packages/web-app/src/features/docs/content/docs", "query": { "collection": "docs" }, "import": "default", "eager": true }), import.meta.glob(["./**/*.{mdx,md}"], { - "base": "./../src/features/docs/content/docs", + "base": "./../../../packages/web-app/src/features/docs/content/docs", "query": { "collection": "docs" }, diff --git a/apps/www/README.md b/apps/www/README.md index de84dfd02..ec4c814e0 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -1,19 +1,12 @@ -# www +# Community web entrypoint -TanStack Start app deployed as a Cloudflare Worker through Alchemy. +This application composes the shared Voidhash web package with the Community +route set and standalone runtime adapters. -## Development - -Run the full Cloudflare-backed stack from the repository root: - -```bash -pnpm dev -``` - -For an app-only build check: +Shared features and routes live in `packages/web-app`; this directory only owns +the Community entrypoint, generated route tree, source configuration, and local +development command. ```bash pnpm --filter @voidhash/www build ``` - -`alchemy.run.ts` owns the Cloudflare deployment with `Cloudflare.Vite`; the app Vite config only contains the TanStack/React app plugins. diff --git a/apps/www/package.json b/apps/www/package.json index a763a05cc..e19bfcec5 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -5,126 +5,34 @@ "license": "AGPL-3.0-only", "type": "module", "scripts": { - "dev": "vp dev", + "dev": "portless voidhash --app-port 3000 pnpm run dev:app", + "dev:app": "node scripts/dev.mjs", "build": "pnpm run generate:docs && vp build --configLoader runner", "preview": "vp preview", "typecheck": "pnpm run generate:docs && tsc --noEmit", "generate:docs": "fumadocs-mdx src/features/source.config.ts .source", - "generate:openapi": "bun scripts/generate-openapi.ts" + "generate:openapi": "pnpm --filter @voidhash/web-app generate:openapi", + "test:components": "pnpm --filter @voidhash/web-app test:components" }, "dependencies": { - "@apple/app-store-server-library": "^1.6.0", - "@axiomhq/pino": "^1.3.1", - "@basementstudio/shader-lab": "^1.3.14", - "@chronark/zod-bird": "^0.3.10", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", - "@effect/platform-node": "catalog:", - "@fontsource-variable/geist": "catalog:", - "@fontsource-variable/geist-mono": "catalog:", - "@headless-tree/core": "^1.7.0", - "@headless-tree/react": "^1.7.0", - "@hookform/resolvers": "catalog:", - "@monaco-editor/react": "^4.7.0", - "@paper-design/shaders-react": "^0.0.71", - "@paralleldrive/cuid2": "^3.3.0", - "@pixi/layout": "^3.2.0", - "@pixi/react": "^8.0.0-beta.25", - "@pixi/ui": "^2.3.0", - "@radix-ui/react-slot": "^1.2.3", - "@react-three/drei": "^10.1.2", - "@react-three/fiber": "^9.1.2", - "@react-three/postprocessing": "^3.0.4", - "@scalar/api-client-react": "^2.0.36", - "@scalar/openapi-upgrader": "0.2.11", - "@t3-oss/env-core": "^0.13.8", - "@tanstack/query-core": "5.100.11", "@tanstack/react-query": "catalog:", - "@tanstack/react-query-devtools": "catalog:", "@tanstack/react-router": "catalog:", "@tanstack/react-router-ssr-query": "catalog:", "@tanstack/react-start": "catalog:", - "@tanstack/react-table": "catalog:", - "@tanstack/zod-adapter": "catalog:", - "@voidhash/agent": "workspace:*", - "@voidhash/api-contracts": "workspace:*", - "@voidhash/core": "workspace:*", - "@voidhash/lib": "workspace:*", - "@voidhash/mimic": "workspace:*", - "@voidhash/mimic-core": "workspace:*", - "@voidhash/mimic-schema": "workspace:*", - "@voidhash/paywall-build": "workspace:*", - "@voidhash/paywall-builtins": "workspace:*", - "@voidhash/paywall-renderer-preact": "workspace:*", - "@voidhash/paywall-renderer-web-core": "workspace:*", - "@voidhash/paywall-workspace": "workspace:*", - "@voidhash/paywalls": "workspace:*", - "@voidhash/rpc": "workspace:*", - "@voidhash/ui": "workspace:*", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cross-fetch": "catalog:", - "date-fns": "^4.1.0", + "@voidhash/web-app": "workspace:*", "effect": "catalog:", - "effect-query": "catalog:", - "esbuild-wasm": "^0.25.10", - "fractional-indexing-jittered": "^1.0.0", "fumadocs-core": "16.11.5", "fumadocs-mdx": "15.2.0", - "fumadocs-openapi": "11.2.2", - "fumadocs-ui": "16.11.5", - "h3-v2": "npm:h3@2.0.1-rc.18", - "lucide-react": "catalog:", - "lucide-static": "^0.555.0", - "monaco-editor": "^0.52.2", - "motion": "^12.23.12", - "nanoid": "^5.1.6", - "next-themes": "^0.4.6", - "pino": "^9.7.0", - "pixi-viewport": "^6.0.3", - "pixi.js": "^8.2.6", - "qrcode.react": "^4.2.0", "react": "catalog:", - "react-dom": "catalog:", - "react-easy-crop": "^6.0.2", - "react-hook-form": "catalog:", - "react-qr-code": "^2.2.0", - "recharts": "^2.15.0", - "resend": "^4.5.1", - "server-only": "^0.0.1", - "slug": "^11.0.0", - "sonner": "catalog:", - "streamdown": "^2.0.0", - "stripe": "^18.0.0", - "tailwind-merge": "^3.3.1", - "three": "^0.178.0", - "vite-tsconfig-paths": "catalog:", - "voidhash": "0.0.1-alpha.4", - "y-protocols": "^1.0.6", - "yjs": "^13.6.27", - "zod": "catalog:", - "zod-openapi": "^5.3.1", - "zustand": "^5.0.8" + "react-dom": "catalog:" }, "devDependencies": { - "@effect/vitest": "catalog:", - "@tailwindcss/vite": "catalog:", - "@types/mdx": "^2.0.13", "@types/node": "^20", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "@vitejs/plugin-react": "catalog:", - "@vitest/ui": "4.1.0", "@voidhash/tsconfig": "workspace:*", - "babel-plugin-react-compiler": "catalog:", - "dotenv-cli": "catalog:", - "tailwindcss": "catalog:", - "tsx": "^4.19.3", - "tw-animate-css": "catalog:", "typescript": "catalog:", "vite": "catalog:", - "vite-plus": "catalog:", - "vitest": "catalog:" + "vite-plus": "catalog:" } } diff --git a/apps/www/scripts/dev.mjs b/apps/www/scripts/dev.mjs new file mode 100644 index 000000000..4eb39af82 --- /dev/null +++ b/apps/www/scripts/dev.mjs @@ -0,0 +1,19 @@ +// oxlint-disable-next-line effect/noNodeBuiltinImport -- this launcher runs in Node before Vite (and therefore any Effect runtime) exists; FileSystem would need a runtime that is not there yet. +import { existsSync } from "node:fs"; + +// Vite only exposes `VITE_`-prefixed values, and only to `import.meta.env` — the +// server routes read plain `process.env` (root credentials, auth secret), so the +// repo-root `.env` the self-host stack uses has to be loaded here too. Real +// 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`; +if (existsSync(rootEnvFile)) process.loadEnvFile(rootEnvFile); + +process.env.VITE_APP_API_URL ??= "https://mimic.voidhash.localhost"; + +const { createServer } = await import("vite"); + +// Keep the Vite server and TanStack Start plugin on the same module instance. +const server = await createServer(); +await server.listen(); +server.printUrls(); diff --git a/apps/www/src/features/source.config.ts b/apps/www/src/features/source.config.ts index bc1cbe7ee..b29268479 100644 --- a/apps/www/src/features/source.config.ts +++ b/apps/www/src/features/source.config.ts @@ -1,13 +1,17 @@ -import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from "./fumadocs-config.ts"; -import { voidhashShikiDark, voidhashShikiLight } from "./docs/lib/shiki-theme.ts"; +import { + defineConfig, + defineDocs, + frontmatterSchema, + metaSchema, +} from "../../../../packages/web-app/src/features/fumadocs-config.ts"; +import { + voidhashShikiDark, + voidhashShikiLight, +} from "../../../../packages/web-app/src/features/docs/lib/shiki-theme.ts"; -// A host that adds documentation surfaces of its own replaces this config -// wholesale rather than registering a second `mdx()` plugin — two instances -// writing the same output directory clobber each other. See -// `apps/www/src/features/source.config.ts` in voidhash-mono, which re-declares -// this collection alongside its own. +// Each entrypoint owns one Fumadocs config and generated output directory. export const docs = defineDocs({ - dir: "src/features/docs/content/docs", + dir: "../../packages/web-app/src/features/docs/content/docs", docs: { schema: frontmatterSchema, }, diff --git a/apps/www/src/features/studio/enterprise/runtime-capabilities.ts b/apps/www/src/features/studio/enterprise/runtime-capabilities.ts deleted file mode 100644 index 0f152e930..000000000 --- a/apps/www/src/features/studio/enterprise/runtime-capabilities.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { env } from "@/lib/env"; - -/** Enabled enterprise capability ids advertised by the host composition. */ -export type EnterpriseCapabilities = Readonly>; - -export interface RuntimeCapabilities { - readonly enterprise: EnterpriseCapabilities; -} - -const disabledCapabilities: RuntimeCapabilities = { - enterprise: {}, -}; - -const loadRuntimeCapabilities = async (): Promise => { - try { - const apiBaseUrl = env.VITE_APP_API_URL.replace(/\/+$/, ""); - const response = await fetch(`${apiBaseUrl}/api/runtime-capabilities`, { - credentials: "include", - }); - if (!response.ok) return disabledCapabilities; - - const body = (await response.json()) as { - readonly enterprise?: Readonly>; - }; - const enterprise: Record = {}; - for (const [capability, enabled] of Object.entries(body.enterprise ?? {})) { - if (enabled === true) { - enterprise[capability] = true; - } - } - return { enterprise }; - } catch { - return disabledCapabilities; - } -}; - -/** Reads the backend composition's UI capabilities once per browser session. */ -export const useRuntimeCapabilities = () => - useQuery({ - queryFn: loadRuntimeCapabilities, - queryKey: ["runtime-capabilities"], - staleTime: Number.POSITIVE_INFINITY, - }); diff --git a/apps/www/src/features/studio/lib/zod-error.ts b/apps/www/src/features/studio/lib/zod-error.ts deleted file mode 100644 index 78dd6401b..000000000 --- a/apps/www/src/features/studio/lib/zod-error.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Credited to https://github.com/unkeyed/unkey -import type { z } from "zod"; - -export function parseZodErrorMessage(err: z.ZodError): string { - try { - const arr = JSON.parse(err.message) as { - message: string; - path: string[]; - }[]; - const { path, message } = arr[0] ?? { message: err.message, path: [] }; - return `${path.join(".")}: ${message}`; - } catch { - return err.message; - } -} diff --git a/apps/www/src/features/studio/paywalls/designer/panel-runtime/in-process-transport.test.tsx b/apps/www/src/features/studio/paywalls/designer/panel-runtime/in-process-transport.test.tsx deleted file mode 100644 index e52fc516e..000000000 --- a/apps/www/src/features/studio/paywalls/designer/panel-runtime/in-process-transport.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// @vitest-environment jsdom - -import { Panel, type PanelContext, type PanelSessionInputs } from "@voidhash/paywalls/panel"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { useState } from "react"; -import { afterEach, describe, expect, test } from "vite-plus/test"; - -import { PanelTreeView } from "./host-renderer"; -import { createInProcessTransport } from "./in-process-transport"; - -afterEach(() => cleanup()); - -const EMPTY_INPUTS: PanelSessionInputs = { - props: {}, - selection: { count: 1 }, - data: { products: [], variables: {} }, -}; - -/** Walks a tree and returns the first `textField` node's `value` prop. */ -const findTextFieldValue = (node: { - type: string; - props: Record; - children?: unknown[]; -}): unknown => { - if (node.type === "textField") return node.props.value; - for (const child of node.children ?? []) { - const found = findTextFieldValue(child as never); - if (found !== undefined) return found; - } - return undefined; -}; - -/** - * A tiny built-in definition: a local `useState` counter shown in a read-only - * textField, with a button that increments it. The click flows through the - * session's synchronous dispatch and re-renders the tree with the new count. - */ -function CounterDefinition(_ctx: PanelContext) { - const [count, setCount] = useState(0); - return ( - - - - - setCount((c) => c + 1)} /> - - ); -} - -describe("in-process transport — round trip", () => { - test("click → dispatch → a NEW tree renders synchronously", () => { - const transport = createInProcessTransport({ - render: CounterDefinition, - initialInputs: EMPTY_INPUTS, - }); - - try { - // The first tree is emitted synchronously during construction. - const initial = transport.getSnapshot(); - expect(initial.status).toBe("ready"); - - render(); - // The count textField shows 0 initially. - expect((screen.getByLabelText("count") as HTMLInputElement).value).toBe("0"); - - // Click the button: the OSS session flushes setState + re-emits the tree - // synchronously before dispatchEvent returns, so the new value is present. - fireEvent.click(screen.getByText("Increment")); - expect((screen.getByLabelText("count") as HTMLInputElement).value).toBe("1"); - - fireEvent.click(screen.getByText("Increment")); - expect((screen.getByLabelText("count") as HTMLInputElement).value).toBe("2"); - - // The snapshot revision advanced (each dispatch emitted a new tree). - const after = transport.getSnapshot(); - expect(after.status).toBe("ready"); - if (after.status === "ready") { - expect(after.revision).toBeGreaterThan(0); - } - } finally { - transport.dispose(); - } - }); - - test("the dev-assert path accepts a valid built-in emission", () => { - // import.meta.env.DEV is true under the test runner, so this exercises the - // decodePanelTreeDev assertion on every emission — a valid definition passes. - const transport = createInProcessTransport({ - render: CounterDefinition, - initialInputs: EMPTY_INPUTS, - }); - try { - expect(transport.getSnapshot().status).toBe("ready"); - } finally { - transport.dispose(); - } - }); - - test("restart tears down and remounts a fresh session with a reset tree", () => { - const transport = createInProcessTransport({ - render: CounterDefinition, - initialInputs: EMPTY_INPUTS, - }); - try { - render(); - fireEvent.click(screen.getByText("Increment")); - fireEvent.click(screen.getByText("Increment")); - - // The live tree carries the incremented count. - const before = transport.getSnapshot(); - expect(before.status).toBe("ready"); - const beforeValue = - before.status === "ready" - ? findTextFieldValue(before.tree.root as never) - : undefined; - expect(beforeValue).toBe(2); - - // Restart mounts a fresh session: the emitted tree's count resets to 0. - transport.restart(); - const after = transport.getSnapshot(); - expect(after.status).toBe("ready"); - const afterValue = - after.status === "ready" ? findTextFieldValue(after.tree.root as never) : undefined; - expect(afterValue).toBe(0); - } finally { - transport.dispose(); - } - }); -}); diff --git a/apps/www/src/features/studio/paywalls/designer/panel-runtime/panel-sandbox-host.test.ts b/apps/www/src/features/studio/paywalls/designer/panel-runtime/panel-sandbox-host.test.ts deleted file mode 100644 index 0d1838e1b..000000000 --- a/apps/www/src/features/studio/paywalls/designer/panel-runtime/panel-sandbox-host.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -// @vitest-environment jsdom - -import type { PanelSessionInputs } from "@voidhash/paywalls/panel"; -import { afterEach, describe, expect, test, vi } from "vite-plus/test"; - -import { - createPanelSandboxTransport, - PANEL_SANDBOX_WATCHDOGS, -} from "./panel-sandbox-host"; -import { PANEL_SANDBOX_PROTOCOL, type GuestMessage } from "./sandbox-messages"; - -const INPUTS: PanelSessionInputs = { - props: {}, - selection: { count: 1 }, - data: { products: [], variables: {} }, -}; - -const VALID_TREE = { - version: 1, - root: { type: "panel", id: 0, props: {}, events: [], children: [] }, -}; - -/** - * Dispatches a `message` event on window carrying `data`. The host adds its - * listener on window, and its source check is stubbed true (see `makeTransport`), - * so this simulates a guest posting `data`. - */ -const emit = (data: unknown): void => { - window.dispatchEvent(new MessageEvent("message", { data })); -}; - -/** Reads the current sessionId off the last posted host message to the iframe. */ -const sessionIdFromPostedInit = (): string => { - const iframe = document.querySelector("iframe"); - return (iframe as unknown as { __lastPost?: { sessionId?: string } })?.__lastPost?.sessionId ?? ""; -}; - -/** - * Builds a transport with the source check stubbed to always-true and a stub - * `sandboxCode`. Captures the sessionId the host mints by intercepting the init - * postMessage to the iframe's contentWindow (patched to record posts). - */ -const makeTransport = ( - onIntents: (raw: unknown) => void = () => {}, - onFatal?: (message: string) => void, -) => { - const posts: Array<{ sessionId?: string; type?: string; seq?: number }> = []; - // Patch iframe creation: intercept the contentWindow.postMessage so we can - // read the minted sessionId and the ping seqs the host sends. - const origAppend = document.body.appendChild.bind(document.body); - const appendSpy = vi - .spyOn(document.body, "appendChild") - .mockImplementation((node: T): T => { - const result = origAppend(node as never) as T; - const iframe = node as unknown as HTMLIFrameElement; - if (iframe.tagName === "IFRAME") { - const win = iframe.contentWindow as unknown as { - postMessage?: (m: unknown) => void; - } | null; - if (win) { - win.postMessage = (m: unknown) => { - posts.push(m as never); - (iframe as unknown as { __lastPost?: unknown }).__lastPost = m; - }; - } - } - return result; - }); - - const transport = createPanelSandboxTransport({ - compiledCode: "module.exports = { default: {} };", - initialInputs: INPUTS, - onIntents, - onFatal, - sandboxCode: "/* stub iife */", - isTrustedSource: () => true, - }); - - return { transport, posts, appendSpy }; -}; - -/** Emits a `panel/ready` (no sessionId needed) then completes the handshake. */ -const completeHandshake = (sessionId: string): void => { - emit({ protocol: PANEL_SANDBOX_PROTOCOL, type: "panel/ready", sessionId }); -}; - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - document.querySelectorAll("iframe").forEach((f) => f.remove()); -}); - -describe("panel-sandbox-host — handshake + snapshots", () => { - test("starts loading and mounts a hidden iframe", () => { - const { transport } = makeTransport(); - try { - expect(transport.kind).toBe("sandbox"); - expect(transport.getSnapshot().status).toBe("loading"); - const iframe = document.querySelector("iframe"); - expect(iframe).not.toBeNull(); - expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts"); - expect(iframe?.style.display).toBe("none"); - } finally { - transport.dispose(); - } - }); - - test("a valid panel/tree transitions to ready with the decoded tree", () => { - const { transport } = makeTransport(); - try { - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - emit({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId, - type: "panel/tree", - revision: 1, - tree: VALID_TREE, - }); - const snap = transport.getSnapshot(); - expect(snap.status).toBe("ready"); - if (snap.status === "ready") { - expect(snap.revision).toBe(1); - expect(snap.tree.root.type).toBe("panel"); - } - } finally { - transport.dispose(); - } - }); - - test("a stale (lower/equal) revision is dropped", () => { - const { transport } = makeTransport(); - try { - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - const tree = (revision: number): GuestMessage => ({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId, - type: "panel/tree", - revision, - tree: VALID_TREE, - }); - emit(tree(3)); - emit(tree(2)); // stale - const snap = transport.getSnapshot(); - expect(snap.status === "ready" && snap.revision).toBe(3); - } finally { - transport.dispose(); - } - }); - - test("an invalid tree is a protocol violation, not a ready snapshot", () => { - const { transport } = makeTransport(); - try { - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - emit({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId, - type: "panel/tree", - revision: 1, - tree: { version: 1, root: { type: "not-a-real-node", id: 0, props: {}, events: [] } }, - }); - // Still loading (no valid tree accepted). - expect(transport.getSnapshot().status).toBe("loading"); - } finally { - transport.dispose(); - } - }); - - test("intents are forwarded RAW to onIntents", () => { - const received: unknown[] = []; - const { transport } = makeTransport((raw) => received.push(raw)); - try { - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - const raw = [{ type: "set-prop", name: "label", value: "hi", gesture: "commit" }]; - emit({ protocol: PANEL_SANDBOX_PROTOCOL, sessionId, type: "panel/intent", intents: raw }); - expect(received).toHaveLength(1); - expect(received[0]).toEqual(raw); - } finally { - transport.dispose(); - } - }); - - test("a guest panel/error surfaces a restartable error snapshot", () => { - const { transport } = makeTransport(); - try { - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - emit({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId, - type: "panel/error", - phase: "render", - message: "kaboom", - }); - const snap = transport.getSnapshot(); - expect(snap.status).toBe("error"); - if (snap.status === "error") { - expect(snap.restartable).toBe(true); - expect(snap.message).toContain("kaboom"); - } - } finally { - transport.dispose(); - } - }); -}); - -describe("panel-sandbox-host — watchdogs", () => { - test("init timeout with no panel/ready → error", () => { - vi.useFakeTimers(); - const { transport } = makeTransport(); - try { - expect(transport.getSnapshot().status).toBe("loading"); - vi.advanceTimersByTime(PANEL_SANDBOX_WATCHDOGS.initTimeoutMs + 10); - // Auto-restart re-mounts (still loading) — but the FIRST attempt errored. - // After the budget is spent it stays in error; here it restarts to loading. - const snap = transport.getSnapshot(); - expect(["loading", "error"]).toContain(snap.status); - } finally { - transport.dispose(); - } - }); - - test("missed pongs kill the guest with a restartable error", () => { - vi.useFakeTimers(); - const { transport } = makeTransport(); - try { - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - // Never pong; advance past (maxMissedPongs + 1) ping intervals. - const intervals = PANEL_SANDBOX_WATCHDOGS.maxMissedPongs + 2; - vi.advanceTimersByTime(PANEL_SANDBOX_WATCHDOGS.pingIntervalMs * intervals + 10); - const snap = transport.getSnapshot(); - // A kill either shows the error, or auto-restarted back to loading. - expect(["error", "loading"]).toContain(snap.status); - } finally { - transport.dispose(); - } - }); - - test("a tree flood kills the guest", () => { - const onFatal = vi.fn(); - const { transport } = makeTransport(() => {}, onFatal); - try { - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - // Emit far more than maxTreesPerSecond within the window. - const flood = PANEL_SANDBOX_WATCHDOGS.maxTreesPerSecond * 4; - let rev = 1; - let errored = false; - for (let i = 0; i < flood; i++) { - emit({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId, - type: "panel/tree", - revision: rev++, - tree: VALID_TREE, - }); - if (transport.getSnapshot().status === "error") { - errored = true; - break; - } - } - expect(errored).toBe(true); - } finally { - transport.dispose(); - } - }); - - test("repeated protocol violations exhaust the budget → error", () => { - const { transport } = makeTransport(); - try { - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - // Send many malformed (wrong-schema) messages that pass the source check. - let errored = false; - for (let i = 0; i < PANEL_SANDBOX_WATCHDOGS.maxProtocolViolations + 5; i++) { - emit({ protocol: PANEL_SANDBOX_PROTOCOL, sessionId, type: "panel/garbage" }); - if (transport.getSnapshot().status === "error") { - errored = true; - break; - } - } - expect(errored).toBe(true); - } finally { - transport.dispose(); - } - }); -}); - -describe("panel-sandbox-host — lifecycle", () => { - test("restart regenerates the sessionId; old-session messages are ignored", () => { - const { transport } = makeTransport(); - try { - const first = sessionIdFromPostedInit(); - completeHandshake(first); - emit({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId: first, - type: "panel/tree", - revision: 1, - tree: VALID_TREE, - }); - expect(transport.getSnapshot().status).toBe("ready"); - - transport.restart(); - const second = sessionIdFromPostedInit(); - expect(second).not.toBe(first); - expect(transport.getSnapshot().status).toBe("loading"); - - // An old-session tree must NOT be accepted. - emit({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId: first, - type: "panel/tree", - revision: 9, - tree: VALID_TREE, - }); - expect(transport.getSnapshot().status).toBe("loading"); - - // The new session works. - completeHandshake(second); - emit({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId: second, - type: "panel/tree", - revision: 1, - tree: VALID_TREE, - }); - expect(transport.getSnapshot().status).toBe("ready"); - } finally { - transport.dispose(); - } - }); - - test("dispose removes the iframe and detaches the listener", () => { - const { transport } = makeTransport(); - const sessionId = sessionIdFromPostedInit(); - completeHandshake(sessionId); - transport.dispose(); - expect(document.querySelector("iframe")).toBeNull(); - // Post-dispose messages are inert (no throw, no snapshot change). - emit({ - protocol: PANEL_SANDBOX_PROTOCOL, - sessionId, - type: "panel/tree", - revision: 1, - tree: VALID_TREE, - }); - expect(transport.getSnapshot().status).not.toBe("ready"); - }); -}); diff --git a/apps/www/src/features/studio/shell/components/dashboard-sidebar/dashboard-sidebar-provider.tsx b/apps/www/src/features/studio/shell/components/dashboard-sidebar/dashboard-sidebar-provider.tsx deleted file mode 100644 index 4bd4e6ef8..000000000 --- a/apps/www/src/features/studio/shell/components/dashboard-sidebar/dashboard-sidebar-provider.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// import { SidebarProvider } from "@voidhash/ui"; - -// export async function DashboardSidebarProvider({ -// children, -// }: { children: React.ReactNode }) { -// return ( -// -// {children} -// -// ); -// } diff --git a/apps/www/src/features/studio/shell/components/dashboard-sidebar/index.ts b/apps/www/src/features/studio/shell/components/dashboard-sidebar/index.ts deleted file mode 100644 index 1e263cf41..000000000 --- a/apps/www/src/features/studio/shell/components/dashboard-sidebar/index.ts +++ /dev/null @@ -1 +0,0 @@ -// export * from './dashboard-sidebar-provider'; diff --git a/apps/www/src/features/studio/shell/components/sidebar/project-settings-sidebar.tsx b/apps/www/src/features/studio/shell/components/sidebar/project-settings-sidebar.tsx deleted file mode 100644 index 3502028c9..000000000 --- a/apps/www/src/features/studio/shell/components/sidebar/project-settings-sidebar.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// "use client"; -// import type * as React from "react"; - -// import { Link, useLocation } from "@tanstack/react-router"; -// import type { User } from "@voidhash/api-contracts"; -// import { -// GradientAvatar, -// Sidebar, -// SidebarContent, -// SidebarHeader, -// Skeleton, -// } from "@voidhash/ui"; -// import { ChevronLeft } from "lucide-react"; -// import { useAuth } from "@/features/studio/components/auth-context"; - -// import { NavMain } from "./nav-main"; - -// type Organization = (typeof User.Type)["organizations"][number]; - -// const ActiveOrganization = ({ activeOrganization }: { activeOrganization: Organization }) => ( -//
-// -// - -// {activeOrganization.name} -//
-// ); - -// const ActiveOrganizationSkeleton = () => ( -// <> -// -// -// -// ); - -// export function ProjectSettingsSidebar({ -// organizationSlug, -// projectSlug, -// ...props -// }: React.ComponentProps & { -// organizationSlug: string; -// projectSlug: string; -// }) { -// const pathname = useLocation({ -// select: (location) => location.pathname, -// }); -// const { user } = useAuth(); - -// const data = { -// navMain: [ -// { -// items: [ -// { -// isActive: () => -// pathname.startsWith(`/studio/${organizationSlug}/${projectSlug}/settings/general`), -// title: "General", -// url: `/studio/${organizationSlug}/${projectSlug}/settings/general`, -// }, -// { -// isActive: () => -// pathname.startsWith( -// `/studio/${organizationSlug}/${projectSlug}/settings/payment-providers`, -// ), -// title: "Payment Providers", -// url: `/studio/${organizationSlug}/${projectSlug}/settings/payment-providers`, -// }, -// ], -// title: "Project", -// }, -// ], -// }; - -// return ( -// -// -//
-// -// {user.organizations.find((o) => o.slug === organizationSlug) && -// (() => { -// const activeOrganization = user.organizations.find( -// (o) => o.slug === organizationSlug, -// ); -// if (!activeOrganization) { -// return ; -// } -// return ; -// })()} -// - -//
Project Settings
-//
-//
-// -// -// -//
-// ); -// } diff --git a/apps/www/src/lib/waitlist.ts b/apps/www/src/lib/waitlist.ts deleted file mode 100644 index 0e124f89a..000000000 --- a/apps/www/src/lib/waitlist.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { INTERNAL_FEATURE_FLAGS } from "@voidhash/rpc"; - -/** - * Whether Voidhash is currently in waitlist mode. - * - * Read from the `waitlist` internal feature flag's *code default* rather than - * from a resolved per-org value, because the marketing pages and the auth - * screens run before there is any organization (or even a user) to scope a - * flag lookup to. Per-organization access is still decided by the resolved - * flag — see {@link isOrganizationWaitlisted}. - * - * Flipping `waitlist.defaultEnabled` to `false` in the flag registry turns the - * whole system off: the CTAs revert to their normal copy and every - * organization without an explicit override is let straight into Studio. - */ -export const WAITLIST_MODE = INTERNAL_FEATURE_FLAGS.waitlist.defaultEnabled; - -/** The CTA label shown in place of a sign-up call to action while in waitlist mode. */ -export const WAITLIST_CTA_LABEL = "Join the waitlist"; - -/** - * The label for a call to action that leads to sign-up: the waitlist label - * while in waitlist mode, otherwise the page's own copy. - * - * @example - *
{signUpCtaLabel("Start for free")} - */ -export const signUpCtaLabel = (defaultLabel: string) => - WAITLIST_MODE ? WAITLIST_CTA_LABEL : defaultLabel; - -/** - * Whether an organization is being held on the waitlist and must not be let - * into Studio. Reads the flags resolved onto the `CurrentUser` bootstrap. - */ -export const isOrganizationWaitlisted = (organization: { - readonly internalFeatureFlags: readonly string[]; -}) => organization.internalFeatureFlags.includes(INTERNAL_FEATURE_FLAGS.waitlist.key); diff --git a/apps/www/src/routeTree.gen.ts b/apps/www/src/routeTree.gen.ts index fbb51364c..11539d18b 100644 --- a/apps/www/src/routeTree.gen.ts +++ b/apps/www/src/routeTree.gen.ts @@ -8,70 +8,70 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as StudioRouteRouteImport } from './routes/studio/route' -import { Route as DocsRouteRouteImport } from './routes/docs/route' -import { Route as AuthRouteRouteImport } from './routes/auth/route' -import { Route as MarketingRouteRouteImport } from './routes/_marketing/route' -import { Route as DocsIndexRouteImport } from './routes/docs/index' -import { Route as AuthIndexRouteImport } from './routes/auth/index' -import { Route as MarketingIndexRouteImport } from './routes/_marketing/index' -import { Route as DocsSplatRouteImport } from './routes/docs/$' -import { Route as AuthVerifyEmailRouteImport } from './routes/auth/verify-email' -import { Route as AuthSignUpRouteImport } from './routes/auth/sign-up' -import { Route as AuthResetPasswordRouteImport } from './routes/auth/reset-password' -import { Route as AuthLogoutRouteImport } from './routes/auth/logout' -import { Route as AuthLoginRouteImport } from './routes/auth/login' -import { Route as AuthForgotPasswordRouteImport } from './routes/auth/forgot-password' -import { Route as StudioAuthenticatedRouteRouteImport } from './routes/studio/_authenticated/route' -import { Route as StudioAuthenticatedIndexRouteImport } from './routes/studio/_authenticated/index' -import { Route as AuthDevicesIndexRouteImport } from './routes/auth/devices/index' -import { Route as StudioAuthenticatedWaitlistRouteImport } from './routes/studio/_authenticated/waitlist' -import { Route as DocsApiSearchRouteImport } from './routes/docs/api/search' -import { Route as DocsApiProxyRouteImport } from './routes/docs/api/proxy' -import { Route as ApiAuthSignOutRouteImport } from './routes/api/auth/sign-out' -import { Route as ApiAuthSignInRouteImport } from './routes/api/auth/sign-in' -import { Route as ApiAuthSessionRouteImport } from './routes/api/auth/session' -import { Route as StudioAuthenticatedDashboardRouteRouteImport } from './routes/studio/_authenticated/_dashboard/route' -import { Route as StudioAuthenticatedCreateOrganizationIndexRouteImport } from './routes/studio/_authenticated/create-organization/index' -import { Route as StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteImport } from './routes/studio/_authenticated/_dashboard/_organization/$organizationSlug/route' -import { Route as StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_organization/$organizationSlug/index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/route' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/index' -import { Route as StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRouteImport } from './routes/studio/_authenticated/_designer/$organizationSlug.$projectSlug.design.$id' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/overview' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments.index' -import { Route as StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/perks' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/api-keys' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products.$id' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons.$id' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.$id' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags.$id' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments.$id' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.trials' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.subscribers' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.revenue' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.query' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.insights' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.dashboards' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.churn' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity.sent-notifications' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity.events' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations.$id' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers.$paymentProviderConfigurationId' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications.$providerConfigurationId' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks.$endpointId.index' -import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRouteImport } from './routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks.$endpointId.$deliveryId' +import { Route as rootRouteImport } from './../../../packages/web-app/src/routes/__root' +import { Route as StudioRouteRouteImport } from './../../../packages/web-app/src/routes/shared/studio/route' +import { Route as DocsRouteRouteImport } from './../../../packages/web-app/src/routes/shared/docs/route' +import { Route as AuthRouteRouteImport } from './../../../packages/web-app/src/routes/shared/auth/route' +import { Route as MarketingRouteRouteImport } from './../../../packages/web-app/src/routes/shared/_marketing/route' +import { Route as DocsIndexRouteImport } from './../../../packages/web-app/src/routes/shared/docs/index' +import { Route as AuthIndexRouteImport } from './../../../packages/web-app/src/routes/shared/auth/index' +import { Route as MarketingIndexRouteImport } from './../../../packages/web-app/src/routes/community/_marketing/index' +import { Route as DocsSplatRouteImport } from './../../../packages/web-app/src/routes/shared/docs/$' +import { Route as AuthVerifyEmailRouteImport } from './../../../packages/web-app/src/routes/community/auth/verify-email' +import { Route as AuthSignUpRouteImport } from './../../../packages/web-app/src/routes/community/auth/sign-up' +import { Route as AuthResetPasswordRouteImport } from './../../../packages/web-app/src/routes/community/auth/reset-password' +import { Route as AuthLogoutRouteImport } from './../../../packages/web-app/src/routes/shared/auth/logout' +import { Route as AuthLoginRouteImport } from './../../../packages/web-app/src/routes/community/auth/login' +import { Route as AuthForgotPasswordRouteImport } from './../../../packages/web-app/src/routes/community/auth/forgot-password' +import { Route as StudioAuthenticatedRouteRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/route' +import { Route as StudioAuthenticatedIndexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/index' +import { Route as AuthDevicesIndexRouteImport } from './../../../packages/web-app/src/routes/shared/auth/devices/index' +import { Route as StudioAuthenticatedWaitlistRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/waitlist' +import { Route as DocsApiSearchRouteImport } from './../../../packages/web-app/src/routes/shared/docs/api/search' +import { Route as DocsApiProxyRouteImport } from './../../../packages/web-app/src/routes/shared/docs/api/proxy' +import { Route as ApiAuthSignOutRouteImport } from './../../../packages/web-app/src/routes/community/api/auth/sign-out' +import { Route as ApiAuthSignInRouteImport } from './../../../packages/web-app/src/routes/community/api/auth/sign-in' +import { Route as ApiAuthSessionRouteImport } from './../../../packages/web-app/src/routes/community/api/auth/session' +import { Route as StudioAuthenticatedDashboardRouteRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/route' +import { Route as StudioAuthenticatedCreateOrganizationIndexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/create-organization/index' +import { Route as StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_organization/$organizationSlug/route' +import { Route as StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_organization/$organizationSlug/index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/route' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/index' +import { Route as StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_designer/$organizationSlug.$projectSlug.design.$id' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/overview' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments.index' +import { Route as StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/perks' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/api-keys' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products.$id' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons.$id' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls.$id' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags.$id' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments.$id' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.trials' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.subscribers' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.revenue' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.query' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.insights' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.dashboards' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics.churn' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity.sent-notifications' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity.events' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations.$id' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers.$paymentProviderConfigurationId' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications.$providerConfigurationId' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks.$endpointId.index' +import { Route as StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRouteImport } from './../../../packages/web-app/src/routes/shared/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks.$endpointId.$deliveryId' const StudioRouteRoute = StudioRouteRouteImport.update({ id: '/studio', @@ -234,8 +234,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute = StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute = - StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRouteImport.update( +const StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRoute = + StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRouteImport.update( { id: '/_designer/$organizationSlug/$projectSlug/design/$id', path: '/$organizationSlug/$projectSlug/design/$id', @@ -260,8 +260,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsInde StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRouteImport.update( { id: '/products/', path: '/products/', @@ -269,8 +269,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsInde StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRouteImport.update( { id: '/persons/', path: '/persons/', @@ -278,8 +278,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndex StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRouteImport.update( { id: '/paywalls/', path: '/paywalls/', @@ -287,8 +287,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsInde StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRouteImport.update( { id: '/flags/', path: '/flags/', @@ -296,8 +296,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRo StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRouteImport.update( { id: '/experiments/', path: '/experiments/', @@ -305,8 +305,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsI StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute = - StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRouteImport.update( +const StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRoute = + StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRouteImport.update( { id: '/~/settings/', path: '/~/settings/', @@ -332,8 +332,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiK StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRouteImport.update( { id: '/products/$id', path: '/products/$id', @@ -341,8 +341,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRo StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRouteImport.update( { id: '/persons/$id', path: '/persons/$id', @@ -350,8 +350,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRou StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRouteImport.update( { id: '/paywalls/$id', path: '/paywalls/$id', @@ -359,8 +359,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRo StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRouteImport.update( { id: '/flags/$id', path: '/flags/$id', @@ -368,8 +368,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRouteImport.update( { id: '/experiments/$id', path: '/experiments/$id', @@ -377,8 +377,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsI StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRouteImport.update( { id: '/analytics/trials', path: '/analytics/trials', @@ -386,8 +386,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTri StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRouteImport.update( { id: '/analytics/subscribers', path: '/analytics/subscribers', @@ -395,8 +395,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSub StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRouteImport.update( { id: '/analytics/revenue', path: '/analytics/revenue', @@ -404,8 +404,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRev StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRouteImport.update( { id: '/analytics/query', path: '/analytics/query', @@ -413,8 +413,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQue StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRouteImport.update( { id: '/analytics/insights', path: '/analytics/insights', @@ -422,8 +422,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsIns StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRouteImport.update( { id: '/analytics/dashboards', path: '/analytics/dashboards', @@ -431,8 +431,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDas StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRouteImport.update( { id: '/analytics/churn', path: '/analytics/churn', @@ -440,8 +440,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChu StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRouteImport.update( { id: '/activity/sent-notifications', path: '/activity/sent-notifications', @@ -449,8 +449,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySent StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRouteImport.update( { id: '/activity/events', path: '/activity/events', @@ -458,8 +458,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEven StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRouteImport.update( { id: '/settings/webhooks/', path: '/settings/webhooks/', @@ -467,8 +467,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebh StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRouteImport.update( { id: '/settings/paywall-locations/', path: '/settings/paywall-locations/', @@ -476,8 +476,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPayw StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRouteImport.update( { id: '/settings/payment-providers/', path: '/settings/payment-providers/', @@ -485,8 +485,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaym StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRouteImport.update( { id: '/settings/notifications/', path: '/settings/notifications/', @@ -494,8 +494,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNoti StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRouteImport.update( { id: '/settings/paywall-locations/$id', path: '/settings/paywall-locations/$id', @@ -503,8 +503,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPayw StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRouteImport.update( { id: '/settings/payment-providers/$paymentProviderConfigurationId', path: '/settings/payment-providers/$paymentProviderConfigurationId', @@ -512,8 +512,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaym StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRouteImport.update( { id: '/settings/notifications/$providerConfigurationId', path: '/settings/notifications/$providerConfigurationId', @@ -521,8 +521,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNoti StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRouteImport.update( { id: '/settings/webhooks/$endpointId/', path: '/settings/webhooks/$endpointId/', @@ -530,8 +530,8 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebh StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute, } as any, ) -const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRoute = - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRouteImport.update( +const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRoute = + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRouteImport.update( { id: '/settings/webhooks/$endpointId/$deliveryId', path: '/settings/webhooks/$endpointId/$deliveryId', @@ -567,40 +567,40 @@ export interface FileRoutesByFullPath { '/studio/$organizationSlug/$projectSlug': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteWithChildren '/studio/$organizationSlug/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute '/studio/$organizationSlug/$projectSlug/overview': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute - '/studio/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute + '/studio/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRoute '/studio/$organizationSlug/$projectSlug/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute - '/studio/$organizationSlug/$projectSlug/activity/events': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute - '/studio/$organizationSlug/$projectSlug/activity/sent-notifications': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRoute - '/studio/$organizationSlug/$projectSlug/analytics/churn': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRoute - '/studio/$organizationSlug/$projectSlug/analytics/dashboards': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRoute - '/studio/$organizationSlug/$projectSlug/analytics/insights': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRoute - '/studio/$organizationSlug/$projectSlug/analytics/query': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRoute - '/studio/$organizationSlug/$projectSlug/analytics/revenue': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRoute - '/studio/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute - '/studio/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute - '/studio/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute - '/studio/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute - '/studio/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute - '/studio/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute - '/studio/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute + '/studio/$organizationSlug/$projectSlug/activity/events': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRoute + '/studio/$organizationSlug/$projectSlug/activity/sent-notifications': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRoute + '/studio/$organizationSlug/$projectSlug/analytics/churn': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRoute + '/studio/$organizationSlug/$projectSlug/analytics/dashboards': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRoute + '/studio/$organizationSlug/$projectSlug/analytics/insights': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRoute + '/studio/$organizationSlug/$projectSlug/analytics/query': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRoute + '/studio/$organizationSlug/$projectSlug/analytics/revenue': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRoute + '/studio/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRoute + '/studio/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRoute + '/studio/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRoute + '/studio/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRoute + '/studio/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRoute + '/studio/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRoute + '/studio/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRoute '/studio/$organizationSlug/$projectSlug/settings/api-keys': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute '/studio/$organizationSlug/$projectSlug/settings/perks': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute - '/studio/$organizationSlug/~/settings/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute - '/studio/$organizationSlug/$projectSlug/experiments/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute - '/studio/$organizationSlug/$projectSlug/flags/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute - '/studio/$organizationSlug/$projectSlug/paywalls/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute - '/studio/$organizationSlug/$projectSlug/persons/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRoute - '/studio/$organizationSlug/$projectSlug/products/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRoute + '/studio/$organizationSlug/~/settings/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRoute + '/studio/$organizationSlug/$projectSlug/experiments/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRoute + '/studio/$organizationSlug/$projectSlug/flags/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRoute + '/studio/$organizationSlug/$projectSlug/paywalls/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRoute + '/studio/$organizationSlug/$projectSlug/persons/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRoute + '/studio/$organizationSlug/$projectSlug/products/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRoute '/studio/$organizationSlug/$projectSlug/settings/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRoute - '/studio/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRoute - '/studio/$organizationSlug/$projectSlug/settings/paywall-locations/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRoute - '/studio/$organizationSlug/$projectSlug/settings/notifications/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/payment-providers/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/paywall-locations/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/webhooks/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRoute - '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRoute + '/studio/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRoute + '/studio/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRoute + '/studio/$organizationSlug/$projectSlug/settings/paywall-locations/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRoute + '/studio/$organizationSlug/$projectSlug/settings/notifications/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRoute + '/studio/$organizationSlug/$projectSlug/settings/payment-providers/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRoute + '/studio/$organizationSlug/$projectSlug/settings/paywall-locations/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRoute + '/studio/$organizationSlug/$projectSlug/settings/webhooks/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRoute + '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRoute + '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRoute } export interface FileRoutesByTo { '/studio': typeof StudioAuthenticatedIndexRoute @@ -624,40 +624,40 @@ export interface FileRoutesByTo { '/studio/create-organization': typeof StudioAuthenticatedCreateOrganizationIndexRoute '/studio/$organizationSlug': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute '/studio/$organizationSlug/$projectSlug/overview': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute - '/studio/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute + '/studio/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRoute '/studio/$organizationSlug/$projectSlug': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute - '/studio/$organizationSlug/$projectSlug/activity/events': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute - '/studio/$organizationSlug/$projectSlug/activity/sent-notifications': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRoute - '/studio/$organizationSlug/$projectSlug/analytics/churn': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRoute - '/studio/$organizationSlug/$projectSlug/analytics/dashboards': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRoute - '/studio/$organizationSlug/$projectSlug/analytics/insights': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRoute - '/studio/$organizationSlug/$projectSlug/analytics/query': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRoute - '/studio/$organizationSlug/$projectSlug/analytics/revenue': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRoute - '/studio/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute - '/studio/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute - '/studio/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute - '/studio/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute - '/studio/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute - '/studio/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute - '/studio/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute + '/studio/$organizationSlug/$projectSlug/activity/events': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRoute + '/studio/$organizationSlug/$projectSlug/activity/sent-notifications': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRoute + '/studio/$organizationSlug/$projectSlug/analytics/churn': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRoute + '/studio/$organizationSlug/$projectSlug/analytics/dashboards': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRoute + '/studio/$organizationSlug/$projectSlug/analytics/insights': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRoute + '/studio/$organizationSlug/$projectSlug/analytics/query': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRoute + '/studio/$organizationSlug/$projectSlug/analytics/revenue': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRoute + '/studio/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRoute + '/studio/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRoute + '/studio/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRoute + '/studio/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRoute + '/studio/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRoute + '/studio/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRoute + '/studio/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRoute '/studio/$organizationSlug/$projectSlug/settings/api-keys': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute '/studio/$organizationSlug/$projectSlug/settings/perks': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute - '/studio/$organizationSlug/~/settings': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute - '/studio/$organizationSlug/$projectSlug/experiments': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute - '/studio/$organizationSlug/$projectSlug/flags': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute - '/studio/$organizationSlug/$projectSlug/paywalls': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute - '/studio/$organizationSlug/$projectSlug/persons': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRoute - '/studio/$organizationSlug/$projectSlug/products': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRoute + '/studio/$organizationSlug/~/settings': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRoute + '/studio/$organizationSlug/$projectSlug/experiments': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRoute + '/studio/$organizationSlug/$projectSlug/flags': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRoute + '/studio/$organizationSlug/$projectSlug/paywalls': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRoute + '/studio/$organizationSlug/$projectSlug/persons': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRoute + '/studio/$organizationSlug/$projectSlug/products': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRoute '/studio/$organizationSlug/$projectSlug/settings': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRoute - '/studio/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRoute - '/studio/$organizationSlug/$projectSlug/settings/paywall-locations/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRoute - '/studio/$organizationSlug/$projectSlug/settings/notifications': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/payment-providers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/paywall-locations': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/webhooks': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRoute - '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRoute - '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRoute + '/studio/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRoute + '/studio/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRoute + '/studio/$organizationSlug/$projectSlug/settings/paywall-locations/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRoute + '/studio/$organizationSlug/$projectSlug/settings/notifications': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRoute + '/studio/$organizationSlug/$projectSlug/settings/payment-providers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRoute + '/studio/$organizationSlug/$projectSlug/settings/paywall-locations': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRoute + '/studio/$organizationSlug/$projectSlug/settings/webhooks': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRoute + '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRoute + '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -690,40 +690,40 @@ export interface FileRoutesById { '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteWithChildren '/studio/_authenticated/_dashboard/_organization/$organizationSlug/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/overview': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute - '/studio/_authenticated/_designer/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute + '/studio/_authenticated/_designer/$organizationSlug/$projectSlug/design/$id': typeof StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity/events': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity/sent-notifications': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/churn': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/dashboards': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/insights': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/query': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/revenue': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity/events': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity/sent-notifications': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/churn': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/dashboards': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/insights': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/query': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/revenue': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/subscribers': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/trials': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/api-keys': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/perks': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute - '/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRoute + '/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings/': typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRoute '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRoute - '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations/$id': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRoute + '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/': typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -1131,7 +1131,7 @@ declare module '@tanstack/react-router' { id: '/studio/_authenticated/_designer/$organizationSlug/$projectSlug/design/$id' path: '/$organizationSlug/$projectSlug/design/$id' fullPath: '/studio/$organizationSlug/$projectSlug/design/$id' - preLoaderRoute: typeof StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRouteImport parentRoute: typeof StudioAuthenticatedRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/overview': { @@ -1152,42 +1152,42 @@ declare module '@tanstack/react-router' { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products/' path: '/products' fullPath: '/studio/$organizationSlug/$projectSlug/products/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/' path: '/persons' fullPath: '/studio/$organizationSlug/$projectSlug/persons/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/' path: '/paywalls' fullPath: '/studio/$organizationSlug/$projectSlug/paywalls/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/' path: '/flags' fullPath: '/studio/$organizationSlug/$projectSlug/flags/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/' path: '/experiments' fullPath: '/studio/$organizationSlug/$projectSlug/experiments/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings/': { id: '/studio/_authenticated/_dashboard/_organization/$organizationSlug/~/settings/' path: '/~/settings' fullPath: '/studio/$organizationSlug/~/settings/' - preLoaderRoute: typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/perks': { @@ -1208,161 +1208,161 @@ declare module '@tanstack/react-router' { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/products/$id' path: '/products/$id' fullPath: '/studio/$organizationSlug/$projectSlug/products/$id' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/$id': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/persons/$id' path: '/persons/$id' fullPath: '/studio/$organizationSlug/$projectSlug/persons/$id' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/paywalls/$id' path: '/paywalls/$id' fullPath: '/studio/$organizationSlug/$projectSlug/paywalls/$id' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/$id': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/flags/$id' path: '/flags/$id' fullPath: '/studio/$organizationSlug/$projectSlug/flags/$id' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/$id': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/experiments/$id' path: '/experiments/$id' fullPath: '/studio/$organizationSlug/$projectSlug/experiments/$id' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/trials': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/trials' path: '/analytics/trials' fullPath: '/studio/$organizationSlug/$projectSlug/analytics/trials' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/subscribers': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/subscribers' path: '/analytics/subscribers' fullPath: '/studio/$organizationSlug/$projectSlug/analytics/subscribers' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/revenue': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/revenue' path: '/analytics/revenue' fullPath: '/studio/$organizationSlug/$projectSlug/analytics/revenue' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/query': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/query' path: '/analytics/query' fullPath: '/studio/$organizationSlug/$projectSlug/analytics/query' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/insights': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/insights' path: '/analytics/insights' fullPath: '/studio/$organizationSlug/$projectSlug/analytics/insights' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/dashboards': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/dashboards' path: '/analytics/dashboards' fullPath: '/studio/$organizationSlug/$projectSlug/analytics/dashboards' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/churn': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/analytics/churn' path: '/analytics/churn' fullPath: '/studio/$organizationSlug/$projectSlug/analytics/churn' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity/sent-notifications': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity/sent-notifications' path: '/activity/sent-notifications' fullPath: '/studio/$organizationSlug/$projectSlug/activity/sent-notifications' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity/events': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/activity/events' path: '/activity/events' fullPath: '/studio/$organizationSlug/$projectSlug/activity/events' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/' path: '/settings/webhooks' fullPath: '/studio/$organizationSlug/$projectSlug/settings/webhooks/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations/' path: '/settings/paywall-locations' fullPath: '/studio/$organizationSlug/$projectSlug/settings/paywall-locations/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers/' path: '/settings/payment-providers' fullPath: '/studio/$organizationSlug/$projectSlug/settings/payment-providers/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications/' path: '/settings/notifications' fullPath: '/studio/$organizationSlug/$projectSlug/settings/notifications/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations/$id': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/paywall-locations/$id' path: '/settings/paywall-locations/$id' fullPath: '/studio/$organizationSlug/$projectSlug/settings/paywall-locations/$id' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId' path: '/settings/payment-providers/$paymentProviderConfigurationId' fullPath: '/studio/$organizationSlug/$projectSlug/settings/payment-providers/$paymentProviderConfigurationId' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId' path: '/settings/notifications/$providerConfigurationId' fullPath: '/studio/$organizationSlug/$projectSlug/settings/notifications/$providerConfigurationId' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/' path: '/settings/webhooks/$endpointId' fullPath: '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId': { id: '/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId' path: '/settings/webhooks/$endpointId/$deliveryId' fullPath: '/studio/$organizationSlug/$projectSlug/settings/webhooks/$endpointId/$deliveryId' - preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRouteImport + preLoaderRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRouteImport parentRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRoute } } @@ -1426,15 +1426,15 @@ const DocsRouteRouteWithChildren = DocsRouteRoute._addFileChildren( interface StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteChildren { StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute: typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute - StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute: typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute + StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRoute: typeof StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRoute } const StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteChildren: StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteChildren = { StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute: StudioAuthenticatedDashboardOrganizationOrganizationSlugIndexRoute, - StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute: - StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsIndexRoute, + StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRoute: + StudioAuthenticatedDashboardOrganizationOrganizationSlugChar126SettingsDotindexRoute, } const StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteWithChildren = @@ -1445,37 +1445,37 @@ const StudioAuthenticatedDashboardOrganizationOrganizationSlugRouteRouteWithChil interface StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteChildren { StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRoute StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRoute - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRoute + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRoute: typeof StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRoute } const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteChildren: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteChildren = @@ -1484,68 +1484,68 @@ const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteCh StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugOverviewRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityEventsRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivitySentNotificationsRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsChurnRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDashboardsRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsInsightsRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsQueryRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsRevenueRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsSubscribersRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsTrialsRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIdRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIdRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIdRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIdRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIdRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDoteventsRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugActivityDotsentNotificationsRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotchurnRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotdashboardsRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotinsightsRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotqueryRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotrevenueRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDotsubscribersRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugAnalyticsDottrialsRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotidRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotidRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotidRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotidRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotidRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsApiKeysRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPerksRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsIndexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugExperimentsDotindexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugFlagsDotindexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPaywallsDotindexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugPersonsDotindexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugProductsDotindexRoute, StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRoute: StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsProviderConfigurationIdRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersPaymentProviderConfigurationIdRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIdRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksIndexRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdDeliveryIdRoute, - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRoute: - StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksEndpointIdIndexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotproviderConfigurationIdRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotpaymentProviderConfigurationIdRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotidRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsNotificationsDotindexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaymentProvidersDotindexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsPaywallLocationsDotindexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotindexRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotdeliveryIdRoute, + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRoute: + StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugSettingsWebhooksDotendpointIdDotindexRoute, } const StudioAuthenticatedDashboardProjectOrganizationSlugProjectSlugRouteRouteWithChildren = @@ -1576,7 +1576,7 @@ interface StudioAuthenticatedRouteRouteChildren { StudioAuthenticatedWaitlistRoute: typeof StudioAuthenticatedWaitlistRoute StudioAuthenticatedIndexRoute: typeof StudioAuthenticatedIndexRoute StudioAuthenticatedCreateOrganizationIndexRoute: typeof StudioAuthenticatedCreateOrganizationIndexRoute - StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute: typeof StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute + StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRoute: typeof StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRoute } const StudioAuthenticatedRouteRouteChildren: StudioAuthenticatedRouteRouteChildren = @@ -1587,8 +1587,8 @@ const StudioAuthenticatedRouteRouteChildren: StudioAuthenticatedRouteRouteChildr StudioAuthenticatedIndexRoute: StudioAuthenticatedIndexRoute, StudioAuthenticatedCreateOrganizationIndexRoute: StudioAuthenticatedCreateOrganizationIndexRoute, - StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute: - StudioAuthenticatedDesignerOrganizationSlugProjectSlugDesignIdRoute, + StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRoute: + StudioAuthenticatedDesignerOrganizationSlugDotprojectSlugDotdesignDotidRoute, } const StudioAuthenticatedRouteRouteWithChildren = diff --git a/apps/www/src/routes/studio/_authenticated/_dashboard/_organization/$organizationSlug/route.tsx b/apps/www/src/routes/studio/_authenticated/_dashboard/_organization/$organizationSlug/route.tsx deleted file mode 100644 index c6c1f12e1..000000000 --- a/apps/www/src/routes/studio/_authenticated/_dashboard/_organization/$organizationSlug/route.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Outlet, createFileRoute, useLocation } from "@tanstack/react-router"; -import { SidebarInset, useSidebar } from "@voidhash/ui"; -import { useEffect } from "react"; - -import { NavBar } from "@/features/studio/shell"; -import { OrganizationSidebar } from "@/features/studio/shell/components/sidebar/organization-sidebar"; - -export const Route = createFileRoute( - "/studio/_authenticated/_dashboard/_organization/$organizationSlug", -)({ - component: OrganizationLayout, -}); - -function LayoutSidebar({ - organizationSidebar, - organizationSettingsSidebar, -}: { - organizationSidebar: React.ReactNode; - organizationSettingsSidebar: React.ReactNode; -}) { - const pathname = useLocation({ - select: (location) => location.pathname, - }); - const isSettingsRoute = pathname.includes("/settings"); - - const { setOpen } = useSidebar(); - useEffect(() => { - if (isSettingsRoute) { - setOpen(false); - } else if (!isSettingsRoute) { - setOpen(true); - } - }, [isSettingsRoute, setOpen]); - - return ( -
- {organizationSidebar} - {isSettingsRoute && organizationSettingsSidebar} -
- ); -} - -function OrganizationLayout() { - const { organizationSlug } = Route.useParams(); - - return ( - <> - -
- - - - - -
- - ); -} diff --git a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/route.tsx b/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/route.tsx deleted file mode 100644 index e17f01703..000000000 --- a/apps/www/src/routes/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug/route.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Outlet, createFileRoute, useLocation } from "@tanstack/react-router"; -import { SidebarInset, useSidebar } from "@voidhash/ui"; -import { useEffect } from "react"; - -import { NavBar } from "@/features/studio/shell"; -import { ProjectSidebar } from "@/features/studio/shell/components/sidebar/project-sidebar"; - -export const Route = createFileRoute( - "/studio/_authenticated/_dashboard/_project/$organizationSlug/$projectSlug", -)({ - component: ProjectLayout, -}); - -function LayoutSidebar({ - projectSidebar, - projectSettingsSidebar, -}: { - projectSidebar: React.ReactNode; - projectSettingsSidebar: React.ReactNode; -}) { - const pathname = useLocation({ - select: (location) => location.pathname, - }); - const isSettingsRoute = pathname.includes("/settings"); - - const { setOpen } = useSidebar(); - useEffect(() => { - if (isSettingsRoute) { - setOpen(false); - } else if (!isSettingsRoute) { - setOpen(true); - } - }, [isSettingsRoute, setOpen]); - - return ( -
- {projectSidebar} - {isSettingsRoute && projectSettingsSidebar} -
- ); -} - -function ProjectLayout() { - const { organizationSlug, projectSlug } = Route.useParams(); - - return ( - <> - -
- - - - - -
- - ); -} diff --git a/apps/www/src/server.ts b/apps/www/src/server.ts index 867392c09..b5ca3d103 100644 --- a/apps/www/src/server.ts +++ b/apps/www/src/server.ts @@ -1,5 +1 @@ -import { createStartHandler, defaultStreamHandler } from "@tanstack/react-start/server"; - -const fetch = createStartHandler(defaultStreamHandler); - -export default { fetch }; +export { default } from "@voidhash/web-app/server"; diff --git a/apps/www/src/start.ts b/apps/www/src/start.ts index 3793ce78e..e067c8b97 100644 --- a/apps/www/src/start.ts +++ b/apps/www/src/start.ts @@ -1,45 +1 @@ -import { createCsrfMiddleware, createStart } from "@tanstack/react-start"; - -import { authRequestMiddleware } from "@/features/auth/adapter/session-adapter"; - -const localHostnames = new Set(["0.0.0.0", "127.0.0.1", "[::1]", "localhost"]); - -const requestOrigin = (request: Request): string => { - const internalUrl = new URL(request.url); - if (!localHostnames.has(internalUrl.hostname)) { - return internalUrl.origin; - } - - const forwardedHost = request.headers.get("X-Forwarded-Host"); - const forwardedProtocol = request.headers.get("X-Forwarded-Proto"); - if (!(forwardedHost && forwardedProtocol)) { - return internalUrl.origin; - } - - try { - const forwardedUrl = new URL(`${forwardedProtocol}://${forwardedHost}`); - return localHostnames.has(forwardedUrl.hostname) ? forwardedUrl.origin : internalUrl.origin; - } catch { - return internalUrl.origin; - } -}; - -const isAllowedOrigin = (origin: string, request: Request): boolean => { - try { - return new URL(origin).origin === requestOrigin(request); - } catch { - return false; - } -}; - -const csrfMiddleware = createCsrfMiddleware({ - filter: (ctx) => ctx.handlerType === "serverFn", - // Local Cloudflare workers replace the public loopback host in request.url. - origin: (origin, ctx) => isAllowedOrigin(origin, ctx.request), -}); - -export const startInstance = createStart(() => ({ - // Providers that maintain a server-side session (refreshing a sealed cookie, - // for example) contribute their middleware through the adapter slot. - requestMiddleware: [csrfMiddleware, ...authRequestMiddleware], -})); +export { startInstance } from "@voidhash/web-app/start"; diff --git a/apps/www/tsconfig.json b/apps/www/tsconfig.json index 6b5931e14..fab140ac2 100644 --- a/apps/www/tsconfig.json +++ b/apps/www/tsconfig.json @@ -18,13 +18,24 @@ "incremental": true, "types": ["node", "vite/client"], "paths": { - "@/*": ["./src/*"] + "@/*": ["../../packages/web-app/src/*"], + "virtual:voidhash-web/auth-browser": [ + "../../packages/web-app/src/composition/community/auth-browser.ts" + ], + "virtual:voidhash-web/auth-server": [ + "../../packages/web-app/src/composition/community/auth-server.ts" + ], + "virtual:voidhash-web/edition": [ + "../../packages/web-app/src/composition/community/edition.ts" + ] } }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], - "exclude": [ - "node_modules", - "src/features/studio/.cache/**", - "src/features/studio/.turbo/**" - ] + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.d.ts", + "../../packages/web-app/src/generated.d.ts", + "../../packages/web-app/src/composition.d.ts" + ], + "exclude": ["node_modules"] } diff --git a/apps/www/vite.config.ts b/apps/www/vite.config.ts index 2af31cf46..002c2e913 100644 --- a/apps/www/vite.config.ts +++ b/apps/www/vite.config.ts @@ -1,144 +1,28 @@ -import tailwindcss from "@tailwindcss/vite"; -import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import viteReact from "@vitejs/plugin-react"; -import { paywallRuntimeBundlePlugin } from "@voidhash/paywall-renderer-preact/vite-plugin"; -import mdx from "fumadocs-mdx/vite"; -import { defineConfig } from "vite"; -import { createRequire } from "node:module"; -import { dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { defineVoidhashWebConfig } from "@voidhash/web-app/vite"; import * as sourceConfig from "./src/features/source.config.ts"; -const devPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; -const devHost = process.env.HOST ?? true; -const appRootPath = fileURLToPath(new URL(".", import.meta.url)); -const appSrcPath = fileURLToPath(new URL("./src", import.meta.url)); -const workspacePath = fileURLToPath(new URL("../..", import.meta.url)); -const require = createRequire(import.meta.url); -const tslibPath = require.resolve("tslib/tslib.es6.mjs"); -const fontSourcePaths = ["@fontsource-variable/geist", "@fontsource-variable/geist-mono"].map( - (packageName) => dirname(require.resolve(packageName)), -); -// The self-host image uses an isolated runtime tree, so its SSR output cannot -// rely on transitive packages being hoisted beside the application. -const bundleServerDependencies = process.env.VOIDHASH_SELFHOST_BUNDLE === "true"; - -const localDevOrigins = [ - `http://localhost:${devPort}`, - `https://localhost:${devPort}`, - `http://127.0.0.1:${devPort}`, - `https://127.0.0.1:${devPort}`, -]; - -function corsMiddleware() { - return { - name: "cors-middleware", - configureServer(server: { - middlewares: { - use: (fn: (req: unknown, res: unknown, next: () => void) => void) => void; - }; - }) { - server.middlewares.use((req, res, next) => { - const request = req as { - headers: { origin?: string }; - method?: string; - }; - const response = res as { - end: () => void; - setHeader: (name: string, value: string) => void; - statusCode: number; - }; - const origin = request.headers.origin; - - if (origin && localDevOrigins.includes(origin)) { - response.setHeader("Access-Control-Allow-Origin", origin); - response.setHeader("Access-Control-Allow-Credentials", "true"); - response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); - response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); - - if (request.method === "OPTIONS") { - response.statusCode = 204; - response.end(); - return; - } - } - - next(); - }); - }, - }; -} - -function tanstackClientEntryMiddleware() { - return { - name: "tanstack-client-entry-middleware", - configureServer(server: { - middlewares: { - use: (fn: (req: unknown, res: unknown, next: () => void) => void) => void; - }; - }) { - server.middlewares.use((req, _res, next) => { - const request = req as { url?: string }; - - // The development HTML emits the raw virtual ID while Vite serves its canonical NUL form. - if (request.url?.startsWith("/@id/virtual:tanstack-start-client-entry")) { - request.url = request.url.replace("/@id/virtual:", "/@id/__x00__virtual:"); - } - - next(); - }); - }, - }; -} - -export default defineConfig(() => ({ - root: appRootPath, - build: { - minify: "esbuild", +export default defineVoidhashWebConfig({ + appRoot: new URL("./", import.meta.url), + composition: { + authBrowser: new URL( + "../../packages/web-app/src/composition/community/auth-browser.ts", + import.meta.url, + ), + authServer: new URL( + "../../packages/web-app/src/composition/community/auth-server.ts", + import.meta.url, + ), + edition: new URL( + "../../packages/web-app/src/composition/community/edition.ts", + import.meta.url, + ), + globals: new URL("../../packages/web-app/src/styles/globals.css", import.meta.url), }, - test: { - setupFiles: ["./src/test-setup.ts"], - }, - plugins: [ - ...mdx(sourceConfig, { - configPath: "src/features/source.config.ts", - }), - tanstackClientEntryMiddleware(), - corsMiddleware(), - paywallRuntimeBundlePlugin(), - tailwindcss(), - tanstackStart({ - srcDirectory: "src", - server: { - entry: "server.ts", - }, - prerender: { - enabled: false, - }, - }), - viteReact(), + routeDirectories: [ + new URL("../../packages/web-app/src/routes/shared/", import.meta.url), + new URL("../../packages/web-app/src/routes/community/", import.meta.url), ], - resolve: { - alias: { - "@": appSrcPath, - "@generated/browser": fileURLToPath(new URL("./.source/browser.ts", import.meta.url)), - "@generated/server": fileURLToPath(new URL("./.source/server.ts", import.meta.url)), - tslib: tslibPath, - }, - // TanStack's server-function compiler must transform WorkOS-owned createServerFn calls. - tsconfigPaths: true, - }, - server: { - cors: { - credentials: true, - origin: true, - }, - fs: { - allow: [workspacePath, ...fontSourcePaths], - }, - host: devHost, - port: devPort, - }, - ssr: bundleServerDependencies ? { noExternal: true } : undefined, -})); + sourceConfig, + workspaceRoot: new URL("../../", import.meta.url), +}); diff --git a/docs/architecture.md b/docs/architecture.md index 026a0934a..9df2e5445 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,13 @@ flowchart TD - `libraries/` contains MIT SDKs embedded in customer applications. - `apps/backend`, `apps/www`, and `apps/mimic-db` are the AGPL service entry - points; `packages/backend` is the backend library they compose. + points. `packages/backend` is the backend library, while + `@voidhash/web-app` is the shared web source package they compose. +- `@voidhash/web-app` owns shared web features and separate shared and + Community route sets. `apps/www` is the thin Community entrypoint that selects + those routes and supplies standalone auth and edition behavior. Another + edition can add its own pages and composition modules without mirroring or + patching Community source. - `@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. diff --git a/examples/mimic-example/.env.example b/examples/mimic-example/.env.example index 75a0e11f0..0c1c5d15a 100644 --- a/examples/mimic-example/.env.example +++ b/examples/mimic-example/.env.example @@ -1,7 +1,7 @@ # Client (Vite) -VITE_EXAMPLE_SERVER_URL=http://localhost:3001 +VITE_EXAMPLE_SERVER_URL=https://mimic-example-api.voidhash.localhost # Server (connects to the mimic-db host) -HOST_URL=http://localhost:5001 +HOST_URL=https://mimic.voidhash.localhost HOST_USERNAME=root HOST_PASSWORD=password diff --git a/examples/mimic-example/package.json b/examples/mimic-example/package.json index 45663ddcd..aa5953442 100644 --- a/examples/mimic-example/package.json +++ b/examples/mimic-example/package.json @@ -3,8 +3,8 @@ "version": "1.0.0-beta.19", "private": true, "description": "Example React application and Node server for the Mimic collaboration SDK.", - "author": "Voidhash (https://voidhash.com)", "license": "MIT", + "author": "Voidhash (https://voidhash.com)", "repository": { "type": "git", "url": "https://github.com/voidhashcom/voidhash", @@ -12,8 +12,10 @@ }, "type": "module", "scripts": { - "dev": "vp dev", - "dev:server": "tsx watch src/server/index.ts", + "dev": "portless mimic-example.voidhash --app-port 5173 pnpm run dev:app", + "dev:app": "vp dev", + "dev:server": "portless mimic-example-api.voidhash --app-port 3001 pnpm run dev:server:app", + "dev:server:app": "tsx watch src/server/index.ts", "build": "vp build", "preview": "vp preview", "start:server": "tsx src/server/index.ts", diff --git a/examples/mimic-example/src/components/kanban/Card.tsx b/examples/mimic-example/src/components/kanban/Card.tsx index 701bab244..ded1dc888 100644 --- a/examples/mimic-example/src/components/kanban/Card.tsx +++ b/examples/mimic-example/src/components/kanban/Card.tsx @@ -9,6 +9,12 @@ interface CardProps { onClick: () => void; } +/** Extra classes applied to a card while it is being dragged. */ +function dragStateClassName(isDragging: boolean): string { + if (isDragging) return "opacity-50 shadow-lg ring-2 ring-blue-500"; + return ""; +} + export function Card({ card, columnId, onClick }: CardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: card.id, @@ -24,6 +30,8 @@ export function Card({ card, columnId, onClick }: CardProps) { transition, }; + const draggingClassName = dragStateClassName(isDragging); + return (

{card.title}

diff --git a/examples/mimic-example/src/components/kanban/Column.tsx b/examples/mimic-example/src/components/kanban/Column.tsx index aa938891e..9996d7c9a 100644 --- a/examples/mimic-example/src/components/kanban/Column.tsx +++ b/examples/mimic-example/src/components/kanban/Column.tsx @@ -6,9 +6,14 @@ import { useDroppable } from "@dnd-kit/core"; import { Card } from "./Card"; import { AddCardForm } from "./AddCardForm"; import { useKanban } from "../../context/KanbanContext"; -import type { Column as ColumnType, Card as CardType } from "../../types/kanban"; +import type { Card as CardType } from "../../types/kanban"; import type { CardSnapshot, ColumnSnapshot } from "../../shared"; +const draggingClass = (isDragging: boolean): string => { + if (isDragging) return "opacity-50"; + return ""; +}; + interface ColumnProps { column: ColumnSnapshot; cards: readonly CardSnapshot[]; @@ -53,6 +58,47 @@ export function Column({ column, cards, onCardClick }: ColumnProps) { } }; + const renderTitle = () => { + if (isEditing) { + return ( + setTitle(e.target.value)} + onBlur={handleTitleSubmit} + onKeyDown={(e) => { + if (e.key === "Enter") handleTitleSubmit(); + if (e.key === "Escape") { + setTitle(columnName); + setIsEditing(false); + } + }} + autoFocus + className=" + font-semibold text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-700 + px-2 py-1 rounded border border-blue-500 outline-none w-full + " + onClick={(e) => e.stopPropagation()} + /> + ); + } + + return ( +

{ + e.stopPropagation(); + setIsEditing(true); + }} + > + {columnName} + + {cards.length} + +

+ ); + }; + return (
{/* Column Header */} @@ -69,40 +115,7 @@ export function Column({ column, cards, onCardClick }: ColumnProps) { {...listeners} className="p-3 flex items-center justify-between cursor-grab active:cursor-grabbing" > - {isEditing ? ( - setTitle(e.target.value)} - onBlur={handleTitleSubmit} - onKeyDown={(e) => { - if (e.key === "Enter") handleTitleSubmit(); - if (e.key === "Escape") { - setTitle(columnName); - setIsEditing(false); - } - }} - autoFocus - className=" - font-semibold text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-700 - px-2 py-1 rounded border border-blue-500 outline-none w-full - " - onClick={(e) => e.stopPropagation()} - /> - ) : ( -

{ - e.stopPropagation(); - setIsEditing(true); - }} - > - {columnName} - - {cards.length} - -

- )} + {renderTitle()}