diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc deleted file mode 100644 index 984955355..000000000 --- a/.cursor/rules/ultracite.mdc +++ /dev/null @@ -1,333 +0,0 @@ ---- -description: Ultracite Rules - AI-Ready Formatter and Linter -globs: "**/*.{ts,tsx,js,jsx}" -alwaysApply: true ---- - -# Project Context -Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. - -## Key Principles -- Zero configuration required -- Subsecond performance -- Maximum type safety -- AI-friendly code generation - -## Before Writing Code -1. Analyze existing patterns in the codebase -2. Consider edge cases and error scenarios -3. Follow the rules below strictly -4. Validate accessibility requirements - -## Rules - -### Accessibility (a11y) -- Don't use `accessKey` attribute on any HTML element. -- Don't set `aria-hidden="true"` on focusable elements. -- Don't add ARIA roles, states, and properties to elements that don't support them. -- Don't use distracting elements like `` or ``. -- Only use the `scope` prop on `` elements. -- Don't assign non-interactive ARIA roles to interactive HTML elements. -- Make sure label elements have text content and are associated with an input. -- Don't assign interactive ARIA roles to non-interactive HTML elements. -- Don't assign `tabIndex` to non-interactive HTML elements. -- Don't use positive integers for `tabIndex` property. -- Don't include "image", "picture", or "photo" in img alt prop. -- Don't use explicit role property that's the same as the implicit/default role. -- Make static elements with click handlers use a valid role attribute. -- Always include a `title` element for SVG elements. -- Give all elements requiring alt text meaningful information for screen readers. -- Make sure anchors have content that's accessible to screen readers. -- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. -- Include all required ARIA attributes for elements with ARIA roles. -- Make sure ARIA properties are valid for the element's supported roles. -- Always include a `type` attribute for button elements. -- Make elements with interactive roles and handlers focusable. -- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). -- Always include a `lang` attribute on the html element. -- Always include a `title` attribute for iframe elements. -- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. -- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. -- Include caption tracks for audio and video elements. -- Use semantic elements instead of role attributes in JSX. -- Make sure all anchors are valid and navigable. -- Ensure all ARIA properties (`aria-*`) are valid. -- Use valid, non-abstract ARIA roles for elements with ARIA roles. -- Use valid ARIA state and property values. -- Use valid values for the `autocomplete` attribute on input elements. -- Use correct ISO language/country codes for the `lang` attribute. - -### Code Complexity and Quality -- Don't use consecutive spaces in regular expression literals. -- Don't use the `arguments` object. -- Don't use primitive type aliases or misleading types. -- Don't use the comma operator. -- Don't use empty type parameters in type aliases and interfaces. -- Don't write functions that exceed a given Cognitive Complexity score. -- Don't nest describe() blocks too deeply in test files. -- Don't use unnecessary boolean casts. -- Don't use unnecessary callbacks with flatMap. -- Use for...of statements instead of Array.forEach. -- Don't create classes that only have static members (like a static namespace). -- Don't use this and super in static contexts. -- Don't use unnecessary catch clauses. -- Don't use unnecessary constructors. -- Don't use unnecessary continue statements. -- Don't export empty modules that don't change anything. -- Don't use unnecessary escape sequences in regular expression literals. -- Don't use unnecessary fragments. -- Don't use unnecessary labels. -- Don't use unnecessary nested block statements. -- Don't rename imports, exports, and destructured assignments to the same name. -- Don't use unnecessary string or template literal concatenation. -- Don't use String.raw in template literals when there are no escape sequences. -- Don't use useless case statements in switch statements. -- Don't use ternary operators when simpler alternatives exist. -- Don't use useless `this` aliasing. -- Don't use any or unknown as type constraints. -- Don't initialize variables to undefined. -- Don't use the void operators (they're not familiar). -- Use arrow functions instead of function expressions. -- Use Date.now() to get milliseconds since the Unix Epoch. -- Use .flatMap() instead of map().flat() when possible. -- Use literal property access instead of computed property access. -- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. -- Use concise optional chaining instead of chained logical expressions. -- Use regular expression literals instead of the RegExp constructor when possible. -- Don't use number literal object member names that aren't base 10 or use underscore separators. -- Remove redundant terms from logical expressions. -- Use while loops instead of for loops when you don't need initializer and update expressions. -- Don't pass children as props. -- Don't reassign const variables. -- Don't use constant expressions in conditions. -- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. -- Don't return a value from a constructor. -- Don't use empty character classes in regular expression literals. -- Don't use empty destructuring patterns. -- Don't call global object properties as functions. -- Don't declare functions and vars that are accessible outside their block. -- Make sure builtins are correctly instantiated. -- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. -- Don't use variables and function parameters before they're declared. -- Don't use 8 and 9 escape sequences in string literals. -- Don't use literal numbers that lose precision. - -### React and JSX Best Practices -- Don't use the return value of React.render. -- Make sure all dependencies are correctly specified in React hooks. -- Make sure all React hooks are called from the top level of component functions. -- Don't forget key props in iterators and collection literals. -- Don't destructure props inside JSX components in Solid projects. -- Don't define React components inside other components. -- Don't use event handlers on non-interactive elements. -- Don't assign to React component props. -- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. -- Don't use dangerous JSX props. -- Don't use Array index in keys. -- Don't insert comments as text nodes. -- Don't assign JSX properties multiple times. -- Don't add extra closing tags for components without children. -- Use `<>...` instead of `...`. -- Watch out for possible "wrong" semicolons inside JSX elements. - -### Correctness and Safety -- Don't assign a value to itself. -- Don't return a value from a setter. -- Don't compare expressions that modify string case with non-compliant values. -- Don't use lexical declarations in switch clauses. -- Don't use variables that haven't been declared in the document. -- Don't write unreachable code. -- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. -- Don't use control flow statements in finally blocks. -- Don't use optional chaining where undefined values aren't allowed. -- Don't have unused function parameters. -- Don't have unused imports. -- Don't have unused labels. -- Don't have unused private class members. -- Don't have unused variables. -- Make sure void (self-closing) elements don't have children. -- Don't return a value from a function with the return type 'void' -- Use isNaN() when checking for NaN. -- Make sure "for" loop update clauses move the counter in the right direction. -- Make sure typeof expressions are compared to valid values. -- Make sure generator functions contain yield. -- Don't use await inside loops. -- Don't use bitwise operators. -- Don't use expressions where the operation doesn't change the value. -- Make sure Promise-like statements are handled appropriately. -- Don't use __dirname and __filename in the global scope. -- Prevent import cycles. -- Don't use configured elements. -- Don't hardcode sensitive data like API keys and tokens. -- Don't let variable declarations shadow variables from outer scopes. -- Don't use the TypeScript directive @ts-ignore. -- Prevent duplicate polyfills from Polyfill.io. -- Don't use useless backreferences in regular expressions that always match empty strings. -- Don't use unnecessary escapes in string literals. -- Don't use useless undefined. -- Make sure getters and setters for the same property are next to each other in class and object definitions. -- Make sure object literals are declared consistently (defaults to explicit definitions). -- Use static Response methods instead of new Response() constructor when possible. -- Make sure switch-case statements are exhaustive. -- Make sure the `preconnect` attribute is used when using Google Fonts. -- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. -- Make sure iterable callbacks return consistent values. -- Use `with { type: "json" }` for JSON module imports. -- Use numeric separators in numeric literals. -- Use object spread instead of `Object.assign()` when constructing new objects. -- Always use the radix argument when using `parseInt()`. -- Make sure JSDoc comment lines start with a single asterisk, except for the first one. -- Include a description parameter for `Symbol()`. -- Don't use spread (`...`) syntax on accumulators. -- Don't use the `delete` operator. -- Don't access namespace imports dynamically. -- Don't use namespace imports. -- Declare regex literals at the top level. -- Don't use `target="_blank"` without `rel="noopener"`. - -### TypeScript Best Practices -- Don't use TypeScript enums. -- Don't export imported variables. -- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. -- Don't use TypeScript namespaces. -- Don't use non-null assertions with the `!` postfix operator. -- Don't use parameter properties in class constructors. -- Don't use user-defined types. -- Use `as const` instead of literal types and type annotations. -- Use either `T[]` or `Array` consistently. -- Initialize each enum member value explicitly. -- Use `export type` for types. -- Use `import type` for types. -- Make sure all enum members are literal values. -- Don't use TypeScript const enum. -- Don't declare empty interfaces. -- Don't let variables evolve into any type through reassignments. -- Don't use the any type. -- Don't misuse the non-null assertion operator (!) in TypeScript files. -- Don't use implicit any type on variable declarations. -- Don't merge interfaces and classes unsafely. -- Don't use overload signatures that aren't next to each other. -- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. - -### Style and Consistency -- Don't use global `eval()`. -- Don't use callbacks in asynchronous tests and hooks. -- Don't use negation in `if` statements that have `else` clauses. -- Don't use nested ternary expressions. -- Don't reassign function parameters. -- This rule lets you specify global variable names you don't want to use in your application. -- Don't use specified modules when loaded by import or require. -- Don't use constants whose value is the upper-case version of their name. -- Use `String.slice()` instead of `String.substr()` and `String.substring()`. -- Don't use template literals if you don't need interpolation or special-character handling. -- Don't use `else` blocks when the `if` block breaks early. -- Don't use yoda expressions. -- Don't use Array constructors. -- Use `at()` instead of integer index access. -- Follow curly brace conventions. -- Use `else if` instead of nested `if` statements in `else` clauses. -- Use single `if` statements instead of nested `if` clauses. -- Use `new` for all builtins except `String`, `Number`, and `Boolean`. -- Use consistent accessibility modifiers on class properties and methods. -- Use `const` declarations for variables that are only assigned once. -- Put default function parameters and optional function parameters last. -- Include a `default` clause in switch statements. -- Use the `**` operator instead of `Math.pow`. -- Use `for-of` loops when you need the index to extract an item from the iterated array. -- Use `node:assert/strict` over `node:assert`. -- Use the `node:` protocol for Node.js builtin modules. -- Use Number properties instead of global ones. -- Use assignment operator shorthand where possible. -- Use function types instead of object types with call signatures. -- Use template literals over string concatenation. -- Use `new` when throwing an error. -- Don't throw non-Error values. -- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. -- Use standard constants instead of approximated literals. -- Don't assign values in expressions. -- Don't use async functions as Promise executors. -- Don't reassign exceptions in catch clauses. -- Don't reassign class members. -- Don't compare against -0. -- Don't use labeled statements that aren't loops. -- Don't use void type outside of generic or return types. -- Don't use console. -- Don't use control characters and escape sequences that match control characters in regular expression literals. -- Don't use debugger. -- Don't assign directly to document.cookie. -- Use `===` and `!==`. -- Don't use duplicate case labels. -- Don't use duplicate class members. -- Don't use duplicate conditions in if-else-if chains. -- Don't use two keys with the same name inside objects. -- Don't use duplicate function parameter names. -- Don't have duplicate hooks in describe blocks. -- Don't use empty block statements and static blocks. -- Don't let switch clauses fall through. -- Don't reassign function declarations. -- Don't allow assignments to native objects and read-only global variables. -- Use Number.isFinite instead of global isFinite. -- Use Number.isNaN instead of global isNaN. -- Don't assign to imported bindings. -- Don't use irregular whitespace characters. -- Don't use labels that share a name with a variable. -- Don't use characters made with multiple code points in character class syntax. -- Make sure to use new and constructor properly. -- Don't use shorthand assign when the variable appears on both sides. -- Don't use octal escape sequences in string literals. -- Don't use Object.prototype builtins directly. -- Don't redeclare variables, functions, classes, and types in the same scope. -- Don't have redundant "use strict". -- Don't compare things where both sides are exactly the same. -- Don't let identifiers shadow restricted names. -- Don't use sparse arrays (arrays with holes). -- Don't use template literal placeholder syntax in regular strings. -- Don't use the then property. -- Don't use unsafe negation. -- Don't use var. -- Don't use with statements in non-strict contexts. -- Make sure async functions actually use await. -- Make sure default clauses in switch statements come last. -- Make sure to pass a message value when creating a built-in error. -- Make sure get methods always return a value. -- Use a recommended display strategy with Google Fonts. -- Make sure for-in loops include an if statement. -- Use Array.isArray() instead of instanceof Array. -- Make sure to use the digits argument with Number#toFixed(). -- Make sure to use the "use strict" directive in script files. - -### Next.js Specific Rules -- Don't use `` elements in Next.js projects. -- Don't use `` elements in Next.js projects. -- Don't import next/document outside of pages/_document.jsx in Next.js projects. -- Don't use the next/head module in pages/_document.js on Next.js projects. - -### Testing Best Practices -- Don't use export or module.exports in test files. -- Don't use focused tests. -- Make sure the assertion function, like expect, is placed inside an it() function call. -- Don't use disabled tests. - -## Common Tasks -- `npx ultracite init` - Initialize Ultracite in your project -- `npx ultracite format` - Format and fix code automatically -- `npx ultracite lint` - Check for issues without fixing - -## Example: Error Handling -```typescript -// ✅ Good: Comprehensive error handling -try { - const result = await fetchData(); - return { success: true, data: result }; -} catch (error) { - console.error('API call failed:', error); - return { success: false, error: error.message }; -} - -// ❌ Bad: Swallowing errors -try { - return await fetchData(); -} catch (e) { - console.log(e); -} -``` \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index c0962f05e..b7f89a072 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,8 @@ **/.env **/.env.* *.log +**/ios/Pods +**/ios/build +**/android/build +**/android/.gradle +**/.expo diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..68f785e35 --- /dev/null +++ b/.env.example @@ -0,0 +1,126 @@ +# `production` for real deployments; `local-evaluation` relaxes credential +# validation for local development and is what the integration suites expect. +# Evaluation mode accepts the documented default root credentials; production +# refuses them. `pnpm stack:up` / `pnpm test:integration` force evaluation mode +# for the stack they manage, so this value is the one a real deployment gets. +SELFHOST_MODE=production +DATABASE_USERNAME=voidhash +DATABASE_PASSWORD=replace-with-a-random-password +DATABASE_NAME=voidhash +DATABASE_SSL=false +# Direct-TCP overrides for the migration process (`pnpm migrate`) and the local +# migration CLI (`pnpm db:migrate`). Each one falls back to its DATABASE_* +# counterpart, so leave them unset unless DATABASE_HOST points at a sandboxed or +# proxied endpoint — a connection broker, or a +# Hyperdrive-style local socket — that only resolves inside the runtime serving +# requests. Migrations run in their own process and need the origin address. +# DATABASE_DIRECT_HOST=postgres +# DATABASE_DIRECT_PORT=5432 +# DATABASE_DIRECT_NAME=voidhash +# DATABASE_DIRECT_USERNAME=voidhash +# DATABASE_DIRECT_PASSWORD=replace-with-a-random-password +# DATABASE_DIRECT_SSL=false +# Overrides for platform state — cluster mailboxes, workflow executions, +# persisted queues, entity alarms, and the platform key-value store. Each falls +# back to its DATABASE_* counterpart, so leaving them unset keeps that state +# beside application data, which is what a deployment wants. They exist because a +# single-node cluster claims every shard in its database: a process that must not +# contend with the deployment for shards needs a database of its own, which is +# how `pnpm test:integration` isolates the suites that build their own cluster. +# DATABASE_PLATFORM_HOST=postgres +# DATABASE_PLATFORM_PORT=5432 +# DATABASE_PLATFORM_NAME=voidhash +# DATABASE_PLATFORM_USERNAME=voidhash +# DATABASE_PLATFORM_PASSWORD=replace-with-a-random-password +# DATABASE_PLATFORM_SSL=false +# Optional analytics profile. Leave CLICKHOUSE_URL unset for the core stack. +# CLICKHOUSE_URL=http://clickhouse:8123 +CLICKHOUSE_DATABASE=voidhash +CLICKHOUSE_ADMIN_USERNAME=voidhash_admin +CLICKHOUSE_ADMIN_PASSWORD=replace-with-a-random-password +CLICKHOUSE_USERNAME=voidhash_app +CLICKHOUSE_PASSWORD=replace-with-a-random-password +CLICKHOUSE_RO_USERNAME=voidhash_ro +CLICKHOUSE_RO_PASSWORD=replace-with-a-random-password +CLICKHOUSE_ANALYTICS_QUERY_USERNAME=voidhash_query +CLICKHOUSE_ANALYTICS_QUERY_PASSWORD=replace-with-a-random-password +CLICKHOUSE_HTTP_PORT=8123 +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_DOCUMENT_IDLE_NOTIFY_DEBOUNCE_MS=15000 +MIMIC_PORT=5001 +# The single root account. Voidhash self-host is single-player: these are the +# only credentials that can sign in, and there is no sign-up. Required in +# production mode; `local-evaluation` falls back to root / voidhash. +VOIDHASH_ROOT_USERNAME=root +VOIDHASH_ROOT_PASSWORD=replace-with-a-random-password +# Optional; defaults to root@voidhash.local. Used as the root user's address. +# VOIDHASH_ROOT_EMAIL= +# Signs the dashboard and API session tokens. Required in production mode. +VOIDHASH_AUTH_SECRET=replace-with-at-least-32-random-characters +# Durable agent model access. Configure at least one provider. +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +# OPENAI_BASE_URL=https://your-openai-compatible-host/v1 +# VOIDHASH_AGENT_MODEL_PROVIDER=openai +# VOIDHASH_AGENT_MODEL_ID=gpt-5.4 +# VOIDHASH_AGENT_VISION_MODEL_PROVIDER=openai +# VOIDHASH_AGENT_VISION_MODEL_ID=gpt-5.4 +# Required when Google Play RTDN is enabled. These must match the Pub/Sub push subscription. +GOOGLE_PUBSUB_PUSH_AUDIENCE= +GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT_EMAIL= +# Optional offline Enterprise activation; configure the token and issuer verification key together. +VOIDHASH_LICENSE_KEY= +VOIDHASH_LICENSE_PUBLIC_KEY= +ENCRYPTION_KEY= +APNS_DELIVERY_ENABLED=false +EXCHANGE_RATE_API_KEY= +S3_ACCESS_KEY_ID=voidhash +S3_SECRET_ACCESS_KEY=replace-with-a-random-password +S3_REGION=us-east-1 +S3_PUBLIC_BUCKET=voidhash-public +S3_ARTIFACT_BUCKET=voidhash-artifacts +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 +SMTP_HOST=mailpit +SMTP_PORT=1025 +SMTP_SECURE=false +SMTP_REQUIRE_TLS=false +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_ADDRESS=noreply@voidhash.local +SMTP_FROM_NAME=Voidhash +SMTP_TLS_REJECT_UNAUTHORIZED=true +SMTP_VERIFY_ON_START=true +MAILPIT_SMTP_PORT=1025 +MAILPIT_UI_PORT=8025 + +# ── Local development & integration tests ──────────────────────────────────── +# Used together with docker-compose.dev.yml: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml \ +# --profile analytics up -d --build +# `pnpm test:integration` (repo root) reads this file and derives host-side +# connection settings from the values below, so the whole suite runs against +# this stack with no additional configuration. + +# Host ports published by the dev overlay. Change them only when another local +# service already owns the default. +DATABASE_HOST_PORT=5432 +COMPILER_HOST_PORT=5002 + +# To enable the analytics profile end-to-end (the compose service, migrations, +# and the ClickHouse integration suite), uncomment CLICKHOUSE_URL above. + +# Browser used by the screenshot integration tests on the host. The container +# ships its own chromium; this is only for host-side test runs. +# PLATFORM_SELFHOST_CHROMIUM_EXECUTABLE_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome + +# ── Values you must provide ────────────────────────────────────────────────── +# VOIDHASH_ROOT_PASSWORD / VOIDHASH_AUTH_SECRET — sign-in. Production mode +# refuses to start until both hold real values. +# OPENAI_API_KEY / ANTHROPIC_API_KEY — required only for the AI designer agent. +# EXCHANGE_RATE_API_KEY — required only for the FX rate sync job. +# ENCRYPTION_KEY — required for payment-provider credential storage. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad2e7a70e..6215afed0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,11 @@ concurrency: group: repository-ci-${{ github.head_ref || github.ref }} cancel-in-progress: true +env: + # Keeps turbo within the runner's 4 vCPUs; the scripts stay flag-free so the + # local and CI invocations are the same command. + TURBO_CONCURRENCY: 2 + jobs: validate: name: Validate @@ -33,6 +38,9 @@ jobs: - 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 @@ -62,10 +70,9 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.draft == false run: pnpm build --concurrency=2 - - name: Typecheck - if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - run: pnpm typecheck --concurrency=2 - - - name: Test + # 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) if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - run: pnpm test --concurrency=2 + run: pnpm verify:quick diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml index 6cb17f201..077c8f6aa 100644 --- a/.github/workflows/selfhost.yml +++ b/.github/workflows/selfhost.yml @@ -40,39 +40,69 @@ jobs: - name: Install workspace dependencies run: pnpm install --frozen-lockfile + - name: Install Chromium + run: pnpm exec playwright-core install --with-deps chromium + + # The stack and the host-side tiers authenticate against each other, so + # they have to read one environment: the repo-root `.env` a developer + # keeps (and `pnpm test:integration` creates on a first checkout). It must + # exist *before* the stack starts — Compose otherwise falls back to the + # built-in defaults in the compose file while the tiers read the file, and + # every host-side connection fails authentication. Compose consumes it via + # `--env-file`; the tiers that read `process.env` rather than the file (the + # two smoke tiers) get the same values from the job environment. + - name: Prepare the stack environment + run: | + cp .env.example .env + # `.env.example` is a deployment template, so it selects production + # mode, which refuses its own placeholder secrets. This is a loopback + # CI stack: `pnpm stack:up` forces the same mode locally. + sed -i 's|^SELFHOST_MODE=.*|SELFHOST_MODE=local-evaluation|' .env + # The thumbnail assertions wait on the idle debounce. + sed -i 's|^MIMIC_DOCUMENT_IDLE_NOTIFY_DEBOUNCE_MS=.*|MIMIC_DOCUMENT_IDLE_NOTIFY_DEBOUNCE_MS=250|' .env + grep -E '^[A-Z][A-Z0-9_]*=' .env >> "$GITHUB_ENV" + - name: Start stateful stores - run: docker compose -f selfhost/docker-compose.yml --profile analytics up -d clickhouse minio --wait --wait-timeout 180 + run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env --profile analytics up -d clickhouse minio --wait --wait-timeout 180 - name: Initialize object store - run: docker compose -f selfhost/docker-compose.yml --profile analytics run --rm minio-init - - - name: Verify S3-compatible object store - env: - PLATFORM_NODE_S3_ENDPOINT: http://127.0.0.1:9000 - PLATFORM_NODE_S3_TEST: 1 - run: pnpm --filter @voidhash/platform-node exec vp test run -c vitest.mts tests/S3ObjectStore.integration.test.ts + run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env --profile analytics run --rm minio-init + # The dev overlay publishes Postgres and the compiler, which the + # host-side integration tier connects to. `CLICKHOUSE_URL` stays a shell + # override rather than an entry in `.env`: it names the compose-internal + # endpoint the application dials, while every host-side tier reaches + # ClickHouse on the published port instead. - name: Build and start Community Compose env: CLICKHOUSE_URL: http://clickhouse:8123 - MIMIC_DOCUMENT_IDLE_NOTIFY_DEBOUNCE_MS: 250 - run: docker compose -f selfhost/docker-compose.yml --profile analytics up --build --wait --wait-timeout 180 + run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env --profile analytics up --build --wait --wait-timeout 180 - name: Reclaim image build cache run: docker builder prune --all --force - - name: Run self-host smoke - run: pnpm exec tsx selfhost/smoke.mts + # Same scripts developers run locally; this workflow owns the stack the + # two tiers need. With the unit tier in Repository CI, this completes the + # coverage of `pnpm verify`. + - name: Integration tier + run: pnpm test:integration + + - name: End-to-end tier + run: pnpm test:e2e - - name: Run self-host release smoke - run: pnpm exec tsx selfhost/release-smoke.mts + - name: End-to-end tier (release) + run: pnpm test:e2e:release + # These two run even when the environment step never did, so they stay off + # `--env-file` (a missing file is a hard Compose error). They need no + # interpolated value: both address the project by name, and the job + # environment already carries what `.env` set. - name: Show Compose diagnostics if: always() run: | - docker compose -f selfhost/docker-compose.yml --profile analytics ps || true - docker compose -f selfhost/docker-compose.yml --profile analytics logs --no-color || true + docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --profile analytics ps || true + docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --profile analytics logs --no-color || true - name: Stop Compose if: always() - run: docker compose -f selfhost/docker-compose.yml --profile analytics down --volumes --remove-orphans + run: docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --profile analytics down --volumes --remove-orphans diff --git a/.nvmrc b/.nvmrc index 2fdffdecf..c94711948 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.10.0 \ No newline at end of file +22.23.2 diff --git a/.vscode/settings.json b/.vscode/settings.json index aebef2e0b..3d3043c7e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,16 @@ { "editor.defaultFormatter": "esbenp.prettier-vscode", - "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": { - "editor.defaultFormatter": "biomejs.biome" - }, + "[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "npm.scriptRunner": "vp", "typescript.tsdk": "node_modules/typescript/lib", "editor.formatOnSave": true, "editor.formatOnPaste": true, "emmet.showExpandedAbbreviation": "never", "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" + "source.fixAll.oxc": "explicit" }, "files.exclude": {} } diff --git a/.zed/settings.json b/.zed/settings.json index c8789f748..b88c7bbb4 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -5,49 +5,66 @@ "JavaScript": { "formatter": { "language_server": { - "name": "biome" + "name": "oxfmt" } }, "code_actions_on_format": { - "source.fixAll.biome": true, - "source.organizeImports.biome": true + "source.fixAll.oxc": true, + "source.organizeImports.oxc": true } }, "TypeScript": { "formatter": { "language_server": { - "name": "biome" + "name": "oxfmt" } }, "code_actions_on_format": { - "source.fixAll.biome": true, - "source.organizeImports.biome": true + "source.fixAll.oxc": true, + "source.organizeImports.oxc": true } }, "JSX": { "formatter": { "language_server": { - "name": "biome" + "name": "oxfmt" } }, "code_actions_on_format": { - "source.fixAll.biome": true, - "source.organizeImports.biome": true + "source.fixAll.oxc": true, + "source.organizeImports.oxc": true } }, "TSX": { "formatter": { "language_server": { - "name": "biome" + "name": "oxfmt" } }, "code_actions_on_format": { - "source.fixAll.biome": true, - "source.organizeImports.biome": true + "source.fixAll.oxc": true, + "source.organizeImports.oxc": true } } }, "lsp": { + "oxlint": { + "initialization_options": { + "settings": { + "run": "onType", + "fixKind": "safe_fix", + "typeAware": true, + "unusedDisableDirectives": "deny" + } + } + }, + "oxfmt": { + "initialization_options": { + "settings": { + "run": "onSave" + } + } + }, "typescript-language-server": { "settings": { "typescript": { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87569c154..603b98e49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,17 @@ Use `pnpm check:publication` to validate license metadata and the public/private repository boundary. The [self-hosting guide](selfhost/README.md) documents the local Compose environment and its smoke tests. +Linting and formatting go through vite-plus: `pnpm lint` (`vp check`) and +`pnpm format` (`vp check --fix`). + +The steps above describe a **standalone clone** of this repository, which installs +its own `node_modules` from this repository's lockfile. This repository is also +consumed as a nested workspace by Voidhash's private monorepo. In that mode the +superproject's root install is authoritative: it already covers every package here, +this directory must **not** have its own `node_modules` (two installs give +`drizzle-orm`/`@types/react` duplicate TypeScript type identities), and all commands +are run from the superproject root rather than from here. + ## Testing Run the smallest relevant package tests while iterating, then run the repository diff --git a/README.md b/README.md index a6d7b17df..f648f7c98 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ run the Community platform locally, see the [self-hosting guide](selfhost/README The [architecture overview](docs/architecture.md) explains the Community, Cloud, and Enterprise composition boundaries, and the [licensing and self-hosting FAQ](docs/licensing-and-self-hosting-faq.md) covers -AGPL and the current BYO WorkOS requirement. +AGPL and the self-hosting model. ## 🤝 Contributing diff --git a/selfhost/entry/Dockerfile b/apps/backend/Dockerfile similarity index 79% rename from selfhost/entry/Dockerfile rename to apps/backend/Dockerfile index 017edcb11..6deaceca5 100644 --- a/selfhost/entry/Dockerfile +++ b/apps/backend/Dockerfile @@ -9,11 +9,11 @@ RUN apt-get update \ WORKDIR /repo COPY . . -RUN corepack pnpm@11.1.3 install --frozen-lockfile --filter @voidhash/selfhost-entry... --filter @voidhash/www... --ignore-scripts --config.node-linker=isolated +RUN corepack pnpm@11.1.3 install --frozen-lockfile --filter @voidhash/backend-app... --filter @voidhash/www... --ignore-scripts --config.node-linker=isolated RUN VITE_APP_API_URL= VITE_APP_ENV=production VOIDHASH_SELFHOST_BUNDLE=true corepack pnpm@11.1.3 exec turbo build --filter @voidhash/www -RUN rm -rf /out && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=isolated --config.block-exotic-subdeps=false --filter @voidhash/selfhost-entry deploy --prod --legacy /out +RUN rm -rf /out && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=isolated --filter @voidhash/backend-app deploy --prod --legacy /out RUN node scripts/check-selfhost-runtime-boundary.mjs /out -RUN rm -rf /www && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=hoisted --config.block-exotic-subdeps=false --config.allow-unused-patches=true --filter @voidhash/www deploy --prod --legacy /www +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 diff --git a/apps/backend/package.json b/apps/backend/package.json index 39041a275..40ed75aba 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,49 +1,60 @@ { - "name": "@voidhash/backend", - "version": "0.1.0", + "name": "@voidhash/backend-app", + "version": "0.0.1-alpha.1", "private": true, "license": "AGPL-3.0-only", "type": "module", + "exports": { + ".": "./src/index.ts", + "./Backend": "./src/backend/Backend.ts", + "./CompilerClient": "./src/compiler/CompilerClient.ts", + "./compiler/main": "./src/compiler/main.ts", + "./migrations": "./src/migrations.ts", + "./MimicNode": "./src/mimic/MimicNode.ts", + "./PgControlStore": "./src/mimic/PgControlStore.ts" + }, "scripts": { - "test": "vp test run -c vitest.unit.mts", + "migrate": "tsx src/migrate.ts", + "start": "tsx src/main.ts", + "start:compiler": "tsx src/compiler/main.ts", + "start:mimic": "tsx src/mimic/main.ts", "typecheck": "tsc --noEmit", - "typecheck-go": "tsgo --noEmit", - "lint": "vp lint .", - "format": "vp fmt ." + "test": "vp test run -c vitest.mts" }, "dependencies": { + "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", "@voidhash/agent": "workspace:*", - "@voidhash/ai-shared": "workspace:*", "@voidhash/api-contracts": "workspace:*", "@voidhash/app-store-server-sdk": "workspace:*", + "@voidhash/backend": "workspace:*", "@voidhash/clickhouse-db": "workspace:*", "@voidhash/core": "workspace:*", "@voidhash/db": "workspace:*", - "@voidhash/google-play-server-sdk": "workspace:*", + "@voidhash/mimic-core": "workspace:*", + "@voidhash/mimic-db": "workspace:*", "@voidhash/mimic-schema": "workspace:*", - "@voidhash/paywall-builtins": "workspace:*", "@voidhash/paywall-renderer-preact": "workspace:*", - "@voidhash/paywall-workspace": "workspace:*", - "@voidhash/rpc": "workspace:*", - "@workos-inc/node": "9.2.0", + "@voidhash/paywall-renderer-web-core": "workspace:*", + "@voidhash/paywalls": "workspace:*", + "@voidhash/platform": "workspace:*", + "@voidhash/platform-selfhost": "workspace:*", "effect": "catalog:", + "esbuild": "^0.25.10", "jose": "catalog:", - "just-bash": "3.1.0", "preact": "^10.25.4", - "zod": "catalog:" + "react": "catalog:", + "react-dom": "catalog:", + "srvx": "0.11.15", + "tsx": "^4.19.3", + "ws": "^8.20.0" }, "devDependencies": { - "@effect/platform-bun": "catalog:", - "@effect/platform-node": "catalog:", - "@types/bun": "latest", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "@orbian/sdk": "https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", + "@types/node": "^24.0.12", + "@types/ws": "^8.18.1", + "@voidhash/mimic-core": "workspace:*", "@voidhash/tsconfig": "workspace:*", - "alchemy": "catalog:", "typescript": "catalog:", - "vite-plus": "catalog:", - "vitest": "catalog:" + "vite-plus": "catalog:" } } diff --git a/selfhost/entry/src/DurableEntityAlarms.ts b/apps/backend/src/DurableEntityAlarms.ts similarity index 82% rename from selfhost/entry/src/DurableEntityAlarms.ts rename to apps/backend/src/DurableEntityAlarms.ts index 5859f238f..55160eb9d 100644 --- a/selfhost/entry/src/DurableEntityAlarms.ts +++ b/apps/backend/src/DurableEntityAlarms.ts @@ -1,5 +1,7 @@ -import type { DurableEntityAddress } from "@orbian/sdk/DurableEntity"; -import type { NodeDurableEntityControlShape } from "@orbian/node/DurableEntity"; +import type { + DurableEntityAddress, + DurableEntityAlarmControlShape, +} from "@voidhash/platform/DurableEntity"; import { Effect } from "effect"; /** Handler for one durable-entity alarm type. */ @@ -13,7 +15,7 @@ export type DurableEntityAlarmHandler = ( * When `now` is omitted, the clock is sampled each time the returned effect runs. */ export const dispatchDurableEntityAlarms = ( - control: NodeDurableEntityControlShape, + control: DurableEntityAlarmControlShape, handlers: Readonly>, now?: number, ): Effect.Effect => diff --git a/apps/backend/src/McpAuthKit.test.ts b/apps/backend/src/McpAuthKit.test.ts deleted file mode 100644 index a142d1992..000000000 --- a/apps/backend/src/McpAuthKit.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Effect } from "effect"; -import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair, type JWK } from "jose"; -import { describe, expect, it } from "vite-plus/test"; - -import { makeMcpAuthKit, normalizeAuthKitDomain } from "./McpAuthKit.ts"; - -const ISSUER = "https://example.authkit.app"; -const AUDIENCE = "https://api.example.com/api/mcp"; - -const authKitFixture = async () => { - const { privateKey, publicKey } = await generateKeyPair("RS256"); - const publicJwk: JWK = { ...(await exportJWK(publicKey)), alg: "RS256", kid: "test" }; - const authKit = makeMcpAuthKit(ISSUER, createLocalJWKSet({ keys: [publicJwk] })); - const sign = (claims: Record, audience = AUDIENCE) => - new SignJWT(claims) - .setProtectedHeader({ alg: "RS256", kid: "test" }) - .setIssuer(ISSUER) - .setAudience(audience) - .setSubject("user_123") - .setIssuedAt() - .setExpirationTime("5m") - .sign(privateKey); - return { authKit, sign }; -}; - -describe("normalizeAuthKitDomain", () => { - it("accepts a bare HTTPS issuer and removes its trailing slash", () => { - expect(normalizeAuthKitDomain(" https://example.authkit.app/ ")).toBe(ISSUER); - }); - - it("rejects insecure or path-bearing issuer values", () => { - expect(normalizeAuthKitDomain("http://example.authkit.app")).toBeUndefined(); - expect(normalizeAuthKitDomain("https://example.authkit.app/oauth2")).toBeUndefined(); - }); -}); - -describe("McpAuthKit.verifyAccessToken", () => { - it("verifies issuer and resource audience and returns the WorkOS identity", async () => { - const { authKit, sign } = await authKitFixture(); - const token = await sign({ org_id: "org_123" }); - - await expect(Effect.runPromise(authKit.verifyAccessToken(token, AUDIENCE))).resolves.toEqual({ - organizationId: "org_123", - subject: "user_123", - }); - }); - - it("rejects a token issued for another MCP resource", async () => { - const { authKit, sign } = await authKitFixture(); - const token = await sign({ org_id: "org_123" }, "https://other.example.com/api/mcp"); - - await expect( - Effect.runPromise(authKit.verifyAccessToken(token, AUDIENCE)), - ).rejects.toMatchObject({ kind: "invalid_token" }); - }); - - it("requires the organization selected during AuthKit consent", async () => { - const { authKit, sign } = await authKitFixture(); - const token = await sign({}); - - await expect( - Effect.runPromise(authKit.verifyAccessToken(token, AUDIENCE)), - ).rejects.toMatchObject({ kind: "invalid_token" }); - }); -}); diff --git a/apps/backend/src/McpAuthKit.ts b/apps/backend/src/McpAuthKit.ts deleted file mode 100644 index 551669a3b..000000000 --- a/apps/backend/src/McpAuthKit.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Context, Data, Effect, Layer } from "effect"; -import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; - -export class McpAuthKitError extends Data.TaggedError("McpAuthKitError")<{ - readonly kind: "invalid_token" | "misconfigured" | "upstream"; - readonly message: string; - readonly cause?: unknown; -}> {} - -export interface McpAuthKitClaims { - readonly organizationId: string; - readonly subject: string; -} - -/** Normalizes the HTTPS AuthKit issuer configured for MCP OAuth discovery. */ -export const normalizeAuthKitDomain = (value: string | undefined): string | undefined => { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - try { - const url = new URL(trimmed); - if ( - url.protocol !== "https:" || - url.username || - url.password || - url.search || - url.hash || - (url.pathname !== "/" && url.pathname !== "") - ) { - return undefined; - } - return url.origin; - } catch { - return undefined; - } -}; - -/** Builds AuthKit MCP verification against the supplied issuer and JWKS resolver. */ -export const makeMcpAuthKit = ( - authorizationServer: string | undefined, - jwks: JWTVerifyGetKey | undefined, -) => { - const requireConfiguration = () => - authorizationServer && jwks - ? Effect.succeed({ authorizationServer, jwks }) - : Effect.fail( - new McpAuthKitError({ - kind: "misconfigured", - message: "WORKOS_AUTHKIT_DOMAIN must be set to the HTTPS AuthKit issuer.", - }), - ); - - const verifyAccessToken = (token: string, audience: string) => - Effect.gen(function* () { - const configured = yield* requireConfiguration(); - const verified = yield* Effect.tryPromise({ - try: () => - jwtVerify(token, configured.jwks, { - audience, - issuer: configured.authorizationServer, - }), - catch: (cause) => - new McpAuthKitError({ - cause, - kind: "invalid_token", - message: - "The AuthKit access token is invalid, expired, or intended for another resource.", - }), - }); - const subject = verified.payload.sub; - const organizationId = verified.payload.org_id; - if (typeof subject !== "string" || typeof organizationId !== "string") { - return yield* Effect.fail( - new McpAuthKitError({ - kind: "invalid_token", - message: "The AuthKit access token is missing its subject or organization claim.", - }), - ); - } - return { organizationId, subject } satisfies McpAuthKitClaims; - }); - - const fetchAuthorizationServerMetadata = () => - Effect.gen(function* () { - const configured = yield* requireConfiguration(); - return yield* Effect.tryPromise({ - try: async () => { - const response = await fetch( - `${configured.authorizationServer}/.well-known/oauth-authorization-server`, - ); - if (!response.ok) throw new Error(`AuthKit metadata returned HTTP ${response.status}`); - return (await response.json()) as unknown; - }, - catch: (cause) => - new McpAuthKitError({ - cause, - kind: "upstream", - message: "Failed to load AuthKit authorization-server metadata.", - }), - }); - }); - - return { - authorizationServer, - fetchAuthorizationServerMetadata, - verifyAccessToken, - } as const; -}; - -/** AuthKit MCP issuer discovery and resource-audience JWT verification. */ -export class McpAuthKit extends Context.Service()("backend/McpAuthKit", { - make: Effect.sync(() => { - const authorizationServer = normalizeAuthKitDomain(process.env.WORKOS_AUTHKIT_DOMAIN); - const jwks = authorizationServer - ? createRemoteJWKSet(new URL(`${authorizationServer}/oauth2/jwks`), { - cacheMaxAge: 5 * 60_000, - cooldownDuration: 30_000, - }) - : undefined; - return makeMcpAuthKit(authorizationServer, jwks); - }), -}) { - static layer = Layer.effect(McpAuthKit)(McpAuthKit.make); -} diff --git a/selfhost/entry/src/agent/AgentNodeWebSocket.ts b/apps/backend/src/agent/AgentNodeWebSocket.ts similarity index 93% rename from selfhost/entry/src/agent/AgentNodeWebSocket.ts rename to apps/backend/src/agent/AgentNodeWebSocket.ts index 9dc9e00eb..a2e8a01d4 100644 --- a/selfhost/entry/src/agent/AgentNodeWebSocket.ts +++ b/apps/backend/src/agent/AgentNodeWebSocket.ts @@ -9,18 +9,22 @@ import { type EffectRunner, type Model, } from "@voidhash/agent"; -import { makeAgentSessionIndex } from "@voidhash/backend/src/ai/AgentSessionIndexAdapter.ts"; +import { makeAgentSessionIndex } from "@voidhash/backend/ai/AgentSessionIndexAdapter"; import { makeWorkspaceAgentSessionFactory, type WorkspaceAgentDeps, -} from "@voidhash/backend/src/ai/WorkspaceAgentSessionFactory.ts"; -import { resolveWorkosSession } from "@voidhash/backend/src/AuthSessionResolver.ts"; +} from "@voidhash/backend/ai/WorkspaceAgentSessionFactory"; +import { resolveUserSession } from "@voidhash/backend/AuthSessionResolver"; import { AuthSession } from "@voidhash/core/domain/auth/Auth"; -import { AgentSessionIndexService, LocalUserSessionService, Workos } from "@voidhash/core/services"; +import { + AgentSessionIndexService, + IdentityProvider, + LocalUserSessionService, +} from "@voidhash/core/services"; import type { AuthTokenVerifier } from "@voidhash/core/services/auth/AuthTokenVerifier"; import { Db } from "@voidhash/db"; -import type { DurableEntityHostShape } from "@orbian/sdk/DurableEntity"; -import { makeNodeDurableEntitySession } from "@orbian/node/NodeDurableEntitySession"; +import type { DurableEntityHostShape } from "@voidhash/platform/DurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/NodeDurableEntitySession"; import { Context, Effect, Redacted } from "effect"; import * as HttpHeaders from "effect/unstable/http/Headers"; import { WebSocketServer, type RawData } from "ws"; @@ -32,11 +36,11 @@ type AgentNodeServices = | Exclude | AgentSessionIndexService | Db - | Workos + | IdentityProvider | LocalUserSessionService; interface AgentConnectionData { - readonly authSession: Effect.Success>; + readonly authSession: Effect.Success>; } interface AgentRoute { @@ -212,7 +216,7 @@ export const installAgentNodeWebSocketServer = ( } const route = parsed.route; void Effect.runPromise( - resolveWorkosSession(headersOf(request), authTokenVerifier).pipe(Effect.provide(services)), + resolveUserSession(headersOf(request), authTokenVerifier).pipe(Effect.provide(services)), ) .then((session) => { if (session.method !== "user" || session.user === null) { diff --git a/selfhost/entry/src/backend/Analytics.ts b/apps/backend/src/backend/Analytics.ts similarity index 91% rename from selfhost/entry/src/backend/Analytics.ts rename to apps/backend/src/backend/Analytics.ts index afa108347..a12056356 100644 --- a/selfhost/entry/src/backend/Analytics.ts +++ b/apps/backend/src/backend/Analytics.ts @@ -22,15 +22,13 @@ import { import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; import { PersonIdentityService } from "@voidhash/core/services/personIdentity/PersonIdentityService"; import { Db } from "@voidhash/db"; -import { KeyValueStore } from "@orbian/sdk/KeyValueStore"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { QueueDriver } from "@orbian/sdk/Queue"; -import { PgKeyValueStoreLive } from "@orbian/node/KeyValueStore"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; -import { PgQueueLive } from "@orbian/node/Queue"; -import { Context, Effect, Layer, Redacted } from "effect"; +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 type { SelfhostRuntimeConfig } from "../config.ts"; +import { makeSelfhostPlatformLive } from "./PlatformProfile.ts"; const analyticsQueueName = "analytics-ingest"; const analyticsDeadLetterQueueName = "analytics-ingest-dlq"; @@ -140,21 +138,13 @@ const makeCaptureIngressLive = Layer.effect( }), ); -/** Builds the PostgreSQL queue, policy counter, and capture service runtime. */ +/** + * Builds the process-wide platform primitives — queue, key-value store, cron + * scheduler, and runtime marker — plus the policy counter and capture services + * layered on top of them. + */ export const makeSelfhostAnalyticsRuntimeLive = (config: SelfhostRuntimeConfig) => { - const postgres = { - database: config.database.databaseName, - host: config.database.host, - password: Redacted.make(config.database.password), - port: config.database.port, - ...(config.database.ssl === undefined ? {} : { ssl: config.database.ssl }), - username: config.database.username, - }; - const platform = Layer.mergeAll( - PgQueueLive(postgres), - PgKeyValueStoreLive(postgres), - NodePlatformRuntimeLive, - ); + const platform = makeSelfhostPlatformLive(config); const database = Db.layer(config.database); const dlq = AnalyticsIngestDlqService.layer.pipe(Layer.provide(database)); const policy = makePolicyCounterStoreLive.pipe(Layer.provide(platform)); diff --git a/selfhost/entry/src/backend/Backend.ts b/apps/backend/src/backend/Backend.ts similarity index 54% rename from selfhost/entry/src/backend/Backend.ts rename to apps/backend/src/backend/Backend.ts index 9a9038d8b..65f02ab8b 100644 --- a/selfhost/entry/src/backend/Backend.ts +++ b/apps/backend/src/backend/Backend.ts @@ -3,18 +3,25 @@ import { BackendPaymentProviderStubsLive, BackendSnapshotImageRendererStubLive, type InfraServices, -} from "@voidhash/backend/src/BackendApp.ts"; +} from "@voidhash/backend/BackendApp"; import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; -import { Workos } from "@voidhash/core/services/auth/Workos"; +import type { AuthTokenVerifier } from "@voidhash/core/services/auth/AuthTokenVerifier"; +import { IdentityProvider } from "@voidhash/core/services/auth/IdentityProvider"; +import { + StandaloneAuthTokenVerifierLive, + StandaloneIdentityProviderLive, +} from "@voidhash/core/services/auth/StandaloneIdentityProvider"; +import { StandaloneOrgDirectoryLive } from "@voidhash/core/services/organizations/StandaloneOrgDirectory"; +import { OrgDirectoryPort } from "@voidhash/core/services/organizations/OrgDirectoryPort"; import { SnapshotImageRenderer } from "@voidhash/core/services/paywallThumbnails/SnapshotImageRenderer"; import type { PublicFileStore } from "@voidhash/core/services/storage/PublicFileStore"; import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/PaywallAssetConfig"; import { Db } from "@voidhash/db"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; -import { Effect, Layer } from "effect"; +import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; +import { Effect, Layer, Redacted } from "effect"; -import type { SelfhostRuntimeConfig, SelfhostWorkosConfig } from "../config.ts"; +import type { SelfhostAuthConfig, SelfhostRuntimeConfig } from "../config.ts"; import { makeHttpComponentCompilerLive } from "../compiler/CompilerClient.ts"; import { makeBackendMimicHostLive } from "./MimicHost.ts"; import { @@ -22,22 +29,30 @@ import { makePublicFileStoreLive, } from "./ObjectStores.ts"; import { MemoryProjectSchemaCacheLive } from "./ProjectSchemaCache.ts"; -import { WorkosOrgPortLive } from "./Workos.ts"; -/** Builds the WorkOS SDK service from operator-provided credentials. */ -export const makeSelfhostWorkosLive = (config: SelfhostWorkosConfig): Layer.Layer => - Workos.layer({ - apiKey: Effect.succeed(config.apiKey), - clientId: Effect.succeed(config.clientId), - cookieName: Effect.succeed(config.cookieName), - cookiePassword: Effect.succeed(config.cookiePassword), - webhookSecret: Effect.succeed(config.webhookSecret), - }); +/** + * The identity-provider half of the infrastructure graph: the HS256 token + * provider for the root identity plus a database-only organization directory. + * Self-host has no external directory, so there is nothing else to wire. + */ +export interface SelfhostAuthLayers { + readonly authTokenVerifier: Layer.Layer; + readonly identity: Layer.Layer; +} + +/** Builds the auth layers for the standalone identity provider. */ +export const makeSelfhostAuthLayers = (config: SelfhostAuthConfig): SelfhostAuthLayers => { + const secret = Redacted.value(config.secret); + return { + authTokenVerifier: StandaloneAuthTokenVerifierLive(secret), + identity: Layer.mergeAll(StandaloneIdentityProviderLive(secret), StandaloneOrgDirectoryLive), + }; +}; /** Builds the provider-neutral backend infrastructure for the Node runtime. */ export const makeBackendInfrastructureLive = ( config: SelfhostRuntimeConfig, - workos: Layer.Layer, + identity: SelfhostAuthLayers["identity"], clickhouse?: Layer.Layer, snapshotImageRenderer: Layer.Layer< SnapshotImageRenderer, @@ -48,18 +63,18 @@ export const makeBackendInfrastructureLive = ( const publicFileStore = makePublicFileStoreLive( config.publicObjectStore, config.publicFilesBaseUrl, - ).pipe(Layer.provide(NodePlatformRuntimeLive)); + ).pipe(Layer.provide(SelfhostPlatformRuntimeLive)); + const db = Db.layer(config.database); return Layer.mergeAll( - Db.layer(config.database), - workos, - WorkosOrgPortLive.pipe(Layer.provide(workos)), + db, + identity.pipe(Layer.provide(db)), Layer.succeed(PaywallAssetConfig, { cdnUrl: config.publicBaseUrl, publicBaseUrl: config.publicBaseUrl, }), makePaywallArtifactStoreLive(config.artifactObjectStore).pipe( - Layer.provide(NodePlatformRuntimeLive), + Layer.provide(SelfhostPlatformRuntimeLive), ), publicFileStore, BackendPaymentProviderStubsLive, diff --git a/apps/backend/src/backend/Background.ts b/apps/backend/src/backend/Background.ts new file mode 100644 index 000000000..786578d67 --- /dev/null +++ b/apps/backend/src/backend/Background.ts @@ -0,0 +1,59 @@ +import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { AnalyticsJanitorService } from "@voidhash/core/services/analyticsIngest/AnalyticsJanitorService"; +import { FxRateSync } from "@voidhash/core/workflows/definitions"; +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"; + +/** 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 jobs: Array> = backendWorkflows.flatMap( + (registration) => { + if (registration.cron === undefined) return []; + if (registration.workflow === FxRateSync && !exchangeRateApiKey) return []; + return [ + CronJob.define({ + expression: registration.cron.schedule, + name: registration.workflow.name, + run: ({ scheduledTime }) => registration.cron!.dispatch(scheduledTime), + }), + ]; + }, + ); + + if (clickhouse) { + const janitorContext = yield* Layer.build( + AnalyticsJanitorService.layer.pipe(Layer.provide(clickhouse)), + ); + const janitor = Context.get(janitorContext, AnalyticsJanitorService); + jobs.push( + CronJob.define({ + expression: "*/5 * * * *", + name: "analytics-janitor", + run: () => + janitor.squash({ batchSize: 1000, safetyWindowSeconds: 120 }).pipe(Effect.asVoid), + }), + ); + } + + return jobs; + }); + +/** Runs every enabled persisted cron job until the enclosing scope closes. */ +export const runSelfhostCronJobs = ( + clickhouse?: Layer.Layer, +) => + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + const jobs = yield* makeSelfhostCronJobs(clickhouse); + return yield* Effect.all( + jobs.map((job) => scheduler.run(job, { pollIntervalMillis: 1_000 })), + { concurrency: "unbounded" }, + ); + }); diff --git a/selfhost/entry/src/backend/Clickhouse.ts b/apps/backend/src/backend/Clickhouse.ts similarity index 100% rename from selfhost/entry/src/backend/Clickhouse.ts rename to apps/backend/src/backend/Clickhouse.ts diff --git a/selfhost/entry/src/backend/MimicHost.ts b/apps/backend/src/backend/MimicHost.ts similarity index 100% rename from selfhost/entry/src/backend/MimicHost.ts rename to apps/backend/src/backend/MimicHost.ts diff --git a/selfhost/entry/src/backend/ObjectStores.ts b/apps/backend/src/backend/ObjectStores.ts similarity index 94% rename from selfhost/entry/src/backend/ObjectStores.ts rename to apps/backend/src/backend/ObjectStores.ts index 0b38c4f97..bd4ee914b 100644 --- a/selfhost/entry/src/backend/ObjectStores.ts +++ b/apps/backend/src/backend/ObjectStores.ts @@ -6,12 +6,12 @@ import { PublicFileStore, PublicFileStoreError, } from "@voidhash/core/services/storage/PublicFileStore"; -import { ObjectStore, ObjectStoreError } from "@orbian/sdk/ObjectStore"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import { ObjectStore, ObjectStoreError } from "@voidhash/platform/ObjectStore"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { S3ObjectStoreLive, type S3ObjectStoreConfig, -} from "@orbian/node/ObjectStore"; +} from "@voidhash/platform-selfhost/ObjectStore"; import { Effect, Layer, Option } from "effect"; const objectStoreCause = (cause: unknown): string => diff --git a/apps/backend/src/backend/PlatformProfile.ts b/apps/backend/src/backend/PlatformProfile.ts new file mode 100644 index 000000000..4d097e858 --- /dev/null +++ b/apps/backend/src/backend/PlatformProfile.ts @@ -0,0 +1,165 @@ +import * as PgClient from "@effect/sql-pg/PgClient"; +import type { DbConfig } from "@voidhash/db/db"; +import type { CronScheduler } from "@voidhash/platform/CronScheduler"; +import type { + DurableEntityAlarmControl, + DurableEntityHost, +} from "@voidhash/platform/DurableEntity"; +import type { KeyValueStore } from "@voidhash/platform/KeyValueStore"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import type { QueueDriver } from "@voidhash/platform/Queue"; +import type { WorkflowRunner } from "@voidhash/platform/WorkflowRunner"; +import { + ClusterDurableEntityControlLive, + ClusterDurableEntityHostLive, +} from "@voidhash/platform-selfhost/ClusterDurableEntity"; +import { ClusterCronSchedulerLive } from "@voidhash/platform-selfhost/CronScheduler"; +import { PgEntityAlarmStoreLive } from "@voidhash/platform-selfhost/EntityAlarmStore"; +import { PgKeyValueStoreLive } from "@voidhash/platform-selfhost/KeyValueStore"; +import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; +import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; +import { ClusterQueueLive } from "@voidhash/platform-selfhost/Queue"; +import { SingleNodeClusterLive } from "@voidhash/platform-selfhost/Topology"; +import * as ClusterWorkflowRunner from "@voidhash/platform-selfhost/Workflow"; +import { Layer, Redacted } from "effect"; +import { + KeyValueStore as PersistenceKeyValueStore, + PersistedQueue, +} from "effect/unstable/persistence"; + +import type { SelfhostRuntimeConfig } from "../config.ts"; + +/** + * The swappable platform primitives a self-host deployment installs. + * + * The mailer, the object store, and the screenshot renderer are deliberately + * absent: they are plain clients configured per process rather than parts of + * the shared cluster composition. + */ +export interface SelfhostPlatformLayers { + readonly queue: Layer.Layer; + readonly keyValueStore: Layer.Layer; + readonly cronScheduler: Layer.Layer; + /** + * The entity host and the control plane the alarm dispatcher polls. Entity + * sessions are process-local, so this layer only serves entities whose shard + * this runner owns — true by construction for the single-runner topology. + */ + readonly durableEntities: Layer.Layer; + readonly workflowRunner: Layer.Layer; + readonly runtime: Layer.Layer; +} + +/** + * The slice of runtime configuration a platform composition needs. + * + * Entrypoints that never read the full {@link SelfhostRuntimeConfig} — the + * mimic process reads its own — still have to resolve their platform through + * this module, so the input is declared narrowly. + */ +export type SelfhostPlatformSelection = Pick; + +/** + * Converts an application-shaped connection into the platform's Postgres + * parameters. Exported because the migration entrypoint builds the entity host + * directly to create its tables. + */ +export const selfhostPlatformPostgres = (database: DbConfig): PgPlatformConfig => ({ + database: database.databaseName, + host: database.host, + password: Redacted.make(database.password), + port: database.port, + ...(database.ssl === undefined ? {} : { ssl: database.ssl }), + username: database.username, +}); + +const platformLayers = (postgres: PgPlatformConfig): SelfhostPlatformLayers => { + const sql = PgClient.layer({ + database: postgres.database, + host: postgres.host, + password: postgres.password, + port: postgres.port, + username: postgres.username, + ...(postgres.ssl === undefined ? {} : { ssl: postgres.ssl }), + }).pipe(Layer.orDie); + + // Every cluster-backed primitive shares this one topology value so a single + // build installs a single runner. Two runners in one process would compete + // for the same shard leases. + const topology = SingleNodeClusterLive({ runnerStorage: "memory" }).pipe( + Layer.provide(sql), + Layer.orDie, + ); + + // The SQL store polls for new rows on an interval that has to stay shorter + // than the driver's claim window, or a non-empty queue reads as empty. + const persistedQueueStore = PersistedQueue.layerStoreSql({ pollInterval: "50 millis" }).pipe( + Layer.provide(sql), + Layer.orDie, + ); + + // Shared by the cron scheduler and entity values so both read one table. + const persistenceKeyValueStore = PersistenceKeyValueStore.layerSql().pipe( + Layer.provide(sql), + Layer.orDie, + ); + + return { + queue: ClusterQueueLive.pipe( + Layer.provide(PersistedQueue.layer), + Layer.provide(persistedQueueStore), + ), + // No cluster adapter implements the typed key-value contract's atomic + // counters, so the analytics policy store stays on Postgres. + keyValueStore: PgKeyValueStoreLive(postgres), + cronScheduler: ClusterCronSchedulerLive.pipe( + Layer.provide(persistenceKeyValueStore), + Layer.provide(topology), + ), + durableEntities: Layer.mergeAll( + ClusterDurableEntityHostLive, + ClusterDurableEntityControlLive, + ).pipe( + Layer.provide(PgEntityAlarmStoreLive.pipe(Layer.provide(sql))), + Layer.provide(persistenceKeyValueStore), + Layer.provide(topology), + ), + workflowRunner: ClusterWorkflowRunner.layer.pipe(Layer.provide(topology)), + runtime: SelfhostPlatformRuntimeLive, + }; +}; + +let cached: SelfhostPlatformLayers | undefined; + +/** + * Resolves the process-wide platform composition. + * + * The result is memoized for the lifetime of the process, and deliberately so: + * every call site has to receive the *same* layer values, because Effect + * memoizes a layer per build by reference. Handing out fresh values would give + * one build two cluster topologies and two queue drivers, and a queue whose + * producer and consumer are different driver instances never delivers. + */ +export const makeSelfhostPlatformLayers = ( + config: SelfhostPlatformSelection, +): SelfhostPlatformLayers => { + cached ??= platformLayers(selfhostPlatformPostgres(config.platformDatabase)); + return cached; +}; + +/** + * The process-wide platform layer: every swappable primitive except the + * workflow runner, which the server root builds once before registering the + * shared workflow registry. + */ +export const makeSelfhostPlatformLive = ( + config: SelfhostRuntimeConfig, +): Layer.Layer => { + const platform = makeSelfhostPlatformLayers(config); + return Layer.mergeAll( + platform.queue, + platform.keyValueStore, + platform.cronScheduler, + platform.runtime, + ); +}; diff --git a/selfhost/entry/src/backend/ProjectSchemaCache.ts b/apps/backend/src/backend/ProjectSchemaCache.ts similarity index 100% rename from selfhost/entry/src/backend/ProjectSchemaCache.ts rename to apps/backend/src/backend/ProjectSchemaCache.ts diff --git a/selfhost/entry/src/backend/Push.ts b/apps/backend/src/backend/Push.ts similarity index 96% rename from selfhost/entry/src/backend/Push.ts rename to apps/backend/src/backend/Push.ts index 9f130b236..454b93ed2 100644 --- a/selfhost/entry/src/backend/Push.ts +++ b/apps/backend/src/backend/Push.ts @@ -13,8 +13,8 @@ import { } from "@voidhash/core/services/notifications/PushDeliveryDispatch"; import { PaymentConfigSecretCrypto } from "@voidhash/core/utils/crypto/PaymentConfigSecretCrypto"; import { Db } from "@voidhash/db"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { QueueDriver } from "@orbian/sdk/Queue"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { QueueDriver } from "@voidhash/platform/Queue"; import { Context, Effect, Layer } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; diff --git a/selfhost/entry/src/backend/Thumbnails.ts b/apps/backend/src/backend/Thumbnails.ts similarity index 94% rename from selfhost/entry/src/backend/Thumbnails.ts rename to apps/backend/src/backend/Thumbnails.ts index e080e6a3b..132e410ad 100644 --- a/selfhost/entry/src/backend/Thumbnails.ts +++ b/apps/backend/src/backend/Thumbnails.ts @@ -16,14 +16,14 @@ import type { PreviewTree, SnapshotNode, } from "@voidhash/paywall-renderer-web-core"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { QueueDriver } from "@orbian/sdk/Queue"; -import { Screenshot } from "@orbian/sdk/Screenshot"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { QueueDriver } from "@voidhash/platform/Queue"; +import { Screenshot } from "@voidhash/platform/Screenshot"; import { ChromiumScreenshotLive, type ChromiumScreenshotConfig, -} from "@orbian/node/Screenshot"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; +} from "@voidhash/platform-selfhost/Screenshot"; +import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; import { Cause, Effect, Layer } from "effect"; import { mimicDocumentIdleQueueName } from "../mimic/MimicDocumentIdleQueue.ts"; @@ -141,7 +141,7 @@ export const makeSelfhostSnapshotImageRendererLive = ( Layer.provide( Layer.merge( ChromiumScreenshotLive(screenshotConfig), - NodePlatformRuntimeLive, + SelfhostPlatformRuntimeLive, ), ), ), diff --git a/selfhost/entry/src/compiler/CompilerClient.ts b/apps/backend/src/compiler/CompilerClient.ts similarity index 100% rename from selfhost/entry/src/compiler/CompilerClient.ts rename to apps/backend/src/compiler/CompilerClient.ts diff --git a/selfhost/entry/src/compiler/CompilerCore.ts b/apps/backend/src/compiler/CompilerCore.ts similarity index 100% rename from selfhost/entry/src/compiler/CompilerCore.ts rename to apps/backend/src/compiler/CompilerCore.ts diff --git a/selfhost/entry/src/compiler/CompilerProtocol.ts b/apps/backend/src/compiler/CompilerProtocol.ts similarity index 100% rename from selfhost/entry/src/compiler/CompilerProtocol.ts rename to apps/backend/src/compiler/CompilerProtocol.ts diff --git a/selfhost/entry/src/compiler/main.ts b/apps/backend/src/compiler/main.ts similarity index 100% rename from selfhost/entry/src/compiler/main.ts rename to apps/backend/src/compiler/main.ts diff --git a/selfhost/entry/src/config.ts b/apps/backend/src/config.ts similarity index 63% rename from selfhost/entry/src/config.ts rename to apps/backend/src/config.ts index ee18bedb2..5196829bb 100644 --- a/selfhost/entry/src/config.ts +++ b/apps/backend/src/config.ts @@ -1,6 +1,11 @@ import type { DbConfig } from "@voidhash/db/db"; -import type { SmtpMailerConfig } from "@orbian/node/Mailer"; -import type { S3ObjectStoreConfig } from "@orbian/node/ObjectStore"; +import type { SmtpMailerConfig } from "@voidhash/platform-selfhost/Mailer"; +import type { S3ObjectStoreConfig } from "@voidhash/platform-selfhost/ObjectStore"; +import { + isPlaceholderSecret, + resolveStandaloneAuthConfig, + standaloneAuthConfigIssues, +} from "@voidhash/core/services/auth/StandaloneAuthConfig"; import { Redacted } from "effect"; const positiveIntegerFromEnv = (name: string, fallback: number): number => { @@ -21,15 +26,6 @@ const optionalBooleanFromEnv = (name: string): boolean | undefined => { throw new Error(`${name} must be true or false`); }; -const requiredInProduction = (name: string, developmentFallback: string): string => { - const value = process.env[name]?.trim(); - if (value) return value; - if (readSelfhostMode() === "production") { - throw new Error(`${name} is required in production`); - } - return developmentFallback; -}; - export type SelfhostMode = "local-evaluation" | "production"; const readSelfhostMode = (): SelfhostMode => { @@ -38,18 +34,6 @@ const readSelfhostMode = (): SelfhostMode => { throw new Error("SELFHOST_MODE must be explicitly set to local-evaluation or production"); }; -const isExampleSecret = (value: string | undefined): boolean => { - const normalized = value?.trim().toLowerCase(); - return ( - !normalized || - normalized === "password" || - normalized.includes("not_configured") || - normalized.includes("change-me") || - normalized.includes("replace-me") || - normalized.startsWith("replace-with-") - ); -}; - const isHttpsUrl = (value: string | undefined): boolean => { if (!value) return false; try { @@ -59,21 +43,19 @@ const isHttpsUrl = (value: string | undefined): boolean => { } }; -/** Refuses evaluation credentials and insecure public URLs in production mode. */ +/** + * Refuses evaluation credentials and insecure public URLs in production mode. + * + * The standalone identity provider is production-grade, so unlike the earlier + * development provider it is not refused here — what is refused is running it on + * the documented evaluation defaults, which are public knowledge. + */ export const validateSelfhostSecurityConfig = (): SelfhostMode => { const mode = readSelfhostMode(); if (mode === "local-evaluation") return mode; - const unsafeSettings: Array = []; - const requiredSecrets = [ - "DATABASE_PASSWORD", - "MIMIC_ROOT_PASSWORD", - "S3_SECRET_ACCESS_KEY", - "WORKOS_API_KEY", - "WORKOS_CLIENT_ID", - "WORKOS_COOKIE_PASSWORD", - "WORKOS_WEBHOOK_SECRET", - ]; + const unsafeSettings: Array = [...standaloneAuthConfigIssues()]; + const requiredSecrets = ["DATABASE_PASSWORD", "MIMIC_ROOT_PASSWORD", "S3_SECRET_ACCESS_KEY"]; if (process.env.CLICKHOUSE_URL?.trim()) { requiredSecrets.push( "CLICKHOUSE_ADMIN_PASSWORD", @@ -83,18 +65,13 @@ export const validateSelfhostSecurityConfig = (): SelfhostMode => { ); } for (const name of requiredSecrets) { - if (isExampleSecret(process.env[name])) unsafeSettings.push(name); + if (isPlaceholderSecret(process.env[name])) unsafeSettings.push(name); } if (!process.env.OPENAI_API_KEY?.trim() && !process.env.ANTHROPIC_API_KEY?.trim()) { unsafeSettings.push("OPENAI_API_KEY or ANTHROPIC_API_KEY"); } - for (const name of [ - "PUBLIC_BASE_URL", - "PUBLIC_FILES_BASE_URL", - "MIMIC_PUBLIC_BASE_URL", - "WORKOS_REDIRECT_URI", - ]) { + for (const name of ["PUBLIC_BASE_URL", "PUBLIC_FILES_BASE_URL", "MIMIC_PUBLIC_BASE_URL"]) { if (!isHttpsUrl(process.env[name])) unsafeSettings.push(name); } @@ -106,15 +83,34 @@ export const validateSelfhostSecurityConfig = (): SelfhostMode => { return mode; }; -/** WorkOS credentials supplied by a Community Edition operator. */ -export interface SelfhostWorkosConfig { - readonly apiKey: string; - readonly clientId: string; - readonly cookieName: string; - readonly cookiePassword: string; - readonly webhookSecret: string; +/** + * The single root identity and the key its session tokens are signed with. + * + * Self-host is single-player: these credentials are the only way in, and there + * is no code path that can create a second user. + */ +export interface SelfhostAuthConfig { + readonly rootUsername: string; + readonly rootPassword: Redacted.Redacted; + readonly rootEmail: string; + readonly secret: Redacted.Redacted; } +/** + * Reads the standalone identity configuration. Evaluation defaults apply when + * the variables are unset; {@link validateSelfhostSecurityConfig} is what + * refuses those defaults in production. + */ +export const getSelfhostAuthConfig = (): SelfhostAuthConfig => { + const resolved = resolveStandaloneAuthConfig(); + return { + rootEmail: resolved.rootEmail, + rootPassword: Redacted.make(resolved.rootPassword), + rootUsername: resolved.rootUsername, + secret: Redacted.make(resolved.secret), + }; +}; + /** A named ClickHouse connection used by the self-host analytics runtime. */ export interface SelfhostClickhouseConnection { readonly database: string; @@ -145,17 +141,23 @@ export interface SelfhostAgentConfig { /** Configuration for the single-process self-host runtime. */ export interface SelfhostRuntimeConfig { readonly agent: SelfhostAgentConfig; + readonly auth: SelfhostAuthConfig; readonly clickhouse?: SelfhostClickhouseConfig; readonly componentCompilerUrl: string; readonly database: DbConfig; readonly host: string; readonly mailer: SmtpMailerConfig; + /** + * Where platform state lives: cluster mailboxes, workflow executions, + * persisted queues, entity alarms, and the key-value store. Defaults to + * {@link SelfhostRuntimeConfig.database}. + */ + readonly platformDatabase: DbConfig; readonly port: number; readonly publicBaseUrl: string; readonly publicFilesBaseUrl: string; readonly publicObjectStore: S3ObjectStoreConfig; readonly artifactObjectStore: S3ObjectStoreConfig; - readonly workos: SelfhostWorkosConfig; } /** Reads optional ClickHouse configuration, returning undefined when analytics is disabled. */ @@ -202,6 +204,62 @@ export const getSelfhostDatabaseConfig = (): DbConfig => { }; }; +/** + * Reads the application database connection used by out-of-band tooling that + * opens its own TCP socket — currently the migration entrypoint, which runs as + * a separate process before the server starts. + * + * Every `DATABASE_DIRECT_*` variable falls back to its `DATABASE_*` counterpart, + * so operators whose application already dials Postgres directly never set them. + * They exist for deployments where `DATABASE_HOST` names a sandboxed or proxied + * endpoint (a connection broker, a Hyperdrive-style local socket) that only + * resolves inside the runtime serving requests. Migrations need multi-statement + * SQL and a session-scoped advisory lock, so they always take the origin. + */ +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 }), + username: process.env.DATABASE_DIRECT_USERNAME?.trim() || fallback.username, + }; +}; + +/** + * Reads the database that holds platform state — cluster mailboxes, workflow + * executions, persisted queues, entity alarms, and the platform key-value store. + * + * Every `DATABASE_PLATFORM_*` variable falls back to its `DATABASE_*` + * counterpart, so a deployment that leaves them unset keeps platform state + * beside application data in one database, which is the supported shape. + * + * Setting `DATABASE_PLATFORM_NAME` moves that state into its own database. The + * reason it is separable is shard ownership: a single-runner cluster claims + * *every* shard in the database it is built over, so two processes sharing one + * database steal each other's messages. That is exactly what a test process does + * to a running deployment, so `pnpm test:integration` points the suites that + * build their own topology at a database of their own. + */ +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, + }; +}; + /** Reads the SMTP transport and default sender configuration. */ export const getSelfhostSmtpConfig = (): SmtpMailerConfig => { const username = process.env.SMTP_USERNAME?.trim() || undefined; @@ -259,11 +317,13 @@ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { ...objectStore, bucketName: process.env.S3_ARTIFACT_BUCKET?.trim() || "voidhash-artifacts", }, + auth: getSelfhostAuthConfig(), database: getSelfhostDatabaseConfig(), ...(clickhouse === undefined ? {} : { 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(), + platformDatabase: getSelfhostPlatformDatabaseConfig(), port: positiveIntegerFromEnv("PORT", 5001), publicBaseUrl, publicFilesBaseUrl: process.env.PUBLIC_FILES_BASE_URL?.trim() || publicBaseUrl, @@ -271,15 +331,5 @@ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { ...objectStore, bucketName: process.env.S3_PUBLIC_BUCKET?.trim() || "voidhash-public", }, - workos: { - apiKey: requiredInProduction("WORKOS_API_KEY", "sk_test_selfhost_not_configured"), - clientId: requiredInProduction("WORKOS_CLIENT_ID", "client_selfhost_not_configured"), - cookieName: process.env.WORKOS_COOKIE_NAME?.trim() || "wos-session", - cookiePassword: requiredInProduction( - "WORKOS_COOKIE_PASSWORD", - "selfhost-development-cookie-password-change-me", - ), - webhookSecret: requiredInProduction("WORKOS_WEBHOOK_SECRET", "whsec_selfhost_not_configured"), - }, }; }; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 000000000..f863480ec --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,18 @@ +export { makeBackendInfrastructureLive, makeSelfhostAuthLayers } from "./backend/Backend.ts"; +export { makeBackendMimicHostLive } from "./backend/MimicHost.ts"; +export { + getSelfhostDatabaseConfig, + getSelfhostMigrationDatabaseConfig, + getSelfhostPlatformDatabaseConfig, + getSelfhostRuntimeConfig, +} from "./config.ts"; +export { getMimicNodeConfig } from "./mimic/config.ts"; +export { runSelfhostMigrations, type SelfhostMigrationOptions } from "./migrations.ts"; +export { makeMimicNodeHostLive, type MimicNodeConfig } from "./mimic/MimicNode.ts"; +export { installMimicNodeWebSocketServer } from "./mimic/MimicNodeWebSocket.ts"; +export { makePgControlStore, PgControlStoreLive } from "./mimic/PgControlStore.ts"; +export { + runSelfhostServer, + type SelfhostServerOptions, + type SelfhostServerRuntime, +} from "./server.ts"; diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts new file mode 100644 index 000000000..3dae94f6c --- /dev/null +++ b/apps/backend/src/main.ts @@ -0,0 +1,15 @@ +import { NodeRuntime } from "@effect/platform-node"; +import { + NoBackendFeatures, + NoBackendRpcExtension, +} from "@voidhash/backend/BackendApp"; + +import { runSelfhostServer } from "./server.ts"; + +NodeRuntime.runMain( + runSelfhostServer({ + edition: "Community Edition", + features: NoBackendFeatures, + rpcExtension: () => NoBackendRpcExtension, + }) as never, +); diff --git a/apps/backend/src/migrate.ts b/apps/backend/src/migrate.ts new file mode 100644 index 000000000..0ac81c2fe --- /dev/null +++ b/apps/backend/src/migrate.ts @@ -0,0 +1,6 @@ +import { NodeRuntime } from "@effect/platform-node"; +import { Effect } from "effect"; + +import { runSelfhostMigrations } from "./migrations.ts"; + +NodeRuntime.runMain(Effect.scoped(runSelfhostMigrations()) as never); diff --git a/apps/backend/src/migrations.ts b/apps/backend/src/migrations.ts new file mode 100644 index 000000000..b0be83062 --- /dev/null +++ b/apps/backend/src/migrations.ts @@ -0,0 +1,52 @@ +import { runAppDatabaseMigrations } from "@voidhash/db/migrations"; +import { PgClusterDurableEntityLive } from "@voidhash/platform-selfhost/ClusterDurableEntity"; +import { Effect, Layer } from "effect"; + +import { migrateSelfhostClickhouse } from "./backend/Clickhouse.ts"; +import { selfhostPlatformPostgres } from "./backend/PlatformProfile.ts"; +import { + getSelfhostClickhouseConfig, + getSelfhostMigrationDatabaseConfig, + getSelfhostPlatformDatabaseConfig, + validateSelfhostSecurityConfig, +} from "./config.ts"; +import { getMimicNodeConfig } from "./mimic/config.ts"; +import { makeMimicNodeHostLive } from "./mimic/MimicNode.ts"; + +/** Extra migration sources a private composition owns alongside the OSS schema. */ +export interface SelfhostMigrationOptions { + /** + * Directories holding `_/migration.sql` folders, applied + * after the OSS schema into the same `__alchemy_migrations` ledger. + */ + readonly additionalDirectories?: ReadonlyArray; +} + +/** + * Applies every migration the self-host runtime needs: the application schema, + * the mimic document control tables, and — when analytics is configured — the + * ClickHouse schema. + */ +export const runSelfhostMigrations = (options: SelfhostMigrationOptions = {}) => + Effect.gen(function* () { + validateSelfhostSecurityConfig(); + const connection = getSelfhostMigrationDatabaseConfig(); + const result = yield* runAppDatabaseMigrations(connection); + let applied = result.applied.length; + let skipped = result.skipped; + for (const directory of options.additionalDirectories ?? []) { + const extra = yield* runAppDatabaseMigrations(connection, { directory }); + applied += extra.applied.length; + skipped += extra.skipped; + } + // Building the host is what creates the self-managed tables: the mimic + // control and document tables in the application database, plus the entity + // value and alarm stores in whichever database holds platform state. + const mimicConfig = getMimicNodeConfig(connection); + const platform = selfhostPlatformPostgres(getSelfhostPlatformDatabaseConfig(connection)); + yield* Layer.build( + makeMimicNodeHostLive(mimicConfig, PgClusterDurableEntityLive(platform)), + ); + yield* migrateSelfhostClickhouse(getSelfhostClickhouseConfig()); + yield* Effect.logInfo("Self-host database migrations are ready", { applied, skipped }); + }); diff --git a/selfhost/entry/src/mimic/MimicDocumentIdleQueue.ts b/apps/backend/src/mimic/MimicDocumentIdleQueue.ts similarity index 86% rename from selfhost/entry/src/mimic/MimicDocumentIdleQueue.ts rename to apps/backend/src/mimic/MimicDocumentIdleQueue.ts index b617f2e07..660fc9941 100644 --- a/selfhost/entry/src/mimic/MimicDocumentIdleQueue.ts +++ b/apps/backend/src/mimic/MimicDocumentIdleQueue.ts @@ -2,8 +2,8 @@ import { MimicDocumentIdleMessage, type MimicDocumentIdleMessageType, } from "@voidhash/mimic-db/ws/idle-notify"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { QueueDriver } from "@orbian/sdk/Queue"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { QueueDriver } from "@voidhash/platform/Queue"; import { Effect } from "effect"; /** Logical PostgreSQL queue carrying idle Mimic document revisions. */ diff --git a/selfhost/entry/src/mimic/MimicNode.ts b/apps/backend/src/mimic/MimicNode.ts similarity index 69% rename from selfhost/entry/src/mimic/MimicNode.ts rename to apps/backend/src/mimic/MimicNode.ts index 6eed75ac7..c4467c91d 100644 --- a/selfhost/entry/src/mimic/MimicNode.ts +++ b/apps/backend/src/mimic/MimicNode.ts @@ -11,29 +11,34 @@ import { makePgDocumentStore, type PgDocumentConfig, } from "@voidhash/mimic-db/core/pg-store"; -import { DurableEntityHost } from "@orbian/sdk/DurableEntity"; -import { - NodeDurableEntityControl, - type PgDurableEntityConfig, - PgDurableEntityHostLive, -} from "@orbian/node/DurableEntity"; +import type { + DurableEntityAlarmControl, + DurableEntityHost, +} from "@voidhash/platform/DurableEntity"; +import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; import { Effect, Layer } from "effect"; import { PgControlStoreLive } from "./PgControlStore.ts"; /** Database-backed configuration for the standalone mimic Node composition. */ export interface MimicNodeConfig { - readonly database: PgDurableEntityConfig; + readonly database: PgPlatformConfig; readonly documents: PgDocumentConfig; } /** * Builds the persistent single-node mimic host. Control state, document logs, - * snapshots, entity KV, and alarms all survive process restarts in Postgres. + * snapshots, entity values, and alarms all survive process restarts in + * Postgres. + * + * The entity layer is supplied by the caller rather than built here: it carries + * the process-wide cluster topology, and a second topology in one process would + * compete with the first for the same shard leases. */ export const makeMimicNodeHostLive = ( config: MimicNodeConfig, -): Layer.Layer => { + entities: Layer.Layer, +): Layer.Layer => { const documentStores = Layer.effect( DocumentStoreFactory, ensureDocumentTables(config.documents).pipe( @@ -45,7 +50,6 @@ export const makeMimicNodeHostLive = ( ), ); - const entities = PgDurableEntityHostLive(config.database); const host = LocalHostServiceLive.pipe( Layer.provide(PgControlStoreLive(config.database)), Layer.provide(entities), diff --git a/selfhost/entry/src/mimic/MimicNodeWebSocket.ts b/apps/backend/src/mimic/MimicNodeWebSocket.ts similarity index 91% rename from selfhost/entry/src/mimic/MimicNodeWebSocket.ts rename to apps/backend/src/mimic/MimicNodeWebSocket.ts index 60150652b..ba8843dc1 100644 --- a/selfhost/entry/src/mimic/MimicNodeWebSocket.ts +++ b/apps/backend/src/mimic/MimicNodeWebSocket.ts @@ -17,11 +17,12 @@ import { encodeServerMessage } from "@voidhash/mimic-db/ws/protocol"; import { makeSessionRegistry } from "@voidhash/mimic-db/ws/session-registry"; import { DurableEntityHost, + type DurableEntityAlarmControlShape, type DurableEntityContext, + type DurableEntitySession, makeDurableEntityAddress, -} from "@orbian/sdk/DurableEntity"; -import type { NodeDurableEntityControlShape } from "@orbian/node/DurableEntity"; -import { makeNodeDurableEntitySession } from "@orbian/node/NodeDurableEntitySession"; +} from "@voidhash/platform/DurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/NodeDurableEntitySession"; import { Duration, Effect, Fiber, Semaphore } from "effect"; import WebSocket, { WebSocketServer, type RawData } from "ws"; @@ -32,6 +33,11 @@ import { interface NodeDocumentSocket { readonly webSocket: WebSocket; + /** + * The entity-side handle for this socket. Host-side broadcasts read their + * attachment from here, so every attachment write has to reach it. + */ + readonly entitySession: DurableEntitySession; attachment: SessionAttachment | null; } @@ -43,7 +49,7 @@ interface NodeDocumentRuntime { /** Persisted idle-notification settings for the Node WebSocket host. */ export interface MimicNodeIdleNotificationOptions { - readonly control: NodeDurableEntityControlShape; + readonly control: DurableEntityAlarmControlShape; readonly debounceMs: number; readonly publish: (message: MimicDocumentIdleMessageType) => Effect.Effect; readonly pollIntervalMs?: number; @@ -219,7 +225,14 @@ export const installMimicNodeWebSocketServer = ( ), }, getAttachment: (socket) => socket.attachment, - setAttachment: (socket, attachment) => void (socket.attachment = attachment), + // Authentication replaces the attachment object rather than mutating it, + // so the entity session has to be updated too. Without this write-through + // the host-side broadcast keeps reading the pre-auth attachment and + // silently skips every authenticated browser socket. + setAttachment: (socket, attachment) => { + socket.attachment = attachment; + 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)), @@ -270,25 +283,14 @@ export const installMimicNodeWebSocketServer = ( } webSockets.handleUpgrade(request, socket, head, (webSocket) => { const runtime = runtimeFor(address.collectionId, address.documentId); - const nodeSocket: NodeDocumentSocket = { - webSocket, - attachment: { - connectionId: crypto.randomUUID(), - collectionId: address.collectionId, - documentId: address.documentId, - origin: request.headers.origin ?? null, - connectedAt: Date.now(), - authenticated: false, - }, + const attachment: SessionAttachment = { + connectionId: crypto.randomUUID(), + collectionId: address.collectionId, + documentId: address.documentId, + origin: request.headers.origin ?? null, + connectedAt: Date.now(), + authenticated: false, }; - const attachment = nodeSocket.attachment; - if (!attachment) { - webSocket.close(1011, "Session initialization failed"); - return; - } - runtime.connections.add(nodeSocket); - runtime.context.registry.trackPending(attachment.connectionId, nodeSocket); - const entityAddress = documentEntityAddress(address.collectionId, address.documentId); const entitySession = makeNodeDurableEntitySession( attachment.connectionId, { @@ -297,6 +299,10 @@ export const installMimicNodeWebSocketServer = ( }, attachment, ); + const nodeSocket: NodeDocumentSocket = { webSocket, entitySession, attachment }; + runtime.connections.add(nodeSocket); + runtime.context.registry.trackPending(attachment.connectionId, nodeSocket); + const entityAddress = documentEntityAddress(address.collectionId, address.documentId); const ready = Effect.runPromise( entities.run(entityAddress, (entity) => entity.sessions.attach(entitySession)), ); diff --git a/selfhost/entry/src/mimic/PgControlStore.ts b/apps/backend/src/mimic/PgControlStore.ts similarity index 98% rename from selfhost/entry/src/mimic/PgControlStore.ts rename to apps/backend/src/mimic/PgControlStore.ts index 423b16088..4c1efcf24 100644 --- a/selfhost/entry/src/mimic/PgControlStore.ts +++ b/apps/backend/src/mimic/PgControlStore.ts @@ -10,7 +10,7 @@ import type { UserRecord, } from "@voidhash/mimic-db/core/store"; import { ControlStore } from "@voidhash/mimic-db/core/store"; -import type { PgDurableEntityConfig } from "@orbian/node/DurableEntity"; +import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; import { Effect, Layer } from "effect"; import { SqlClient } from "effect/unstable/sql"; @@ -262,7 +262,7 @@ export const makePgControlStore = (sql: SqlClient.SqlClient): ControlStoreApi => }; /** Persistent Postgres control-store layer for the single-node mimic host. */ -export const PgControlStoreLive = (config: PgDurableEntityConfig): Layer.Layer => +export const PgControlStoreLive = (config: PgPlatformConfig): Layer.Layer => Layer.effect( ControlStore, Effect.gen(function* () { diff --git a/apps/backend/src/mimic/config.ts b/apps/backend/src/mimic/config.ts new file mode 100644 index 000000000..97e4e23d2 --- /dev/null +++ b/apps/backend/src/mimic/config.ts @@ -0,0 +1,33 @@ +import type { DbConfig } from "@voidhash/db/db"; +import { makePgDocumentConfig } from "@voidhash/mimic-db/core/pg-store"; +import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; +import { Redacted } from "effect"; + +import { getSelfhostDatabaseConfig } from "../config.ts"; +import type { MimicNodeConfig } from "./MimicNode.ts"; + +/** + * Reads the self-host mimic database configuration. Defaults to the shared + * application connection; the migration entrypoint passes the direct-TCP + * connection instead, because building this host also issues DDL. + */ +export const getMimicNodeConfig = ( + connection: DbConfig = getSelfhostDatabaseConfig(), +): MimicNodeConfig => { + const { databaseName: database, host, port, username } = connection; + const databaseConfig: PgPlatformConfig = { + host, + port, + database, + username, + password: Redacted.make(connection.password), + }; + const documents = makePgDocumentConfig({ + host, + port, + database, + username, + password: connection.password, + }); + return { database: databaseConfig, documents }; +}; diff --git a/selfhost/entry/src/mimic/main.ts b/apps/backend/src/mimic/main.ts similarity index 74% rename from selfhost/entry/src/mimic/main.ts rename to apps/backend/src/mimic/main.ts index 96bc16d4e..19792c84b 100644 --- a/selfhost/entry/src/mimic/main.ts +++ b/apps/backend/src/mimic/main.ts @@ -4,15 +4,15 @@ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import { getConfig } from "@voidhash/mimic-db/config"; import { makeRoutesLive } from "@voidhash/mimic-db/http/rpc-app"; -import { DurableEntityHost } from "@orbian/sdk/DurableEntity"; import { - NodeDurableEntityControl, -} from "@orbian/node/DurableEntity"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; -import { PgQueueLive } from "@orbian/node/Queue"; + DurableEntityAlarmControl, + DurableEntityHost, +} from "@voidhash/platform/DurableEntity"; import { Context, Effect, Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; +import { makeSelfhostPlatformLayers } from "../backend/PlatformProfile.ts"; +import { getSelfhostPlatformDatabaseConfig } from "../config.ts"; import { getMimicNodeConfig } from "./config.ts"; import { makeSelfhostMimicDocumentIdlePublisher } from "./MimicDocumentIdleQueue.ts"; import { makeMimicNodeHostLive } from "./MimicNode.ts"; @@ -20,7 +20,14 @@ import { installMimicNodeWebSocketServer } from "./MimicNodeWebSocket.ts"; const port = Number(process.env.PORT ?? "5001"); const config = getMimicNodeConfig(); -const hostLayer = makeMimicNodeHostLive(config); +// 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. +// Resolving the composition through the shared module is what keeps a split +// deployment from silently dropping every message. +const platform = makeSelfhostPlatformLayers({ + platformDatabase: getSelfhostPlatformDatabaseConfig(), +}); +const hostLayer = makeMimicNodeHostLive(config, platform.durableEntities); NodeRuntime.runMain( Effect.scoped( @@ -28,10 +35,8 @@ NodeRuntime.runMain( const hostContext = yield* Layer.build(hostLayer); const host = Context.get(hostContext, HostServiceTag); const entities = Context.get(hostContext, DurableEntityHost); - const entityControl = Context.get(hostContext, NodeDurableEntityControl); - const queueContext = yield* Layer.build( - Layer.merge(PgQueueLive(config.database), NodePlatformRuntimeLive), - ); + const entityControl = Context.get(hostContext, DurableEntityAlarmControl); + const queueContext = yield* Layer.build(Layer.merge(platform.queue, platform.runtime)); const publishIdleDocument = yield* makeSelfhostMimicDocumentIdlePublisher.pipe( Effect.provide(queueContext), ); diff --git a/selfhost/entry/src/release-smoke.ts b/apps/backend/src/release-smoke.ts similarity index 90% rename from selfhost/entry/src/release-smoke.ts rename to apps/backend/src/release-smoke.ts index a9f57ee83..1fa6368c4 100644 --- a/selfhost/entry/src/release-smoke.ts +++ b/apps/backend/src/release-smoke.ts @@ -1,5 +1,5 @@ import { NodeRuntime } from "@effect/platform-node"; -import { BackendSnapshotHtmlRendererLive } from "@voidhash/backend/src/PaywallSnapshotHtmlRenderer.ts"; +import { BackendSnapshotHtmlRendererLive } from "@voidhash/backend/PaywallSnapshotHtmlRenderer"; import type { AnyAuthSession } from "@voidhash/core/domain/auth/Auth"; import { AuthSession } from "@voidhash/core/domain/auth/Auth"; import { @@ -11,8 +11,9 @@ import { Context, Effect, Layer } from "effect"; import { makeBackendInfrastructureLive, - makeSelfhostWorkosLive, + makeSelfhostAuthLayers, } from "./backend/Backend.ts"; +import { makeSelfhostPlatformLayers } from "./backend/PlatformProfile.ts"; import { getSelfhostRuntimeConfig } from "./config.ts"; import { makeMimicNodeHostLive } from "./mimic/MimicNode.ts"; import { getMimicNodeConfig } from "./mimic/config.ts"; @@ -65,14 +66,17 @@ NodeRuntime.runMain( const userId = requiredEnv("SELFHOST_RELEASE_USER_ID"); const config = getSelfhostRuntimeConfig(); const hostContext = yield* Layer.build( - makeMimicNodeHostLive(getMimicNodeConfig()), + makeMimicNodeHostLive( + getMimicNodeConfig(), + makeSelfhostPlatformLayers(config).durableEntities, + ), ); const hostLayer = Layer.succeed( HostServiceTag, Context.get(hostContext, HostServiceTag), ); - const workos = makeSelfhostWorkosLive(config.workos); - const infrastructure = makeBackendInfrastructureLive(config, workos).pipe( + const authLayers = makeSelfhostAuthLayers(config.auth); + const infrastructure = makeBackendInfrastructureLive(config, authLayers.identity).pipe( Layer.provide(hostLayer), ); const dependencies = Layer.mergeAll( diff --git a/apps/backend/src/routes/webhooks/workos.ts b/apps/backend/src/routes/webhooks/workos.ts deleted file mode 100644 index cd1250296..000000000 --- a/apps/backend/src/routes/webhooks/workos.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * WorkOS webhook endpoint — `POST /api/webhooks/workos`. - * - * WorkOS is the source of truth for users and organizations. The handler - * verifies the WorkOS signature, persists the raw event for idempotency, and - * applies public user/organization mutations locally. Optional multi-user - * membership projection is delegated through an extension port. - * - * - Bad signature → 400. - * - Idempotent duplicate (same `event.id` already recorded) → 200. - * - Processing failure → 500 (WorkOS retries on the same `event.id`). - */ -import type { - Event as WorkosEvent, - Organization as WorkosOrganization, - User as WorkosUser, -} from "@workos-inc/node"; -import { LocalUserSessionService } from "@voidhash/core/services/auth/LocalUserSessionService"; -import { Workos } from "@voidhash/core/services/auth/Workos"; -import { OrganizationMembershipWebhookPort } from "@voidhash/core/services/organizations/OrganizationMembershipWebhookPort"; -import { generateId } from "@voidhash/core/utils"; -import { - Db, - eq, - organization, - user, - workosWebhookEvents, - type InsertOrganization, - type InsertWorkosWebhookEvent, -} from "@voidhash/db"; -import { Cause, Context, Effect, Layer } from "effect"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; - -type DbService = Context.Service.Shape; -type WorkosShape = Context.Service.Shape; -type LocalUserSessionShape = Context.Service.Shape; -type MembershipWebhookShape = Context.Service.Shape; - -class WebhookProcessingError extends Error { - constructor(message: string) { - super(message); - this.name = "WebhookProcessingError"; - } -} - -const describeFailure = (failure: unknown): string => { - const message = failure instanceof Error ? failure.message : String(failure); - const cause = (failure as { readonly cause?: unknown } | null)?.cause; - if (!cause) return message; - const causeMessage = - cause instanceof Error - ? cause.message - : (() => { - try { - return typeof cause === "object" ? JSON.stringify(cause) : String(cause); - } catch { - return String(cause); - } - })(); - return causeMessage && causeMessage !== message ? `${message}: ${causeMessage}` : message; -}; - -const recordWebhookReceived = (db: DbService, event: InsertWorkosWebhookEvent) => - db.insert(workosWebhookEvents).values(event); - -const findWebhookRecordByExternalId = (db: DbService, externalEventId: string) => - db.query.workosWebhookEvents.findFirst({ - where: { externalEventId }, - }); - -const refreshWebhookRecordForRetry = ( - db: DbService, - input: Pick & { readonly id: string }, -) => - db - .update(workosWebhookEvents) - .set({ - error: null, - eventType: input.eventType, - payload: input.payload, - }) - .where(eq(workosWebhookEvents.id, input.id)); - -const markWebhookProcessed = (db: DbService, id: string) => - db - .update(workosWebhookEvents) - .set({ error: null, processedAt: new Date() }) - .where(eq(workosWebhookEvents.id, id)); - -const markWebhookError = (db: DbService, id: string, error: string) => - db.update(workosWebhookEvents).set({ error }).where(eq(workosWebhookEvents.id, id)); - -const isOrganizationEvent = ( - e: WorkosEvent, -): e is Extract => - e.event === "organization.created" || - e.event === "organization.updated" || - e.event === "organization.deleted"; - -const isMembershipEvent = ( - e: WorkosEvent, -): e is Extract => - e.event === "organization_membership.created" || - e.event === "organization_membership.updated" || - e.event === "organization_membership.deleted"; - -const isUserEvent = (e: WorkosEvent): e is Extract => - e.event === "user.created" || e.event === "user.updated" || e.event === "user.deleted"; - -const upsertOrganizationFromWorkos = (db: DbService, org: WorkosOrganization) => - Effect.gen(function* () { - const existing = yield* db.query.organization.findFirst({ - where: { workosOrganizationId: org.id }, - }); - if (existing) { - yield* db - .update(organization) - .set({ name: org.name }) - .where(eq(organization.workosOrganizationId, org.id)); - return; - } - - // `externalId` carries the local id we stamped on WorkOS at create time. - // Re-link a row that already exists under that id (a direct existence - // check, not a length heuristic); never adopt the external id as a fresh - // primary key, so a foreign external id can't leak into ours. - if (org.externalId) { - const existingByExternalId = yield* db.query.organization.findFirst({ - where: { id: org.externalId }, - }); - if (existingByExternalId) { - yield* db - .update(organization) - .set({ name: org.name, workosOrganizationId: org.id }) - .where(eq(organization.id, existingByExternalId.id)); - return; - } - } - - // We weren't the creator (e.g., org created in the WorkOS Admin Portal - // directly). Mint a local row from scratch with our own generated id. - const newOrg: InsertOrganization = { - createdAt: new Date(), - id: generateId("organization"), - logo: null, - metadata: null, - name: org.name, - slug: `${(org.externalId ?? org.id).slice(0, 12)}-${crypto.randomUUID().slice(0, 6)}`, - workosOrganizationId: org.id, - }; - yield* db.insert(organization).values(newOrg); - }); - -const deleteOrganizationByWorkosId = (db: DbService, workosOrganizationId: string) => - db.delete(organization).where(eq(organization.workosOrganizationId, workosOrganizationId)); - -const deleteUserByWorkosUser = (db: DbService, workosUser: WorkosUser) => - Effect.gen(function* () { - const existing = yield* db.query.user.findFirst({ - where: { workosUserId: workosUser.id }, - }); - if (existing) { - yield* db.delete(user).where(eq(user.id, existing.id)); - return; - } - yield* db.delete(user).where(eq(user.email, workosUser.email)); - }); - -const processEvent = ( - db: DbService, - workosAuth: WorkosShape, - localUserSessions: LocalUserSessionShape, - membershipWebhooks: MembershipWebhookShape, - event: WorkosEvent, -): Effect.Effect => { - if (isOrganizationEvent(event)) { - if (event.event === "organization.deleted") { - return deleteOrganizationByWorkosId(db, event.data.id); - } - return upsertOrganizationFromWorkos(db, event.data); - } - if (isMembershipEvent(event)) { - if (event.event === "organization_membership.deleted") { - return membershipWebhooks.processEvent({ - _tag: "Delete", - externalMembershipId: event.data.id, - }); - } - const role = - typeof event.data.role === "string" - ? event.data.role - : (event.data.role?.slug ?? "member"); - return membershipWebhooks.processEvent({ - _tag: "Upsert", - membership: { - externalId: event.data.id, - externalOrganizationId: event.data.organizationId, - externalUserId: event.data.userId, - role, - }, - }); - } - if (isUserEvent(event)) { - if (event.event === "user.deleted") { - return deleteUserByWorkosUser(db, event.data); - } - return localUserSessions.resolveLocalUser(event.data).pipe(Effect.asVoid); - } - return Effect.void; -}; - -const handleWebhook = Effect.gen(function* () { - const db = yield* Db; - const workosAuth = yield* Workos; - const localUserSessions = yield* LocalUserSessionService; - const membershipWebhooks = yield* OrganizationMembershipWebhookPort; - const request = yield* HttpServerRequest.HttpServerRequest; - - const rawBody = yield* request.text; - const signatureHeader = request.headers["workos-signature"] ?? ""; - - if (!signatureHeader) { - return yield* HttpServerResponse.json( - { error: "Missing workos-signature header" }, - { status: 400 }, - ); - } - - const event = yield* workosAuth.verifyWebhook({ rawBody, signatureHeader }).pipe( - Effect.catch((error) => { - // Surface the underlying SDK reason (e.g. "Signature hash does not match - // …", "Timestamp outside the tolerance zone") rather than the generic - // wrapper message, so signature/secret problems are diagnosable. - const detail = - error.cause instanceof Error ? error.cause.message : String(error.cause ?? error.message); - return Effect.logError(`WorkOS webhook signature verification failed: ${detail}`).pipe( - Effect.andThen(Effect.fail(new WebhookProcessingError(detail))), - ); - }), - ); - - const rowId = generateId("workosWebhookEvent"); - const insertResult = yield* Effect.result( - recordWebhookReceived(db, { - createdAt: new Date(), - eventType: event.event, - externalEventId: event.id, - id: rowId, - payload: event as unknown as object, - }), - ); - - let eventRowId: string = rowId; - if (insertResult._tag === "Failure") { - const existing = yield* findWebhookRecordByExternalId(db, event.id); - if (!existing) { - const message = describeFailure(insertResult.failure); - return yield* Effect.fail(new WebhookProcessingError(message)); - } - - if (existing.processedAt) { - yield* Effect.logInfo(`WorkOS webhook ${event.id} already processed; skipping`); - return yield* HttpServerResponse.json({ received: true, duplicate: true }); - } - - eventRowId = existing.id; - yield* refreshWebhookRecordForRetry(db, { - eventType: event.event, - id: existing.id, - payload: event as unknown as object, - }); - } - - const processed = yield* Effect.result( - processEvent(db, workosAuth, localUserSessions, membershipWebhooks, event), - ); - if (processed._tag === "Failure") { - const message = describeFailure(processed.failure); - yield* markWebhookError(db, eventRowId, message.slice(0, 500)).pipe( - Effect.catch(() => Effect.void), - ); - return yield* Effect.fail(new WebhookProcessingError(message)); - } - - yield* markWebhookProcessed(db, eventRowId); - return yield* HttpServerResponse.json({ received: true }); -}); - -const registerWorkosWebhookRoute = Effect.gen(function* () { - const router = yield* HttpRouter.HttpRouter; - - yield* router.add( - "POST", - "/api/webhooks/workos", - handleWebhook.pipe( - // catchCause (not catch) so defects — a raw driver/crypto throw, not just - // the typed error channel — are logged with their full cause instead of - // escaping the worker as an opaque exception ("[object Object]"). - Effect.catchCause((cause) => - Effect.gen(function* () { - // Stringify the cause into the message — passing the cause object as a - // second arg renders as "[object Object]" in the Workers log pipeline. - yield* Effect.logError(`WorkOS webhook error: ${Cause.pretty(cause)}`); - return yield* HttpServerResponse.json( - { error: "Webhook processing failed" }, - { status: 500 }, - ); - }), - ), - ), - ); -}); - -export const WorkosWebhookRouteLayer = Layer.effectDiscard(registerWorkosWebhookRoute); diff --git a/selfhost/entry/src/main.ts b/apps/backend/src/server.ts similarity index 63% rename from selfhost/entry/src/main.ts rename to apps/backend/src/server.ts index a2c40c729..ad6cd70af 100644 --- a/selfhost/entry/src/main.ts +++ b/apps/backend/src/server.ts @@ -1,36 +1,40 @@ import { createServer } from "node:http"; -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { NodeHttpServer } from "@effect/platform-node"; import { EventCaptureApi } from "@voidhash/api-contracts/event-capture"; import { buildBackendFetch, buildBackendAgentServices, - NoBackendFeatures, - NoBackendRpcExtension, -} from "@voidhash/backend/src/BackendApp.ts"; -import { RpcAuthLive } from "@voidhash/backend/src/RpcMiddlewares.ts"; + type BackendFeatureComposition, + type BackendRpcExtension, + type BackendRuntimeLayers, +} from "@voidhash/backend/BackendApp"; +import type { McpOAuth } from "@voidhash/backend/McpOAuth"; +import { RpcAuthLive } from "@voidhash/backend/RpcMiddlewares"; import { AuthTokenVerifier } from "@voidhash/core/services/auth/AuthTokenVerifier"; import { EventCaptureService } from "@voidhash/core/services/analyticsIngest/EventCaptureService"; +import { AnalyticsDispatchService } from "@voidhash/core/services/analyticsIngest/AnalyticsDispatchService"; import { PushDeliveryDispatch } from "@voidhash/core/services/notifications/PushDeliveryDispatch"; import { PaywallThumbnailService } from "@voidhash/core/services/paywallThumbnails/PaywallThumbnailService"; +import { backendWorkflows } from "@voidhash/core/workflows/registry"; +import { Db } from "@voidhash/db"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import { getConfig as getMimicConfig } from "@voidhash/mimic-db/config"; import { makeRoutesLive } from "@voidhash/mimic-db/http/rpc-app"; -import { DurableEntityHost } from "@orbian/sdk/DurableEntity"; -import { NodeDurableEntityControl } from "@orbian/node/DurableEntity"; -import { SmtpMailerLive } from "@orbian/node/Mailer"; +import { DurableEntityAlarmControl, DurableEntityHost } from "@voidhash/platform/DurableEntity"; +import { SmtpMailerLive } from "@voidhash/platform-selfhost/Mailer"; import { Context, Effect, Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; +import type * as Rpc from "effect/unstable/rpc/Rpc"; -import { EventCaptureGroupLive } from "@voidhash/backend/src/routes/event-capture.ts"; +import { EventCaptureGroupLive } from "@voidhash/backend/routes/event-capture"; import { makeSelfhostAnalyticsRuntimeLive, runSelfhostAnalyticsConsumers, } from "./backend/Analytics.ts"; -import { WorkosAuthTokenVerifierLive } from "./backend/AuthTokenVerifier.ts"; import { runSelfhostCronJobs } from "./backend/Background.ts"; -import { makeBackendInfrastructureLive, makeSelfhostWorkosLive } from "./backend/Backend.ts"; +import { makeBackendInfrastructureLive, makeSelfhostAuthLayers } from "./backend/Backend.ts"; import { makeSelfhostClickhouseLayers } from "./backend/Clickhouse.ts"; import { runSelfhostPushDeliveryConsumers, @@ -41,11 +45,8 @@ import { makeSelfhostSnapshotImageRendererLive, runSelfhostPaywallThumbnailConsumer, } from "./backend/Thumbnails.ts"; -import { - makeSelfhostWorkflowRuntimeLive, - registerSelfhostWorkflows, -} from "./backend/WorkflowPorts.ts"; -import { getSelfhostRuntimeConfig } from "./config.ts"; +import { makeSelfhostPlatformLayers } from "./backend/PlatformProfile.ts"; +import { getSelfhostRuntimeConfig, type SelfhostRuntimeConfig } from "./config.ts"; import { installAgentNodeWebSocketServer } from "./agent/AgentNodeWebSocket.ts"; import { makeSelfhostMimicDocumentIdlePublisher } from "./mimic/MimicDocumentIdleQueue.ts"; import { installMimicNodeWebSocketServer } from "./mimic/MimicNodeWebSocket.ts"; @@ -63,18 +64,84 @@ const isCaptureRequest = (url: string | undefined): boolean => { return pathname === "/i" || pathname.startsWith("/i/"); }; -NodeRuntime.runMain( +/** + * The runtime values a composition root can only obtain from inside the server + * boot sequence, handed to the option factories that need them. + */ +export interface SelfhostServerRuntime { + readonly authTokenVerifier: AuthTokenVerifier["Service"]; + readonly config: SelfhostRuntimeConfig; +} + +/** + * Everything a composition root chooses about a self-host process. + * + * The Community entrypoint passes the core-only defaults; a private + * distribution passes its own feature bundle, admin RPC surface, identity + * directory, webhook routes, and MCP authorization server. No application code + * differs between the two — only the values below. + */ +export interface SelfhostServerOptions< + RFeatureRpcs extends Rpc.Any = never, + RFeatureServices = never, + RExtensionRpcs extends Rpc.Any = never, + RIdentityDirectory = never, +> { + /** Edition name logged once at boot, e.g. `"Community Edition"`. */ + readonly edition: string; + readonly features: BackendFeatureComposition; + /** + * Built after the identity layers resolve, because an admin RPC surface + * typically authenticates against the same token verifier the transport uses. + */ + readonly rpcExtension: (runtime: SelfhostServerRuntime) => BackendRpcExtension; + /** + * External directory merged into the infrastructure graph so raw routes and + * feature ports can resolve it at request time. Self-host's own identity + * provider is always the standalone one; this is the *directory* a private + * composition projects users and organizations from. + */ + readonly identityDirectory?: Layer.Layer; + readonly routeExtension?: BackendRuntimeLayers["routeExtension"]; + readonly mcpOAuth?: Layer.Layer; +} + +/** + * Boots the single-process self-host runtime: HTTP transport, mimic document + * host, analytics capture, background consumers, and WebSocket upgrades. + * + * Runs until interrupted; the returned effect owns every resource in its scope. + */ +export const runSelfhostServer = < + RFeatureRpcs extends Rpc.Any = never, + RFeatureServices = never, + RExtensionRpcs extends Rpc.Any = never, + RIdentityDirectory = never, +>( + options: SelfhostServerOptions< + RFeatureRpcs, + RFeatureServices, + RExtensionRpcs, + RIdentityDirectory + >, +) => Effect.scoped( Effect.gen(function* () { const config = getSelfhostRuntimeConfig(); - yield* Effect.logInfo("Community Edition active"); + yield* Effect.logInfo(`${options.edition} active`); const mimicConfig = getMimicConfig(); - const hostContext = yield* Layer.build(makeMimicNodeHostLive(getMimicNodeConfig())); + const platform = makeSelfhostPlatformLayers(config); + const hostContext = yield* Layer.build( + makeMimicNodeHostLive(getMimicNodeConfig(), platform.durableEntities), + ); const host = Context.get(hostContext, HostServiceTag); const entities = Context.get(hostContext, DurableEntityHost); - const entityControl = Context.get(hostContext, NodeDurableEntityControl); + const entityControl = Context.get(hostContext, DurableEntityAlarmControl); const hostLayer = Layer.succeed(HostServiceTag, host); - const workos = makeSelfhostWorkosLive(config.workos); + const authLayers = makeSelfhostAuthLayers(config.auth); + yield* Effect.logInfo( + `Identity provider: standalone (root user ${config.auth.rootUsername})`, + ); const clickhouse = config.clickhouse ? makeSelfhostClickhouseLayers(config.clickhouse) : undefined; @@ -85,19 +152,21 @@ NodeRuntime.runMain( executablePath: chromiumExecutablePath, } : undefined; - const infrastructure = makeBackendInfrastructureLive( - config, - workos, - clickhouse?.readOnly, - chromiumConfig === undefined - ? undefined - : makeSelfhostSnapshotImageRendererLive(chromiumConfig), + const infrastructure = Layer.mergeAll( + makeBackendInfrastructureLive( + config, + authLayers.identity, + clickhouse?.readOnly, + chromiumConfig === undefined + ? undefined + : makeSelfhostSnapshotImageRendererLive(chromiumConfig), + ), + options.identityDirectory ?? Layer.empty, ).pipe(Layer.provide(hostLayer)); - const authContext = yield* Layer.build( - WorkosAuthTokenVerifierLive.pipe(Layer.provide(workos)), - ); + const authContext = yield* Layer.build(authLayers.authTokenVerifier); const authTokenVerifier = Context.get(authContext, AuthTokenVerifier); - const workflowRuntime = makeSelfhostWorkflowRuntimeLive(config); + const rpcExtension = options.rpcExtension({ authTokenVerifier, config }); + const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); const analyticsRuntime = makeSelfhostAnalyticsRuntimeLive(config); const runtimeContext = yield* Layer.build( Layer.mergeAll( @@ -120,14 +189,26 @@ NodeRuntime.runMain( const agentServices = yield* Layer.build( Layer.mergeAll( buildBackendAgentServices({ - features: NoBackendFeatures, + features: options.features, infrastructure, pushDeliveryDispatch, + ...(options.mcpOAuth === undefined ? {} : { mcpOAuth: options.mcpOAuth }), }), infrastructure, ), ); - yield* registerSelfhostWorkflows(config).pipe(Effect.provide(runtimeContext)); + const workflowInfra = Layer.mergeAll( + Db.layer(config.database), + Layer.succeed( + AnalyticsDispatchService, + Context.get(runtimeContext, AnalyticsDispatchService), + ), + ); + yield* Effect.forEach( + backendWorkflows, + (registration) => registration.register(workflowInfra), + { discard: true }, + ).pipe(Effect.provide(runtimeContext), Effect.orDie); yield* Effect.forkScoped( runSelfhostAnalyticsConsumers(config, clickhouse?.readWrite).pipe( Effect.provide(runtimeContext), @@ -137,7 +218,7 @@ NodeRuntime.runMain( runSelfhostPushDeliveryConsumers(config).pipe(Effect.provide(runtimeContext)), ); yield* Effect.forkScoped( - runSelfhostCronJobs(config, clickhouse?.readWrite).pipe(Effect.provide(runtimeContext)), + runSelfhostCronJobs(clickhouse?.readWrite).pipe(Effect.provide(runtimeContext)), ); if (chromiumConfig !== undefined) { const thumbnailContext = yield* Layer.build( @@ -158,11 +239,13 @@ NodeRuntime.runMain( const backendEffect = yield* buildBackendFetch({ auth: RpcAuthLive(authTokenVerifier), - features: NoBackendFeatures, - rpcExtension: NoBackendRpcExtension, + features: options.features, + rpcExtension, infrastructure, ...(clickhouse === undefined ? {} : { analyticsQueryClient: clickhouse.analyticsQuery }), pushDeliveryDispatch, + ...(options.routeExtension === undefined ? {} : { routeExtension: options.routeExtension }), + ...(options.mcpOAuth === undefined ? {} : { mcpOAuth: options.mcpOAuth }), }).pipe(Effect.provide(runtimeContext)); const mimicEffect = yield* makeRoutesLive(hostLayer).pipe( Layer.provide(NodeHttpServer.layerHttpServices), @@ -260,5 +343,4 @@ NodeRuntime.runMain( yield* Effect.logInfo(`Self-host runtime listening on ${config.publicBaseUrl}`); yield* Effect.never; }), - ) as never, -); + ); diff --git a/selfhost/entry/src/www/Www.ts b/apps/backend/src/www/Www.ts similarity index 100% rename from selfhost/entry/src/www/Www.ts rename to apps/backend/src/www/Www.ts diff --git a/selfhost/entry/tests/AgentNodeWebSocket.integration.test.ts b/apps/backend/tests/AgentNodeWebSocket.integration.test.ts similarity index 93% rename from selfhost/entry/tests/AgentNodeWebSocket.integration.test.ts rename to apps/backend/tests/AgentNodeWebSocket.integration.test.ts index 454a884f5..6ae5feb6d 100644 --- a/selfhost/entry/tests/AgentNodeWebSocket.integration.test.ts +++ b/apps/backend/tests/AgentNodeWebSocket.integration.test.ts @@ -2,13 +2,13 @@ import { createServer, type Server } from "node:http"; import { AgentSessionIndexService, + IdentityProvider, LocalUserSessionService, PaywallService, PaywallWorkspaceService, - Workos, } from "@voidhash/core/services"; import { Db } from "@voidhash/db"; -import { makeMemoryDurableEntityHost } from "@orbian/node/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; import { Context, Effect, Redacted } from "effect"; import { WebSocket } from "ws"; import { afterEach, describe, expect, it } from "vite-plus/test"; @@ -89,13 +89,19 @@ const authSession = { }, }; +const identity = { + email: "user@example.com", + emailVerified: true, + externalId: "user_1", + firstName: "Probe", + id: "workos_user_1", + lastName: "user", + profilePictureUrl: null, +}; + const makeServices = () => { let context = Context.empty() as Context.Context; context = Context.add(context, Db, {} as never); - context = Context.add(context, Workos, { - getUser: () => Effect.succeed({ id: "workos_user_1" }), - setUserExternalId: () => Effect.void, - } as unknown as Workos["Service"]); context = Context.add(context, LocalUserSessionService, { resolveLocalUser: () => Effect.succeed(authSession.user), loadUserAccess: () => @@ -105,6 +111,13 @@ const makeServices = () => { }), 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"]); diff --git a/selfhost/entry/tests/AgentNodeWebSocket.test.ts b/apps/backend/tests/AgentNodeWebSocket.test.ts similarity index 100% rename from selfhost/entry/tests/AgentNodeWebSocket.test.ts rename to apps/backend/tests/AgentNodeWebSocket.test.ts diff --git a/selfhost/entry/tests/Analytics.integration.test.ts b/apps/backend/tests/Analytics.integration.test.ts similarity index 84% rename from selfhost/entry/tests/Analytics.integration.test.ts rename to apps/backend/tests/Analytics.integration.test.ts index 8c007d1cf..638302379 100644 --- a/selfhost/entry/tests/Analytics.integration.test.ts +++ b/apps/backend/tests/Analytics.integration.test.ts @@ -17,9 +17,7 @@ import { } from "../src/backend/Analytics.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const describePg = process.env.SELFHOST_PG_TEST === "1" ? describe : describe.skip; - -describePg("self-host analytics queue", () => { +describe("self-host analytics queue", () => { it("captures, processes, and acknowledges an event without ClickHouse", async () => { const config = getSelfhostRuntimeConfig(); const suffix = crypto.randomUUID(); @@ -92,11 +90,19 @@ describePg("self-host analytics queue", () => { 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(sql` - DELETE FROM platform_queue_messages - WHERE body_json -> 'envelope' ->> 'projectId' = ${projectId} + DELETE FROM effect_queue + WHERE (element::jsonb #>> '{}')::jsonb -> 'envelope' ->> 'projectId' = ${projectId} `); - }).pipe(Effect.provide(database)), + }).pipe(Effect.provide(Db.layer(config.platformDatabase))), ); } diff --git a/selfhost/entry/tests/BackendAdapters.test.ts b/apps/backend/tests/BackendAdapters.test.ts similarity index 69% rename from selfhost/entry/tests/BackendAdapters.test.ts rename to apps/backend/tests/BackendAdapters.test.ts index 432de8d32..b60c77e17 100644 --- a/selfhost/entry/tests/BackendAdapters.test.ts +++ b/apps/backend/tests/BackendAdapters.test.ts @@ -3,7 +3,7 @@ import { Effect, Redacted } from "effect"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { MemoryProjectSchemaCacheLive } from "../src/backend/ProjectSchemaCache.ts"; -import { getSelfhostRuntimeConfig } from "../src/config.ts"; +import { getSelfhostMigrationDatabaseConfig, getSelfhostRuntimeConfig } from "../src/config.ts"; const originalEnvironment = { ...process.env }; @@ -55,7 +55,7 @@ describe("self-host runtime configuration", () => { port: 1025, verifyOnStart: false, }); - expect(config.workos.clientId).toBe("client_selfhost_not_configured"); + expect(config.auth.rootUsername).toBe("root"); }); it("reads BYO agent provider and model settings", () => { @@ -105,12 +105,26 @@ describe("self-host runtime configuration", () => { expect(Redacted.value(mailer.password!)).toBe("secret"); }); - it("requires BYO WorkOS credentials in production", () => { + it("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"; + + const auth = getSelfhostRuntimeConfig().auth; + + expect(auth.rootUsername).toBe("operator"); + expect(auth.rootEmail).toBe("root@voidhash.local"); + expect(Redacted.value(auth.rootPassword)).toBe("a-real-root-password"); + }); + + it("names every unconfigured standalone credential when production starts", () => { process.env.NODE_ENV = "production"; process.env.SELFHOST_MODE = "production"; - delete process.env.WORKOS_API_KEY; + delete process.env.VOIDHASH_ROOT_USERNAME; + delete process.env.VOIDHASH_ROOT_PASSWORD; + delete process.env.VOIDHASH_AUTH_SECRET; - expect(() => getSelfhostRuntimeConfig()).toThrow("WORKOS_API_KEY"); + expect(() => getSelfhostRuntimeConfig()).toThrow(/VOIDHASH_ROOT_USERNAME/); }); it("supports an explicit plaintext connection for an internal Compose database", () => { @@ -120,6 +134,47 @@ describe("self-host runtime configuration", () => { expect(getSelfhostRuntimeConfig().database).toMatchObject({ host: "postgres", ssl: false }); }); + it("falls back to the application connection for migrations", () => { + process.env.DATABASE_HOST = "postgres"; + process.env.DATABASE_PORT = "6543"; + process.env.DATABASE_NAME = "voidhash"; + process.env.DATABASE_USERNAME = "voidhash"; + process.env.DATABASE_PASSWORD = "application-secret"; + process.env.DATABASE_SSL = "false"; + delete process.env.DATABASE_DIRECT_HOST; + + expect(getSelfhostMigrationDatabaseConfig()).toEqual({ + databaseName: "voidhash", + host: "postgres", + password: "application-secret", + port: 6543, + ssl: false, + username: "voidhash", + }); + }); + + it("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"; + process.env.DATABASE_USERNAME = "voidhash"; + process.env.DATABASE_PASSWORD = "application-secret"; + process.env.DATABASE_SSL = "false"; + process.env.DATABASE_DIRECT_HOST = "postgres"; + process.env.DATABASE_DIRECT_PORT = "6543"; + process.env.DATABASE_DIRECT_SSL = "true"; + + expect(getSelfhostRuntimeConfig().database.host).toBe("broker.internal.local"); + expect(getSelfhostMigrationDatabaseConfig()).toEqual({ + databaseName: "voidhash", + host: "postgres", + password: "application-secret", + port: 6543, + ssl: true, + username: "voidhash", + }); + }); + it("enables ClickHouse only when its URL is configured", () => { process.env.CLICKHOUSE_URL = "http://clickhouse:8123"; process.env.CLICKHOUSE_DATABASE = "analytics"; diff --git a/apps/backend/tests/Background.integration.test.ts b/apps/backend/tests/Background.integration.test.ts new file mode 100644 index 000000000..249500c6d --- /dev/null +++ b/apps/backend/tests/Background.integration.test.ts @@ -0,0 +1,85 @@ +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 { 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 = [ + "AppStoreExpireParkedNotificationsWorkflow", + "PurchaseLedgerDrainWorkflow", +] as const; + +const twoDaysMillis = 2 * 24 * 60 * 60 * 1000; + +/** + * Drives a job through the {@link CronScheduler} port and reports how many times + * its body executed. + * + * The job runs under a probe name so the slot state the adapter persists cannot + * collide with the schedule a running deployment owns. Both adapters arm an + * unseen slot on first sight rather than firing it, so the first tick arms and + * the second one — far enough ahead that every enabled expression is due — + * claims and runs it. + * + * The probe name is unique per run because both adapters persist slot progress + * durably. Reusing one name would let the first run's recorded position survive: + * every later run ticks at or behind it, nothing is ever due again, and the test + * would pass exactly once per database and fail from then on. + */ +const runThroughScheduler = (job: CronJob) => + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + let executions = 0; + const probe: CronJob = { + ...job, + name: `${job.name}-probe-${crypto.randomUUID()}`, + run: (context) => + job.run(context).pipe( + Effect.tap(() => + Effect.sync(() => { + executions += 1; + }), + ), + ), + }; + const now = Date.now(); + yield* scheduler.tick(probe, new Date(now)); + yield* scheduler.tick(probe, new Date(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(); + + 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())), + ), + ), + ); + + expect(outcome.registered).toEqual(expect.arrayContaining([...requiredJobNames])); + for (const name of requiredJobNames) { + expect(outcome.executions[name]).toBeGreaterThanOrEqual(1); + } + }); +}); diff --git a/selfhost/entry/tests/Clickhouse.integration.test.ts b/apps/backend/tests/Clickhouse.integration.test.ts similarity index 91% rename from selfhost/entry/tests/Clickhouse.integration.test.ts rename to apps/backend/tests/Clickhouse.integration.test.ts index ca117ce1b..7861e217c 100644 --- a/selfhost/entry/tests/Clickhouse.integration.test.ts +++ b/apps/backend/tests/Clickhouse.integration.test.ts @@ -29,9 +29,6 @@ import { } from "../src/backend/Clickhouse.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const describeClickhouse = - process.env.SELFHOST_CLICKHOUSE_TEST === "1" ? describe : describe.skip; - const analyticsTables = [ CLICKHOUSE_EVENTS_TABLE, CLICKHOUSE_PERSONS_TABLE, @@ -60,7 +57,7 @@ const countEvents = ( }).pipe(Effect.provide(layer), Effect.scoped), ); -describeClickhouse("self-host ClickHouse analytics", () => { +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"); @@ -167,11 +164,19 @@ describeClickhouse("self-host ClickHouse analytics", () => { 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 platform_queue_messages - WHERE body_json -> 'envelope' ->> 'projectId' = ${projectId} + DELETE FROM effect_queue + WHERE (element::jsonb #>> '{}')::jsonb -> 'envelope' ->> 'projectId' = ${projectId} `); - }).pipe(Effect.provide(database)), + }).pipe(Effect.provide(Db.layer(config.platformDatabase))), ); } }, 30_000); diff --git a/selfhost/entry/tests/Compiler.test.ts b/apps/backend/tests/Compiler.test.ts similarity index 100% rename from selfhost/entry/tests/Compiler.test.ts rename to apps/backend/tests/Compiler.test.ts diff --git a/selfhost/entry/tests/CompilerClient.integration.test.ts b/apps/backend/tests/CompilerClient.integration.test.ts similarity index 80% rename from selfhost/entry/tests/CompilerClient.integration.test.ts rename to apps/backend/tests/CompilerClient.integration.test.ts index 9e15f8cd0..47470e025 100644 --- a/selfhost/entry/tests/CompilerClient.integration.test.ts +++ b/apps/backend/tests/CompilerClient.integration.test.ts @@ -4,10 +4,11 @@ import { describe, expect, it } from "vitest"; import { makeHttpComponentCompilerLive } from "../src/compiler/CompilerClient.ts"; -const compilerUrl = process.env.SELFHOST_COMPILER_URL; -const describeCompiler = compilerUrl ? describe : describe.skip; +// 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"; -describeCompiler("self-host component compiler client", () => { +describe("self-host component compiler client", () => { it("round-trips compile and extraction results through HTTP", async () => { const result = await Effect.runPromise( Effect.gen(function* () { diff --git a/selfhost/entry/tests/MimicDocumentIdle.test.ts b/apps/backend/tests/MimicDocumentIdle.test.ts similarity index 92% rename from selfhost/entry/tests/MimicDocumentIdle.test.ts rename to apps/backend/tests/MimicDocumentIdle.test.ts index e11b14d6f..ee03d0983 100644 --- a/selfhost/entry/tests/MimicDocumentIdle.test.ts +++ b/apps/backend/tests/MimicDocumentIdle.test.ts @@ -3,9 +3,11 @@ import { IDLE_NOTIFIED_SEQ_KEY, type MimicDocumentIdleMessageType, } from "@voidhash/mimic-db/ws/idle-notify"; -import { makeDurableEntityAddress } from "@orbian/sdk/DurableEntity"; -import type { NodeDurableEntityControlShape } from "@orbian/node/DurableEntity"; -import { makeMemoryDurableEntityHost } from "@orbian/node/MemoryDurableEntity"; +import { + type DurableEntityAlarmControlShape, + makeDurableEntityAddress, +} from "@voidhash/platform/DurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; import { Effect } from "effect"; import { describe, expect, it, vi } from "vitest"; @@ -16,7 +18,7 @@ const address = makeDurableEntityAddress( "collection-1:document-1", ); -const control: NodeDurableEntityControlShape = { +const control: DurableEntityAlarmControlShape = { listDueAlarms: () => Effect.succeed([{ address, scheduledTime: 0 }]), }; diff --git a/selfhost/entry/tests/MimicNode.integration.test.ts b/apps/backend/tests/MimicNode.integration.test.ts similarity index 82% rename from selfhost/entry/tests/MimicNode.integration.test.ts rename to apps/backend/tests/MimicNode.integration.test.ts index 06d7f7f5b..e47667437 100644 --- a/selfhost/entry/tests/MimicNode.integration.test.ts +++ b/apps/backend/tests/MimicNode.integration.test.ts @@ -4,10 +4,12 @@ import type { AddressInfo } from "node:net"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import type { SchemaObject, Value } from "@voidhash/mimic-core"; import { + DurableEntityAlarmControl, DurableEntityHost, makeDurableEntityAddress, -} from "@orbian/sdk/DurableEntity"; -import { NodeDurableEntityControl } from "@orbian/node/DurableEntity"; +} 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 { describe, expect, it } from "vitest"; import WebSocket from "ws"; @@ -32,6 +34,18 @@ const config: MimicNodeConfig = { }, }; +// 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 schema: SchemaObject = { kind: "object", fields: { @@ -43,17 +57,20 @@ const value: Value = { fields: { title: { kind: "string", value: "persistent" } }, }; -const describePg = process.env.SELFHOST_PG_TEST === "1" ? describe : describe.skip; +// Every build owns its own single-node cluster, which is what makes the +// restart assertions meaningful: nothing process-local carries over. +const hostLayer = () => + makeMimicNodeHostLive(config, PgClusterDurableEntityLive(platformConfig)); const runHost = (program: Effect.Effect): Promise => Effect.runPromise( - program.pipe(Effect.provide(makeMimicNodeHostLive(config))) as Effect.Effect, + Effect.scoped(program.pipe(Effect.provide(hostLayer()))) as Effect.Effect, ); const runStandalone = (program: Effect.Effect): Promise => Effect.runPromise(program as Effect.Effect); -describePg("self-host mimic Node composition", () => { +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( @@ -86,10 +103,10 @@ describePg("self-host mimic Node composition", () => { }); it("serves the document auth and snapshot protocol over a real Node WebSocket", async () => { - const runtime = ManagedRuntime.make(makeMimicNodeHostLive(config)); + const runtime = ManagedRuntime.make(hostLayer()); const host = await runtime.runPromise(HostServiceTag); const entities = await runtime.runPromise(DurableEntityHost); - const entityControl = await runtime.runPromise(NodeDurableEntityControl); + const entityControl = await runtime.runPromise(DurableEntityAlarmControl); const server = createServer(); const closeWebSockets = installMimicNodeWebSocketServer(server, host, entities, { control: entityControl, diff --git a/apps/backend/tests/MimicNodeWebSocket.test.ts b/apps/backend/tests/MimicNodeWebSocket.test.ts new file mode 100644 index 000000000..7d6fed53a --- /dev/null +++ b/apps/backend/tests/MimicNodeWebSocket.test.ts @@ -0,0 +1,106 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +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 WebSocket from "ws"; + +import { installMimicNodeWebSocketServer } from "../src/mimic/MimicNodeWebSocket.ts"; + +const collectionId = "collection-1"; +const documentId = "document-1"; + +/** + * 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. + */ +const stubHost = { + authenticateDocumentToken: () => + Effect.succeed({ tokenId: "token-1", permission: "write" as const }), + getDocument: () => + Effect.succeed({ + value: { kind: "object" as const, fields: {} }, + version: 1, + }), + getPresenceSnapshot: () => Effect.succeed({ presences: {} }), + setPresence: () => Effect.void, + removePresence: () => Effect.void, +} as unknown as HostService; + +const cleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +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); + }); + }); + +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(); + } + }); + }); + + 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), + ), + ), + ), + ); + + // 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"); + }); +}); diff --git a/selfhost/entry/tests/PaywallRelease.integration.test.ts b/apps/backend/tests/PaywallRelease.integration.test.ts similarity index 97% rename from selfhost/entry/tests/PaywallRelease.integration.test.ts rename to apps/backend/tests/PaywallRelease.integration.test.ts index b023608b3..0a130b3cf 100644 --- a/selfhost/entry/tests/PaywallRelease.integration.test.ts +++ b/apps/backend/tests/PaywallRelease.integration.test.ts @@ -14,8 +14,6 @@ import { describe, expect, it } from "vitest"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const describePg = process.env.SELFHOST_PG_TEST === "1" ? describe : describe.skip; - const makeSession = (projectId: string, userId: string): AnyAuthSession => { const now = new Date(); return { @@ -48,7 +46,7 @@ const makeSession = (projectId: string, userId: string): AnyAuthSession => { }; }; -describePg("self-host paywall releases", () => { +describe("self-host paywall releases", () => { it("creates, publishes, and advances a visual paywall release", async () => { const config = getSelfhostRuntimeConfig(); const suffix = crypto.randomUUID(); diff --git a/selfhost/entry/tests/Push.integration.test.ts b/apps/backend/tests/Push.integration.test.ts similarity index 65% rename from selfhost/entry/tests/Push.integration.test.ts rename to apps/backend/tests/Push.integration.test.ts index 4465d7c22..e60eb2a02 100644 --- a/selfhost/entry/tests/Push.integration.test.ts +++ b/apps/backend/tests/Push.integration.test.ts @@ -10,21 +10,24 @@ import { } from "../src/backend/Push.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const describePg = process.env.SELFHOST_PG_TEST === "1" ? describe : describe.skip; - -describePg("self-host push-delivery queue", () => { +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()}`; - const database = Db.layer(config.database); const remaining = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const db = yield* Db; + // 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 platform_queue_messages - WHERE body_json ->> 'pushNotificationDeliveryId' = ${deliveryId} + DELETE FROM effect_queue + WHERE (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} `); const dispatchContext = yield* Layer.build(SelfhostPushDeliveryDispatchLive); const dispatch = Context.get(dispatchContext, PushDeliveryDispatch); @@ -42,18 +45,16 @@ describePg("self-host push-delivery queue", () => { while (Date.now() < deadline) { const rows = yield* db.execute(sql` SELECT COUNT(*)::integer AS total - FROM platform_queue_messages - WHERE body_json ->> 'pushNotificationDeliveryId' = ${deliveryId} + FROM effect_queue + WHERE completed = FALSE + AND (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(database), - Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), - ), + }).pipe(Effect.provide(makeSelfhostAnalyticsRuntimeLive(config))), ), ); diff --git a/selfhost/entry/tests/SecurityConfig.test.ts b/apps/backend/tests/SecurityConfig.test.ts similarity index 80% rename from selfhost/entry/tests/SecurityConfig.test.ts rename to apps/backend/tests/SecurityConfig.test.ts index 6f42b866b..8476f862d 100644 --- a/selfhost/entry/tests/SecurityConfig.test.ts +++ b/apps/backend/tests/SecurityConfig.test.ts @@ -12,11 +12,9 @@ const validProductionEnvironment = { PUBLIC_FILES_BASE_URL: "https://files.example.test", S3_SECRET_ACCESS_KEY: "object-store-secret", SELFHOST_MODE: "production", - WORKOS_API_KEY: "configured-workos-key", - WORKOS_CLIENT_ID: "client_configured", - WORKOS_COOKIE_PASSWORD: "cookie-secret-with-sufficient-entropy", - WORKOS_REDIRECT_URI: "https://app.example.test/api/auth/callback", - WORKOS_WEBHOOK_SECRET: "whsec_configured", + 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) => { @@ -55,20 +53,24 @@ describe("validateSelfhostSecurityConfig", () => { "DATABASE_PASSWORD", "MIMIC_ROOT_PASSWORD", "S3_SECRET_ACCESS_KEY", - "WORKOS_API_KEY", - "WORKOS_CLIENT_ID", - "WORKOS_COOKIE_PASSWORD", - "WORKOS_WEBHOOK_SECRET", + "VOIDHASH_ROOT_PASSWORD", + "VOIDHASH_AUTH_SECRET", ])("rejects an absent or example %s in production mode", (name) => { stubEnvironment(validProductionEnvironment); vi.stubEnv(name, "password"); expect(() => validateSelfhostSecurityConfig()).toThrow(name); }); - it("rejects the local-evaluation cookie placeholder in production mode", () => { + it("rejects an unset root username in production mode", () => { stubEnvironment(validProductionEnvironment); - vi.stubEnv("WORKOS_COOKIE_PASSWORD", "selfhost-development-cookie-password-change-me"); - expect(() => validateSelfhostSecurityConfig()).toThrow("WORKOS_COOKIE_PASSWORD"); + vi.stubEnv("VOIDHASH_ROOT_USERNAME", ""); + expect(() => validateSelfhostSecurityConfig()).toThrow("VOIDHASH_ROOT_USERNAME"); + }); + + it("rejects a placeholder root password in production mode", () => { + stubEnvironment(validProductionEnvironment); + vi.stubEnv("VOIDHASH_ROOT_PASSWORD", "replace-with-a-random-password"); + expect(() => validateSelfhostSecurityConfig()).toThrow("VOIDHASH_ROOT_PASSWORD"); }); it("requires every enabled ClickHouse role to have a non-example password", () => { @@ -85,7 +87,6 @@ describe("validateSelfhostSecurityConfig", () => { "PUBLIC_BASE_URL", "PUBLIC_FILES_BASE_URL", "MIMIC_PUBLIC_BASE_URL", - "WORKOS_REDIRECT_URI", ])("rejects a non-HTTPS %s in production mode", (name) => { stubEnvironment(validProductionEnvironment); vi.stubEnv(name, "http://localhost:5001"); diff --git a/apps/backend/tests/StandaloneAuth.integration.test.ts b/apps/backend/tests/StandaloneAuth.integration.test.ts new file mode 100644 index 000000000..29f8d2ff5 --- /dev/null +++ b/apps/backend/tests/StandaloneAuth.integration.test.ts @@ -0,0 +1,130 @@ +import { resolveUserSession } from "@voidhash/backend/AuthSessionResolver"; +import { AuthTokenVerifier } from "@voidhash/core/services/auth/AuthTokenVerifier"; +import { LocalUserSessionService } from "@voidhash/core/services/auth/LocalUserSessionService"; +import { + STANDALONE_AUTH_COOKIE_NAME, + STANDALONE_ROOT_SUBJECT, + 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 * as HttpHeaders from "effect/unstable/http/Headers"; +import { afterAll, describe, expect, it } from "vitest"; + +import { makeSelfhostAuthLayers } from "../src/backend/Backend.ts"; +import { getSelfhostDatabaseConfig } from "../src/config.ts"; + +const secret = "integration-test-standalone-secret"; +const rootEmail = "standalone-auth-root@integration.test"; + +/** + * Builds the auth layers directly rather than through `getSelfhostAuthConfig`, + * so the suite does not depend on the ambient root credentials of the machine + * running it. + */ +const authLayers = makeSelfhostAuthLayers({ + rootEmail, + rootPassword: Redacted.make("integration-test-root-password"), + rootUsername: "root", + secret: Redacted.make(secret), +}); + +const database = Db.layer(getSelfhostDatabaseConfig()); + +const cleanup = Effect.gen(function* () { + const db = yield* Db; + yield* db.execute( + sql`DELETE FROM "user" WHERE email = ${rootEmail} OR workos_user_id = ${STANDALONE_ROOT_SUBJECT}`, + ); +}).pipe(Effect.provide(database), Effect.scoped); + +const resolve = (headers: Record) => + Effect.gen(function* () { + const verifierContext = yield* Layer.build(authLayers.authTokenVerifier); + const verifier = Context.get(verifierContext, AuthTokenVerifier); + return yield* resolveUserSession(HttpHeaders.fromInput(headers), verifier).pipe( + Effect.provide(authLayers.identity.pipe(Layer.provide(database))), + Effect.provide(LocalUserSessionService.layer), + Effect.provide(database), + ); + }).pipe(Effect.scoped, Effect.runPromise); + +const token = (email: string, name?: string) => + Effect.runPromise(signStandaloneAuthToken({ email, secret, ...(name ? { name } : {}) })); + +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" }), + ); + + await expect(resolve({ authorization: `Bearer ${forged}` })).rejects.toBeDefined(); + }); + + it("rejects a request with no credentials", async () => { + await expect(resolve({})).rejects.toBeDefined(); + }); + + it("adopts an existing row for the same email instead of creating a second user", async () => { + await Effect.runPromise(cleanup); + await Effect.runPromise( + Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(user).values({ + banned: false, + banExpires: null, + banReason: null, + createdAt: new Date(), + customImageUrl: null, + email: rootEmail, + emailVerified: true, + id: "user_standaloneadopt00000000", + image: null, + name: "Previously Provisioned", + role: null, + updatedAt: new Date(), + workosUserId: "user_external_previous", + }); + }).pipe(Effect.provide(database), Effect.scoped), + ); + + const session = await resolve({ authorization: `Bearer ${await token(rootEmail)}` }); + + expect(session.user?.id).toBe("user_standaloneadopt00000000"); + expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); + + const rows = await Effect.runPromise( + 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 new file mode 100644 index 000000000..dc8de8c22 --- /dev/null +++ b/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts @@ -0,0 +1,129 @@ +import { StandaloneOrgDirectoryLive } from "@voidhash/core/services/organizations/StandaloneOrgDirectory"; +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 { afterAll, describe, expect, it } from "vitest"; + +import { getSelfhostDatabaseConfig } from "../src/config.ts"; + +const database = Db.layer(getSelfhostDatabaseConfig()); + +const userId = generateId("user"); +const orgId = generateId("organization"); +const memberId = generateId("member"); +const email = "local-org-directory@integration.test"; + +const withServices = (effect: Effect.Effect) => + Effect.runPromise( + effect.pipe( + Effect.provide(StandaloneOrgDirectoryLive), + Effect.provide(database), + Effect.scoped, + ), + ); + +describe("local organization directory", () => { + afterAll(async () => { + await 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( + Effect.gen(function* () { + const port = yield* OrgDirectoryPort; + const db = yield* Db; + + yield* db.insert(user).values({ + banned: false, + banExpires: null, + banReason: null, + createdAt: new Date(), + customImageUrl: null, + email, + emailVerified: true, + id: userId, + image: null, + name: "Directory Dev", + role: null, + updatedAt: new Date(), + workosUserId: `local_${"a".repeat(24)}`, + }); + + const createdOrg = yield* port.createOrganization({ + externalId: orgId, + name: "Directory Org", + }); + expect(createdOrg.id).toBe(`local_org_${orgId}`); + // `organization.workos_organization_id` is varchar(64). + expect(createdOrg.id.length).toBeLessThanOrEqual(64); + + const membership = yield* port.createMembership({ + roleSlug: "admin", + workosOrganizationId: createdOrg.id, + workosUserId: `local_${"a".repeat(24)}`, + }); + expect(membership.id.startsWith("local_mem_")).toBe(true); + expect(membership.id.length).toBeLessThanOrEqual(64); + + // The real INSERTs OrganizationService performs — proof the synthesized + // ids actually satisfy the constraints. + yield* db.insert(organization).values({ + createdAt: new Date(), + id: orgId, + logo: null, + metadata: null, + name: "Directory Org", + slug: `directory-org-${orgId.slice(-8)}`, + workosOrganizationId: createdOrg.id, + }); + yield* db.insert(member).values({ + createdAt: new Date(), + id: memberId, + organizationId: orgId, + role: "admin", + userId, + workosMembershipId: membership.id, + }); + }), + ); + }); + + it("reads users and memberships back out of the local tables", async () => { + await withServices( + Effect.gen(function* () { + const port = yield* OrgDirectoryPort; + + const found = yield* port.findUserByEmail(email); + expect(found?.email).toBe(email); + expect(found?.firstName).toBe("Directory"); + expect(found?.lastName).toBe("Dev"); + expect(found?.externalId).toBe(userId); + + const memberships = yield* port.listMembershipsForUser(found?.id ?? ""); + expect(memberships).toHaveLength(1); + expect(memberships[0]?.organizationId).toBe(`local_org_${orgId}`); + expect(memberships[0]?.role).toBe("admin"); + + const org = yield* port.getOrganizationByExternalId(orgId); + expect(org?.name).toBe("Directory Org"); + }), + ); + }); + + it("returns null for an unknown email", async () => { + await withServices( + Effect.gen(function* () { + const port = yield* OrgDirectoryPort; + expect(yield* port.findUserByEmail("nobody@integration.test")).toBeNull(); + }), + ); + }); +}); diff --git a/selfhost/entry/tests/ThumbnailQueue.integration.test.ts b/apps/backend/tests/ThumbnailQueue.integration.test.ts similarity index 82% rename from selfhost/entry/tests/ThumbnailQueue.integration.test.ts rename to apps/backend/tests/ThumbnailQueue.integration.test.ts index 8b8f56589..50e49c15a 100644 --- a/selfhost/entry/tests/ThumbnailQueue.integration.test.ts +++ b/apps/backend/tests/ThumbnailQueue.integration.test.ts @@ -11,9 +11,7 @@ import { mimicDocumentIdleQueueName, } from "../src/mimic/MimicDocumentIdleQueue.ts"; -const describePg = process.env.SELFHOST_PG_TEST === "1" ? describe : describe.skip; - -describePg("self-host thumbnail queue", () => { +describe("self-host thumbnail queue", () => { it("delivers and acknowledges an idle-document revision", async () => { const config = getSelfhostRuntimeConfig(); const documentId = `thumbnail-${crypto.randomUUID()}`; @@ -50,12 +48,15 @@ describePg("self-host thumbnail queue", () => { 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 platform_queue_messages + DELETE FROM effect_queue WHERE queue_name = ${mimicDocumentIdleQueueName} - AND body_json ->> 'documentId' = ${documentId} + AND (element::jsonb #>> '{}')::jsonb ->> 'documentId' = ${documentId} `); - }).pipe(Effect.provide(Db.layer(config.database))), + }).pipe(Effect.provide(Db.layer(config.platformDatabase))), ); } diff --git a/selfhost/entry/tests/Thumbnails.test.ts b/apps/backend/tests/Thumbnails.test.ts similarity index 100% rename from selfhost/entry/tests/Thumbnails.test.ts rename to apps/backend/tests/Thumbnails.test.ts diff --git a/selfhost/entry/tests/WorkflowComposition.integration.test.ts b/apps/backend/tests/WorkflowComposition.integration.test.ts similarity index 65% rename from selfhost/entry/tests/WorkflowComposition.integration.test.ts rename to apps/backend/tests/WorkflowComposition.integration.test.ts index 30a6e2f56..55b3e1c06 100644 --- a/selfhost/entry/tests/WorkflowComposition.integration.test.ts +++ b/apps/backend/tests/WorkflowComposition.integration.test.ts @@ -1,7 +1,8 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; -import { WebhookDeliveryWorkflow } from "@voidhash/core/services/webhookDispatch/WebhookDeliveryWorkflow"; +import { DeliverWebhookRegistration } from "@voidhash/core/workflows/DeliverWebhook"; +import { DeliverWebhook } from "@voidhash/core/workflows/definitions"; import { Db, WebhookDeliveryStatus, @@ -11,19 +12,15 @@ import { webhookDeliveryAttempts, webhookEndpoints, } from "@voidhash/db"; -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; +import * as Workflow from "@voidhash/platform/Workflow"; import { describe, expect, it } from "vitest"; -import { - makeSelfhostWorkflowRuntimeLive, - registerSelfhostWorkflows, -} from "../src/backend/WorkflowPorts.ts"; +import { makeSelfhostPlatformLayers } from "../src/backend/PlatformProfile.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const describePg = process.env.SELFHOST_PG_TEST === "1" ? describe : describe.skip; - -describePg("self-host workflow composition", () => { - it("delivers a webhook through the durable Postgres runner", async () => { +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[] = []; @@ -44,6 +41,9 @@ describePg("self-host workflow composition", () => { 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); try { await Effect.runPromise( @@ -71,9 +71,8 @@ describePg("self-host workflow composition", () => { const attempts = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - yield* registerSelfhostWorkflows(config); - const delivery = yield* WebhookDeliveryWorkflow; - yield* delivery.dispatch({ + yield* DeliverWebhookRegistration.register(database); + yield* Workflow.execute(DeliverWebhook, { attemptNumber: 1, deliveryId, endpointId, @@ -97,10 +96,7 @@ describePg("self-host workflow composition", () => { yield* Effect.sleep("25 millis"); } return yield* Effect.die("webhook workflow timed out"); - }).pipe( - Effect.provide(database), - Effect.provide(makeSelfhostWorkflowRuntimeLive(config)), - ), + }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), ), ); @@ -116,32 +112,36 @@ describePg("self-host workflow composition", () => { .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` - DELETE FROM platform_workflow_activity - WHERE execution_id IN ( - SELECT execution_id FROM platform_workflow_execution - WHERE payload_json ->> 'deliveryId' = ${deliveryId} - ) - `); - yield* db.execute(sql` - DELETE FROM platform_workflow_deferred - WHERE execution_id IN ( - SELECT execution_id FROM platform_workflow_execution - WHERE payload_json ->> 'deliveryId' = ${deliveryId} + 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 ->> 'deliveryId' = ${deliveryId} + ) ) `); yield* db.execute(sql` - DELETE FROM platform_workflow_clock - WHERE execution_id IN ( - SELECT execution_id FROM platform_workflow_execution - WHERE payload_json ->> 'deliveryId' = ${deliveryId} + DELETE FROM cluster_messages + WHERE entity_id IN ( + SELECT entity_id FROM cluster_messages + WHERE tag = 'run' AND payload::jsonb ->> 'deliveryId' = ${deliveryId} ) `); - yield* db.execute(sql` - DELETE FROM platform_workflow_execution - WHERE payload_json ->> 'deliveryId' = ${deliveryId} - `); - }).pipe(Effect.provide(database)), + }).pipe(Effect.provide(platformDatabase)), ); await new Promise((resolve) => server.close(() => resolve())); } diff --git a/apps/backend/tests/WorkflowRegistry.integration.test.ts b/apps/backend/tests/WorkflowRegistry.integration.test.ts new file mode 100644 index 000000000..f6c19f562 --- /dev/null +++ b/apps/backend/tests/WorkflowRegistry.integration.test.ts @@ -0,0 +1,390 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { AnalyticsDispatchService } from "@voidhash/core/services/analyticsIngest/AnalyticsDispatchService"; +import { + AppStoreExpireParkedNotifications, + AppStoreReconcileOriginalTransaction, + AppStoreReplayParkedNotifications, + AppStoreReplayParkedSdkNotifications, + DeliverWebhook, + FxRateSync, + GooglePlayReplayParkedNotifications, + PurchaseLedgerDrain, + StripeReplayParkedNotifications, +} from "@voidhash/core/workflows/definitions"; +import { backendWorkflows } from "@voidhash/core/workflows/registry"; +import { + Db, + type InsertPurchaseLedger, + PurchaseLedgerStatus, + WebhookDeliveryStatus, + and, + eq, + fxRates, + inArray, + paymentProviderNotificationProcessed, + purchaseLedger, + sql, + webhookDeliveries, + webhookDeliveryAttempts, + webhookEndpoints, +} from "@voidhash/db"; +import * as Workflow from "@voidhash/platform/Workflow"; +import { Effect, Exit, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +import { makeSelfhostPlatformLayers } from "../src/backend/PlatformProfile.ts"; +import { getSelfhostRuntimeConfig } from "../src/config.ts"; + +const FX_UPDATE_UNIX = 1_767_225_600; +const FX_AS_OF_DATE = new Date(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; + + 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 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 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"; + + 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 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.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 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(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`, + providerId: "stripe", + result: "parked_pending_product_mapping", + source: "webhook", + }, + ]); + + yield* Effect.forEach( + backendWorkflows, + (registration) => registration.register(workflowInfra), + { discard: true }, + ); + + 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); + + 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 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 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 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); + + 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) { + 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: 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); +}); diff --git a/selfhost/entry/tests/Www.test.ts b/apps/backend/tests/Www.test.ts similarity index 98% rename from selfhost/entry/tests/Www.test.ts rename to apps/backend/tests/Www.test.ts index 6ea8f9d9c..f47a4df5d 100644 --- a/selfhost/entry/tests/Www.test.ts +++ b/apps/backend/tests/Www.test.ts @@ -67,7 +67,6 @@ describe("WWW route ownership", () => { "/rpc/users", "/i/v1/capture", "/api/v1/users", - "/api/webhooks/workos", "/files/avatar.png", "/p/hash/index.html", "/c/hash/runtime.js", diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index b9da6a8e5..f040f8ac8 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,17 +1,12 @@ { - "extends": "@voidhash/tsconfig/alchemy-base.json", + "extends": "@voidhash/tsconfig/typescript-6.json", "compilerOptions": { - "types": ["bun"], - "allowImportingTsExtensions": true, - "composite": true, - "stripInternal": true, - "noEmit": false, - "outDir": "./lib", - "rootDir": "./src", - "paths": { - "@/*": ["./src/*"] - } + "types": ["node"], + "noEmit": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true }, - "include": ["src"], + "include": ["src", "tests", "vitest.mts"], "exclude": ["**/node_modules/**"] } diff --git a/apps/backend/vitest.integration.mts b/apps/backend/vitest.integration.mts new file mode 100644 index 000000000..0353a4ffb --- /dev/null +++ b/apps/backend/vitest.integration.mts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite-plus"; + +// Integration tier: runs against the provisioned self-host stack via +// `pnpm test:integration`. Timeouts are generous because these tests wait on +// real containers rather than fakes. +// +// Files run one at a time. Most of them build a single-node cluster, and a +// single-node cluster claims every shard in its database: two files running at +// once would take each other's messages exactly the way a test process and a +// running deployment do. +export default defineConfig({ + test: { + environment: "node", + include: ["./**/*.integration.test.ts"], + exclude: ["./node_modules/**", "./dist/**"], + reporters: ["verbose"], + fileParallelism: false, + hookTimeout: 300_000, + teardownTimeout: 300_000, + testTimeout: 120_000, + }, +}); diff --git a/apps/backend/vitest.mts b/apps/backend/vitest.mts new file mode 100644 index 000000000..4d8eb60c4 --- /dev/null +++ b/apps/backend/vitest.mts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite-plus"; + +// 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({ + test: { + environment: "node", + include: ["./**/*.test.ts"], + exclude: ["./**/*.integration.test.ts", "./node_modules/**", "./dist/**"], + reporters: ["verbose"], + }, +}); diff --git a/apps/backend/vitest.unit.mts b/apps/backend/vitest.unit.mts deleted file mode 100644 index 7f23222f6..000000000 --- a/apps/backend/vitest.unit.mts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vite-plus"; - -export default defineConfig({ - test: { - exclude: ["./**/*.integration.test.ts", "./node_modules/**"], - include: ["./**/*.test.ts"], - reporters: ["verbose"], - }, -}); diff --git a/apps/cli/build.ts b/apps/cli/build.ts index dc64c9037..e0585d706 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -45,7 +45,7 @@ const main = async () => { }; main().catch((error) => { - // biome-ignore lint/suspicious/noConsole: User facing console error. + // User facing console error. console.error(error); process.exit(1); }); diff --git a/apps/cli/src/domain/services/auth.ts b/apps/cli/src/domain/services/auth.ts index bcb7ab708..21b8fdf30 100644 --- a/apps/cli/src/domain/services/auth.ts +++ b/apps/cli/src/domain/services/auth.ts @@ -183,7 +183,6 @@ const make = Effect.gen(function* effect() { yield* Effect.logDebug(`Starting callback server on ${host}:${port}`); yield* Effect.forkChild( Effect.catch(runCallbackServer(callbackEventsPubSub), (error) => { - // biome-ignore lint/suspicious/noConsole: Error logging console.log(error); return Effect.die(error); }), 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 0f6c2d6c5..38378dbaa 100644 --- a/apps/cli/src/utils/js-loading/js-file-loading.ts +++ b/apps/cli/src/utils/js-loading/js-file-loading.ts @@ -10,11 +10,9 @@ export class FailedToLoadJsFileError extends Data.TaggedError("FailedToLoadJsFil const assertES5 = ({ unregister }: { unregister: () => void }) => Effect.try({ try: () => require("./_es5.ts"), - // biome-ignore lint/suspicious/noExplicitAny: yolo catch: (e: any) => { unregister(); if ("errors" in e && Array.isArray(e.errors) && e.errors.length > 0) { - // biome-ignore lint/suspicious/noExplicitAny: yolo const es5Error = (e.errors as any[]).some((it) => it.text?.includes(`("es5") is not supported yet`), ); @@ -57,7 +55,7 @@ export const safeRegister = () => }).pipe( Effect.catch(() => Effect.succeed({ - // biome-ignore lint/suspicious/noEmptyBlockStatements: it is on purpose an empty function. It is here instead of try-catch due to tsx. + // it is on purpose an empty function. It is here instead of try-catch due to tsx. unregister(): void {}, }), ), diff --git a/apps/cli/sst-env.d.ts b/apps/cli/sst-env.d.ts index f2ed71576..b3584169c 100644 --- a/apps/cli/sst-env.d.ts +++ b/apps/cli/sst-env.d.ts @@ -2,7 +2,6 @@ /* tslint:disable */ /* eslint-disable */ /* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ /// diff --git a/apps/mimic-admin/src/styles/globals.css b/apps/mimic-admin/src/styles/globals.css index 688cf5367..cbe66d68d 100644 --- a/apps/mimic-admin/src/styles/globals.css +++ b/apps/mimic-admin/src/styles/globals.css @@ -1,4 +1,3 @@ -/** biome-ignore-all lint/nursery/noUnknownAtRule: tailwind */ @import "tailwindcss"; @import "tw-animate-css"; diff --git a/apps/mimic-db/REWRITE.md b/apps/mimic-db/REWRITE.md index 01226039d..5a599c60d 100644 --- a/apps/mimic-db/REWRITE.md +++ b/apps/mimic-db/REWRITE.md @@ -17,7 +17,7 @@ document load** instead of an eager "migration push" over every document. | `node:vm` (`Script`/`createContext`) migration sandbox | in-process `evaluateBundledMigration` (`new Function`, **no `node:vm`/quickjs**) — see "Migration execution" below | | eager "migration push" iterates every document | `MimicHostObject` records new schema versions; each document **migrates itself on load** | | standalone/gateway/worker Node entrypoints | `MimicDbWorker` Cloudflare Worker (one fetch entrypoint) | -| deployed standalone; backend points at external URL | **provisioned from `apps/backend`** alchemy stack; service binding + `MIMIC_HOST_URL` | +| deployed standalone; backend points at external URL | **provisioned from `packages/backend`** alchemy stack; service binding + `MIMIC_HOST_URL` | ## Preserved contracts (so clients/backend keep working) diff --git a/apps/mimic-db/package.json b/apps/mimic-db/package.json index cac383a6d..a66e8f2be 100644 --- a/apps/mimic-db/package.json +++ b/apps/mimic-db/package.json @@ -26,7 +26,7 @@ "@effect/sql-pg": "catalog:", "@voidhash/mimic-core": "workspace:*", "@voidhash/mimic-server": "workspace:*", - "@orbian/sdk": "https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", + "@voidhash/platform": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/apps/mimic-db/src/core/local-entity-host.ts b/apps/mimic-db/src/core/local-entity-host.ts index 956d06f83..00a03d4d6 100644 --- a/apps/mimic-db/src/core/local-entity-host.ts +++ b/apps/mimic-db/src/core/local-entity-host.ts @@ -3,7 +3,7 @@ import { type DurableEntityContext, type DurableEntityHostShape, type DurableEntitySession, -} from "@orbian/sdk/DurableEntity"; +} from "@voidhash/platform/DurableEntity"; import { Effect, Layer, Semaphore } from "effect"; interface MemoryEntityState { diff --git a/apps/mimic-db/src/core/local-host-service.ts b/apps/mimic-db/src/core/local-host-service.ts index 2b675c190..8258880e9 100644 --- a/apps/mimic-db/src/core/local-host-service.ts +++ b/apps/mimic-db/src/core/local-host-service.ts @@ -2,7 +2,7 @@ import { type DurableEntityContext, DurableEntityHost, makeDurableEntityAddress, -} from "@orbian/sdk/DurableEntity"; +} from "@voidhash/platform/DurableEntity"; import { Effect, Layer } from "effect"; import type { MigrationRegistry } from "@voidhash/mimic-server/migrate"; import { NotFoundError } from "@voidhash/mimic-server/rpc"; diff --git a/apps/mimic-db/tests/durable-entity-host.test.ts b/apps/mimic-db/tests/durable-entity-host.test.ts index e17bc6936..14d6ffb82 100644 --- a/apps/mimic-db/tests/durable-entity-host.test.ts +++ b/apps/mimic-db/tests/durable-entity-host.test.ts @@ -1,4 +1,4 @@ -import { makeDurableEntityAddress, type DurableEntitySession } from "@orbian/sdk/DurableEntity"; +import { makeDurableEntityAddress, type DurableEntitySession } from "@voidhash/platform/DurableEntity"; import { Effect } from "effect"; import { describe, expect, test } from "vitest"; diff --git a/apps/mimic-db/tests/unit/direct-migration.test.ts b/apps/mimic-db/tests/unit/direct-migration.test.ts index eca48b8f4..2af9647cc 100644 --- a/apps/mimic-db/tests/unit/direct-migration.test.ts +++ b/apps/mimic-db/tests/unit/direct-migration.test.ts @@ -4,7 +4,7 @@ import { defineMigrationRegistry, type AnyDirectMigration, } from "@voidhash/mimic-server/migrate"; -import { makeDurableEntityAddress } from "@orbian/sdk/DurableEntity"; +import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/apps/mimic-db/vitest.mts b/apps/mimic-db/vitest.mts index 51b79bb1e..d8c77b4e5 100644 --- a/apps/mimic-db/vitest.mts +++ b/apps/mimic-db/vitest.mts @@ -4,6 +4,8 @@ import { dirname, resolve } from "node:path"; const rootDir = dirname(fileURLToPath(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: { @@ -15,8 +17,8 @@ export default defineConfig({ }, test: { environment: "node", - include: ["./tests/**/*.test.ts"], - exclude: ["./node_modules/**"], + include: ["./**/*.test.ts"], + exclude: ["./**/*.integration.test.ts", "./node_modules/**", "./dist/**"], reporters: ["verbose"], }, }); diff --git a/apps/studio/src/components/PreviewErrorBoundary.tsx b/apps/studio/src/components/PreviewErrorBoundary.tsx index 3b3782961..224ff0547 100644 --- a/apps/studio/src/components/PreviewErrorBoundary.tsx +++ b/apps/studio/src/components/PreviewErrorBoundary.tsx @@ -26,7 +26,7 @@ export class PreviewErrorBoundary extends Component< } componentDidCatch(error: Error, info: ErrorInfo): void { - // biome-ignore lint/suspicious/noConsole: surface author errors in the dev console. + // surface author errors in the dev console. console.error("[voidhash-studio] preview render error", error, info); } diff --git a/apps/www/.source/browser.ts b/apps/www/.source/browser.ts index a20e34d72..f8c66bc1b 100644 --- a/apps/www/.source/browser.ts +++ b/apps/www/.source/browser.ts @@ -15,12 +15,5 @@ const browserCollections = { }, "eager": false })), - design: create.doc("design", import.meta.glob(["./**/*.{mdx,md}"], { - "base": "./../src/features/design/content/docs", - "query": { - "collection": "design" - }, - "eager": false - })), }; export default browserCollections; \ No newline at end of file diff --git a/apps/www/.source/server.ts b/apps/www/.source/server.ts index 25501242d..0b72d6d61 100644 --- a/apps/www/.source/server.ts +++ b/apps/www/.source/server.ts @@ -21,19 +21,4 @@ export const docs = await create.docs("docs", "src/features/docs/content/docs", "collection": "docs" }, "eager": true -})); - -export const design = await create.docs("design", "src/features/design/content/docs", import.meta.glob(["./**/*.{json,yaml}"], { - "base": "./../src/features/design/content/docs", - "query": { - "collection": "design" - }, - "import": "default", - "eager": true -}), import.meta.glob(["./**/*.{mdx,md}"], { - "base": "./../src/features/design/content/docs", - "query": { - "collection": "design" - }, - "eager": true })); \ No newline at end of file diff --git a/apps/www/package.json b/apps/www/package.json index 4f1072071..a763a05cc 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -62,9 +62,6 @@ "@voidhash/paywalls": "workspace:*", "@voidhash/rpc": "workspace:*", "@voidhash/ui": "workspace:*", - "@workos-inc/node": "8.13.0", - "@workos/authkit-session": "0.5.2", - "@workos/authkit-tanstack-react-start": "0.8.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cross-fetch": "catalog:", @@ -75,7 +72,7 @@ "fractional-indexing-jittered": "^1.0.0", "fumadocs-core": "16.11.5", "fumadocs-mdx": "15.2.0", - "fumadocs-openapi": "^11.2.2", + "fumadocs-openapi": "11.2.2", "fumadocs-ui": "16.11.5", "h3-v2": "npm:h3@2.0.1-rc.18", "lucide-react": "catalog:", diff --git a/apps/www/src/features/www/hero/hero-shader.tsx b/apps/www/src/components/hero-shader.tsx similarity index 100% rename from apps/www/src/features/www/hero/hero-shader.tsx rename to apps/www/src/components/hero-shader.tsx diff --git a/apps/www/src/components/voidhash-gradient-background.tsx b/apps/www/src/components/voidhash-gradient-background.tsx deleted file mode 100644 index 9450994f6..000000000 --- a/apps/www/src/components/voidhash-gradient-background.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; - -import { cn } from "@/lib/utils"; - -import { VoidhashGradientControls } from "./voidhash-gradient-controls"; -import { - DEFAULT_VOIDHASH_GRADIENT_SETTINGS, - mergeVoidhashGradientSettings, - VOIDHASH_GRADIENT_CONTROLS_STORAGE_KEY, - VOIDHASH_GRADIENT_STORAGE_KEY, - type VoidhashGradientSettings, -} from "./voidhash-gradient-settings"; - -const VoidhashGradientCanvas = lazy(async () => { - const module = await import("./voidhash-gradient-canvas"); - - return { - default: module.VoidhashGradientCanvas, - }; -}); - -type VoidhashGradientPlacement = "bottom" | "top"; -type ReadyPlacements = Record; - -function getFadeMask(settings: VoidhashGradientSettings, placement: VoidhashGradientPlacement) { - const start = Math.min(settings.fadeStart, settings.fadeEnd - 1); - const end = Math.max(settings.fadeEnd, start + 1); - const mid = start + (end - start) * 0.35; - const direction = placement === "top" ? "to top" : "to bottom"; - - return `linear-gradient(${direction}, transparent 0%, transparent ${start}%, rgba(0, 0, 0, 0.12) ${mid}%, black ${end}%, black 100%)`; -} - -function getLayerStyle(settings: VoidhashGradientSettings, placement: VoidhashGradientPlacement): CSSProperties { - return { - [placement]: `${placement === "top" ? settings.topOffset : settings.bottomOffset}%`, - filter: `blur(${settings.blur}px)`, - height: `${settings.effectHeight}%`, - maskImage: getFadeMask(settings, placement), - WebkitMaskImage: getFadeMask(settings, placement), - }; -} - -function VoidhashGradientEntryVeil({ isVisible }: { isVisible: boolean }) { - return ( -
- ); -} - -function VoidhashGradientLayer({ - isRevealed, - onReady, - placement, - settings, -}: { - isRevealed: boolean; - onReady: (placement: VoidhashGradientPlacement) => void; - placement: VoidhashGradientPlacement; - settings: VoidhashGradientSettings; -}) { - const [isCanvasReady, setIsCanvasReady] = useState(false); - - return ( -
-
- { - setIsCanvasReady(true); - onReady(placement); - }} - seed={placement === "top" ? settings.topSeed : settings.bottomSeed} - settings={settings} - /> -
-
- ); -} - -export type VoidhashGradientBackgroundProps = { - className?: string; - controlsQueryParam?: string; - controlsStorageKey?: string; - controlsTitle?: string; - settings?: Partial; - settingsStorageKey?: string; -}; - -/** Renders the reusable blurred Three.js gradient background used across Voidhash surfaces. */ -export function VoidhashGradientBackground({ - className, - controlsQueryParam = "gradientControls", - controlsStorageKey = VOIDHASH_GRADIENT_CONTROLS_STORAGE_KEY, - controlsTitle = "Gradient FX", - settings: settingsProp, - settingsStorageKey = VOIDHASH_GRADIENT_STORAGE_KEY, -}: VoidhashGradientBackgroundProps) { - const baseSettings = useMemo( - () => mergeVoidhashGradientSettings(settingsProp ?? DEFAULT_VOIDHASH_GRADIENT_SETTINGS), - [settingsProp], - ); - const [isMounted, setIsMounted] = useState(false); - const [canDropEntryVeil, setCanDropEntryVeil] = useState(false); - const [hasRevealed, setHasRevealed] = useState(false); - const [readyPlacements, setReadyPlacements] = useState({ bottom: false, top: false }); - const [settings, setSettings] = useState(baseSettings); - const [showControls, setShowControls] = useState(false); - - useEffect(() => { - setIsMounted(true); - - const params = new URLSearchParams(window.location.search); - const controlsEnabled = params.has(controlsQueryParam) || localStorage.getItem(controlsStorageKey) === "1"; - const savedSettings = localStorage.getItem(settingsStorageKey); - - if (savedSettings) { - try { - setSettings(mergeVoidhashGradientSettings(JSON.parse(savedSettings), baseSettings)); - } catch { - localStorage.removeItem(settingsStorageKey); - } - } - - if (controlsEnabled) { - setShowControls(true); - localStorage.setItem(controlsStorageKey, "1"); - } - }, [baseSettings, controlsQueryParam, controlsStorageKey, settingsStorageKey]); - - useEffect(() => { - if (!isMounted) { - return; - } - - const veilTimer = window.setTimeout(() => setCanDropEntryVeil(true), 1800); - - return () => window.clearTimeout(veilTimer); - }, [isMounted]); - - useEffect(() => { - if (isMounted && showControls) { - localStorage.setItem(settingsStorageKey, JSON.stringify(settings)); - } - }, [isMounted, settings, settingsStorageKey, showControls]); - - const handleLayerReady = useCallback((placement: VoidhashGradientPlacement) => { - setReadyPlacements((current) => { - if (current[placement]) { - return current; - } - - return { ...current, [placement]: true }; - }); - }, []); - - const isShaderReady = readyPlacements.bottom && (!settings.topEnabled || readyPlacements.top); - - useEffect(() => { - if (!hasRevealed && isMounted && (isShaderReady || canDropEntryVeil)) { - setHasRevealed(true); - } - }, [canDropEntryVeil, hasRevealed, isMounted, isShaderReady]); - - const rootClassName = cn("relative isolate h-full w-full overflow-hidden bg-black", className); - - if (!isMounted) { - return ( -
- -
- ); - } - - return ( -
- - - {settings.topEnabled ? ( - - ) : null} - - - - {showControls ? ( - { - setShowControls(false); - localStorage.removeItem(controlsStorageKey); - }} - onReset={() => { - setSettings(baseSettings); - localStorage.removeItem(settingsStorageKey); - }} - settings={settings} - title={controlsTitle} - /> - ) : null} -
- ); -} diff --git a/apps/www/src/components/voidhash-gradient-canvas.tsx b/apps/www/src/components/voidhash-gradient-canvas.tsx deleted file mode 100644 index 3c437287c..000000000 --- a/apps/www/src/components/voidhash-gradient-canvas.tsx +++ /dev/null @@ -1,328 +0,0 @@ -"use client"; - -import { Canvas, useFrame } from "@react-three/fiber"; -import { useEffect, useMemo, useRef } from "react"; -import * as THREE from "three"; - -import type { VoidhashGradientSettings } from "./voidhash-gradient-settings"; - -const vertexShader = ` -uniform float uTime; -uniform float uAmplitude; -uniform float uFrequency; -uniform float uMidFrequency; -uniform float uHighFrequency; -uniform float uLift; -uniform float uSeed; - -varying vec2 vUv; -varying vec3 vNormal; -varying vec3 vViewPosition; -varying float vWave; - -vec4 mod289(vec4 x) { - return x - floor(x * (1.0 / 289.0)) * 289.0; -} - -vec3 mod289(vec3 x) { - return x - floor(x * (1.0 / 289.0)) * 289.0; -} - -vec4 permute(vec4 x) { - return mod289(((x * 34.0) + 10.0) * x); -} - -vec4 taylorInvSqrt(vec4 r) { - return 1.79284291400159 - 0.85373472095314 * r; -} - -float snoise(vec3 v) { - const vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); - const vec4 d = vec4(0.0, 0.5, 1.0, 2.0); - - vec3 i = floor(v + dot(v, c.yyy)); - vec3 x0 = v - i + dot(i, c.xxx); - - vec3 g = step(x0.yzx, x0.xyz); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - - vec3 x1 = x0 - i1 + c.xxx; - vec3 x2 = x0 - i2 + c.yyy; - vec3 x3 = x0 - d.yyy; - - i = mod289(i); - vec4 p = permute(permute(permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) - + i.y + vec4(0.0, i1.y, i2.y, 1.0)) - + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - float n_ = 0.142857142857; - vec3 ns = n_ * d.wyz - d.xzx; - - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0)); - - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - - vec4 norm = taylorInvSqrt(vec4( - dot(p0, p0), - dot(p1, p1), - dot(p2, p2), - dot(p3, p3) - )); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - vec4 m = max(0.6 - vec4( - dot(x0, x0), - dot(x1, x1), - dot(x2, x2), - dot(x3, x3) - ), 0.0); - m = m * m; - - return 42.0 * dot(m * m, vec4( - dot(p0, x0), - dot(p1, x1), - dot(p2, x2), - dot(p3, x3) - )); -} - -float surfaceHeight(vec2 p, float time) { - vec2 seedOffset = vec2(uSeed * 1.37, uSeed * -0.73); - vec2 q = vec2(p.x * uFrequency, p.y * uFrequency * 2.55) + seedOffset; - float seedTime = time + uSeed * 0.11; - float low = snoise(vec3(q, seedTime)); - float mid = snoise(vec3(q * uMidFrequency + vec2(7.4, -3.2) + seedOffset * 0.43, seedTime * 1.65)); - float high = snoise(vec3(q * uHighFrequency + vec2(-2.0, 5.7) - seedOffset * 0.26, seedTime * 2.4)); - - return (low * 0.62 + mid * 0.28 + high * 0.1) * uAmplitude; -} - -void main() { - vUv = uv; - - float lift = smoothstep(0.02, 0.92, uv.y); - float height = surfaceHeight(position.xy, uTime) * (0.72 + lift * 0.62); - float epsilon = 0.08; - float heightX = surfaceHeight(position.xy + vec2(epsilon, 0.0), uTime); - float heightY = surfaceHeight(position.xy + vec2(0.0, epsilon), uTime); - - vec3 displaced = position; - displaced.y += lift * uLift; - displaced.z += height; - - vec3 displacedNormal = normalize(vec3( - (height - heightX) / epsilon, - (height - heightY) / epsilon, - 1.0 - )); - - vec4 mvPosition = modelViewMatrix * vec4(displaced, 1.0); - - vNormal = normalize(normalMatrix * displacedNormal); - vViewPosition = -mvPosition.xyz; - vWave = height; - - gl_Position = projectionMatrix * mvPosition; -} -`; - -const fragmentShader = ` -uniform vec3 uBaseColor; -uniform vec3 uFresnelColor; -uniform float uFresnelPower; -uniform float uFresnelStrength; -uniform float uHorizonStrength; -uniform float uLateralStrength; -uniform float uIntensity; -uniform float uBaseGlow; -uniform float uOpacity; - -varying vec2 vUv; -varying vec3 vNormal; -varying vec3 vViewPosition; -varying float vWave; - -void main() { - vec3 normal = normalize(vNormal); - vec3 viewDirection = normalize(vViewPosition); - float fresnel = pow(1.0 - max(dot(normal, viewDirection), 0.0), uFresnelPower); - float horizon = smoothstep(0.18, 0.94, vUv.y); - float lateralFresnel = smoothstep(0.18, 1.0, vUv.x); - float blue = clamp( - fresnel * uFresnelStrength - + horizon * uHorizonStrength - + lateralFresnel * uLateralStrength - + vWave * 0.08, - 0.0, - 1.0 - ); - float alpha = smoothstep(0.0, 0.1, vUv.y) * (1.0 - smoothstep(0.8, 1.0, vUv.y)); - float intensity = clamp(uIntensity + fresnel * 0.28 + horizon * 0.12, 0.0, 1.0); - float purpleBalance = clamp(uBaseGlow, 0.0, 1.5); - vec3 color = mix(uBaseColor, uFresnelColor, blue) * intensity; - color = mix(color, uBaseColor * intensity, min(purpleBalance, 1.0)); - color = min(color, max(uBaseColor, uFresnelColor)); - - gl_FragColor = vec4(color, alpha * uOpacity); -} -`; - -function setUniformColor(color: THREE.Color, hex: string) { - const value = Number.parseInt(hex.slice(1), 16); - - color.r = ((value >> 16) & 255) / 255; - color.g = ((value >> 8) & 255) / 255; - color.b = (value & 255) / 255; -} - -function AnimatedPlane({ - inverted, - onReady, - seed, - settings, -}: { - inverted: boolean; - onReady?: () => void; - seed: number; - settings: VoidhashGradientSettings; -}) { - const materialRef = useRef(null); - const didReportReadyRef = useRef(false); - const isMountedRef = useRef(true); - const uniforms = useMemo(() => { - const baseColor = new THREE.Color(); - const fresnelColor = new THREE.Color(); - - setUniformColor(baseColor, settings.baseColor); - setUniformColor(fresnelColor, settings.fresnelColor); - - return { - uBaseColor: { value: baseColor }, - uFresnelColor: { value: fresnelColor }, - uAmplitude: { value: settings.amplitude }, - uBaseGlow: { value: settings.baseGlow }, - uFresnelPower: { value: settings.fresnelPower }, - uFresnelStrength: { value: settings.fresnelStrength }, - uFrequency: { value: settings.frequency }, - uHighFrequency: { value: settings.highFrequency }, - uHorizonStrength: { value: settings.horizonStrength }, - uIntensity: { value: settings.intensity }, - uLateralStrength: { value: settings.lateralStrength }, - uLift: { value: settings.lift }, - uMidFrequency: { value: settings.midFrequency }, - uOpacity: { value: settings.opacity }, - uSeed: { value: seed }, - uTime: { value: 0 }, - }; - }, [seed, settings]); - - useEffect(() => { - return () => { - isMountedRef.current = false; - }; - }, []); - - useEffect(() => { - uniforms.uAmplitude.value = settings.amplitude; - setUniformColor(uniforms.uBaseColor.value, settings.baseColor); - uniforms.uBaseGlow.value = settings.baseGlow; - setUniformColor(uniforms.uFresnelColor.value, settings.fresnelColor); - uniforms.uFresnelPower.value = settings.fresnelPower; - uniforms.uFresnelStrength.value = settings.fresnelStrength; - uniforms.uFrequency.value = settings.frequency; - uniforms.uHighFrequency.value = settings.highFrequency; - uniforms.uHorizonStrength.value = settings.horizonStrength; - uniforms.uIntensity.value = settings.intensity; - uniforms.uLateralStrength.value = settings.lateralStrength; - uniforms.uLift.value = settings.lift; - uniforms.uMidFrequency.value = settings.midFrequency; - uniforms.uOpacity.value = settings.opacity; - uniforms.uSeed.value = seed; - }, [seed, settings, uniforms]); - - useFrame(({ clock }) => { - if (materialRef.current) { - materialRef.current.uniforms.uTime.value = clock.elapsedTime * settings.speed; - } - - if (!didReportReadyRef.current) { - didReportReadyRef.current = true; - requestAnimationFrame(() => { - if (isMountedRef.current) { - onReady?.(); - } - }); - } - }); - - return ( - - - - - - - ); -} - -/** Renders the WebGL plane used by reusable Voidhash gradient backgrounds. */ -export function VoidhashGradientCanvas({ - inverted = false, - onReady, - seed, - settings, -}: { - inverted?: boolean; - onReady?: () => void; - seed: number; - settings: VoidhashGradientSettings; -}) { - return ( - { - gl.outputColorSpace = THREE.SRGBColorSpace; - gl.setClearColor(0x000000, 0); - }} - > - - - ); -} diff --git a/apps/www/src/components/voidhash-gradient-controls.tsx b/apps/www/src/components/voidhash-gradient-controls.tsx deleted file mode 100644 index 2f49f5f00..000000000 --- a/apps/www/src/components/voidhash-gradient-controls.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { useState, type Dispatch, type SetStateAction } from "react"; - -import type { VoidhashGradientSettings } from "./voidhash-gradient-settings"; - -type NumericSettingKey = { - [Key in keyof VoidhashGradientSettings]: VoidhashGradientSettings[Key] extends number ? Key : never; -}[keyof VoidhashGradientSettings]; - -type BooleanSettingKey = { - [Key in keyof VoidhashGradientSettings]: VoidhashGradientSettings[Key] extends boolean ? Key : never; -}[keyof VoidhashGradientSettings]; - -type ColorSettingKey = { - [Key in keyof VoidhashGradientSettings]: VoidhashGradientSettings[Key] extends string ? Key : never; -}[keyof VoidhashGradientSettings]; - -const booleanControls: Array<{ key: BooleanSettingKey; label: string }> = [ - { key: "topEnabled", label: "Top effect" }, -]; - -const numericControls: Array<{ - key: NumericSettingKey; - label: string; - min: number; - max: number; - step: number; -}> = [ - { key: "blur", label: "Blur", min: 0, max: 90, step: 1 }, - { key: "effectHeight", label: "Height", min: 70, max: 180, step: 1 }, - { key: "bottomOffset", label: "Bottom", min: -60, max: 20, step: 1 }, - { key: "topOffset", label: "Top", min: -20, max: 60, step: 1 }, - { key: "bottomSeed", label: "Bottom seed", min: 0, max: 80, step: 0.1 }, - { key: "topSeed", label: "Top seed", min: 0, max: 80, step: 0.1 }, - { key: "fadeStart", label: "Fade start", min: 0, max: 80, step: 1 }, - { key: "fadeEnd", label: "Fade full", min: 10, max: 100, step: 1 }, - { key: "planeY", label: "Plane Y", min: -2.4, max: 0.2, step: 0.01 }, - { key: "rotationX", label: "Tilt", min: -1.4, max: -0.25, step: 0.01 }, - { key: "scaleX", label: "Width", min: 0.8, max: 2.2, step: 0.01 }, - { key: "scaleY", label: "Depth", min: 0.5, max: 1.8, step: 0.01 }, - { key: "amplitude", label: "Amplitude", min: 0, max: 1.8, step: 0.01 }, - { key: "frequency", label: "Frequency", min: 0.05, max: 0.5, step: 0.01 }, - { key: "midFrequency", label: "Mid noise", min: 0.8, max: 4, step: 0.01 }, - { key: "highFrequency", label: "Fine noise", min: 1.5, max: 7, step: 0.01 }, - { key: "speed", label: "Speed", min: 0, max: 0.2, step: 0.001 }, - { key: "lift", label: "Lift", min: 0, max: 1, step: 0.01 }, - { key: "fresnelPower", label: "Fresnel pow", min: 0.5, max: 4, step: 0.01 }, - { key: "fresnelStrength", label: "Fresnel", min: 0, max: 3, step: 0.01 }, - { key: "horizonStrength", label: "Horizon", min: 0, max: 2, step: 0.01 }, - { key: "lateralStrength", label: "Blue bias", min: 0, max: 1.5, step: 0.01 }, - { key: "intensity", label: "Intensity", min: 0.2, max: 2, step: 0.01 }, - { key: "baseGlow", label: "Purple mix", min: 0, max: 1.5, step: 0.01 }, - { key: "opacity", label: "Opacity", min: 0, max: 1, step: 0.01 }, -]; - -const colorControls: Array<{ key: ColorSettingKey; label: string }> = [ - { key: "baseColor", label: "Base" }, - { key: "fresnelColor", label: "Fresnel" }, -]; - -function formatValue(value: number) { - if (Math.abs(value) >= 10) { - return value.toFixed(0); - } - - return value.toFixed(2); -} - -/** Renders the debug tuner for Voidhash gradient background settings. */ -export function VoidhashGradientControls({ - onChange, - onHide, - onReset, - settings, - title = "Gradient FX", -}: { - onChange: Dispatch>; - onHide: () => void; - onReset: () => void; - settings: VoidhashGradientSettings; - title?: string; -}) { - const [copied, setCopied] = useState(false); - - return ( -
-
-
{title}
- -
- -
- {booleanControls.map((control) => ( - - ))} - - {colorControls.map((control) => ( - - ))} - - {numericControls.map((control) => ( - - ))} -
- -
- - -
-
- ); -} diff --git a/apps/www/src/components/voidhash-gradient-settings.ts b/apps/www/src/components/voidhash-gradient-settings.ts deleted file mode 100644 index bf826f336..000000000 --- a/apps/www/src/components/voidhash-gradient-settings.ts +++ /dev/null @@ -1,102 +0,0 @@ -export type VoidhashGradientSettings = { - blur: number; - effectHeight: number; - bottomOffset: number; - topEnabled: boolean; - topOffset: number; - bottomSeed: number; - topSeed: number; - fadeStart: number; - fadeEnd: number; - planeY: number; - rotationX: number; - scaleX: number; - scaleY: number; - amplitude: number; - frequency: number; - midFrequency: number; - highFrequency: number; - speed: number; - lift: number; - fresnelPower: number; - fresnelStrength: number; - horizonStrength: number; - lateralStrength: number; - intensity: number; - baseGlow: number; - opacity: number; - baseColor: string; - fresnelColor: string; -}; - -export const VOIDHASH_GRADIENT_STORAGE_KEY = "voidhash:gradient-background-settings"; -export const VOIDHASH_GRADIENT_CONTROLS_STORAGE_KEY = "voidhash:gradient-background-controls"; - -export const DEFAULT_VOIDHASH_GRADIENT_SETTINGS: VoidhashGradientSettings = { - blur: 7, - effectHeight: 98, - bottomOffset: 0, - topEnabled: false, - topOffset: 0, - bottomSeed: 0, - topSeed: 28.7, - fadeStart: 22, - fadeEnd: 58, - planeY: -2.4, - rotationX: -0.86, - scaleX: 1.42, - scaleY: 0.96, - amplitude: 1.42, - frequency: 0.19, - midFrequency: 0.8, - highFrequency: 1.5, - speed: 0.072, - lift: 0.58, - fresnelPower: 0.96, - fresnelStrength: 1.7, - horizonStrength: 0, - lateralStrength: 0.52, - intensity: 1.03, - baseGlow: 0, - opacity: 1, - baseColor: "#7f14ff", - fresnelColor: "#0673ff", -}; - -const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; - -/** Merges persisted or caller-provided values with the supported gradient settings. */ -export function mergeVoidhashGradientSettings( - value: unknown, - defaults: VoidhashGradientSettings = DEFAULT_VOIDHASH_GRADIENT_SETTINGS, -): VoidhashGradientSettings { - if (!value || typeof value !== "object") { - return defaults; - } - - const settings: VoidhashGradientSettings = { ...defaults }; - const entries = Object.entries(value as Partial>); - - for (const [key, entryValue] of entries) { - if (!(key in settings)) { - continue; - } - - const settingKey = key as keyof VoidhashGradientSettings; - const defaultValue = settings[settingKey]; - - if (typeof defaultValue === "number" && typeof entryValue === "number" && Number.isFinite(entryValue)) { - settings[settingKey] = entryValue as never; - } - - if (typeof defaultValue === "boolean" && typeof entryValue === "boolean") { - settings[settingKey] = entryValue as never; - } - - if (typeof defaultValue === "string" && typeof entryValue === "string" && HEX_COLOR_PATTERN.test(entryValue)) { - settings[settingKey] = entryValue.toLowerCase() as never; - } - } - - return settings; -} diff --git a/apps/www/src/features/auth/adapter/auth-screens.ts b/apps/www/src/features/auth/adapter/auth-screens.ts new file mode 100644 index 000000000..4ac7caa3d --- /dev/null +++ b/apps/www/src/features/auth/adapter/auth-screens.ts @@ -0,0 +1,25 @@ +/** + * Shape of the auth-screen slot. + * + * Lives apart from `ui-adapter.tsx` because that module is replaced wholesale + * by a build alias: a private adapter must be able to import the contract + * without importing (and so re-entering) the module it is replacing. + * + * A `null` screen means the route redirects to `/auth/login` instead of + * rendering — which is what self-host wants for every self-service flow, since + * it has exactly one user. + */ +import type { ComponentType } from "react"; + +/** Props every auth screen receives from its route. */ +export interface AuthScreenProps { + readonly next?: string | undefined; +} + +export interface AuthScreens { + readonly login: ComponentType; + readonly signUp: ComponentType | null; + readonly verifyEmail: ComponentType | null; + readonly forgotPassword: ComponentType | null; + readonly resetPassword: ComponentType | null; +} diff --git a/apps/www/src/features/auth/adapter/session-adapter.ts b/apps/www/src/features/auth/adapter/session-adapter.ts new file mode 100644 index 000000000..3d4701bdf --- /dev/null +++ b/apps/www/src/features/auth/adapter/session-adapter.ts @@ -0,0 +1,84 @@ +/** + * Server-side identity-provider slot. + * + * This module is the seam between the open-source dashboard and whichever + * identity provider a deployment runs. The open-source default implements the + * standalone (single root user) provider; a private composition replaces this + * module wholesale through a build alias — the same mechanism the other + * dashboard slots use — and supplies its own provider without forking any + * route. + * + * Every export is a `createServerFn` or plain data, so the bodies and the + * server-only modules they reach stay out of the client bundle. + */ +import { createServerFn } from "@tanstack/react-start"; +import { getRequest, setResponseHeader } from "@tanstack/react-start/server"; + +import { + clearedStandaloneSessionCookie, + readStandaloneSession, +} from "../lib/standalone-session"; + +/** + * The identity fields the dashboard needs before its own `CurrentUser` RPC + * resolves. + */ +export interface SessionUser { + readonly createdAt: string | null; + readonly email: string; + readonly emailVerified: boolean; + readonly externalId: string | null; + readonly firstName: string | null; + readonly id: string; + readonly lastName: string | null; + readonly profilePictureUrl: string | null; + readonly updatedAt: string | null; +} + +/** The authenticated user for the current request, or `null`. */ +export const getSessionUser = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const session = await readStandaloneSession(getRequest()); + if (!session) return null; + return { + createdAt: null, + email: session.user.email, + emailVerified: true, + externalId: null, + firstName: session.user.name, + id: session.user.id, + lastName: null, + profilePictureUrl: null, + updatedAt: null, + }; + }, +); + +/** + * Ends the session. A server function rather than a `fetch` of the sign-out + * route, so it works during SSR — the logout route's loader runs on the server + * for a direct navigation, where a relative URL cannot be fetched. + */ +export const clearSession = createServerFn({ method: "POST" }).handler(async () => { + setResponseHeader("Set-Cookie", clearedStandaloneSessionCookie()); + return { ok: true }; +}); + +/** + * Ends the session for sign-out. + * + * Returns the path to land on, or `null` when the provider already performed + * its own redirect (a hosted sign-out endpoint does this). The standalone + * provider has no remote session, so clearing the cookie is the whole sign-out + * and the redirect is the route's to perform. + */ +export const performSignOut = async (returnTo: string): Promise => { + await clearSession(); + return returnTo; +}; + +/** + * Request middleware the provider needs on every request. The standalone + * provider verifies a self-contained token per request and needs none. + */ +export const authRequestMiddleware = [] as const; diff --git a/apps/www/src/features/auth/adapter/ui-adapter.tsx b/apps/www/src/features/auth/adapter/ui-adapter.tsx new file mode 100644 index 000000000..a551fd9b7 --- /dev/null +++ b/apps/www/src/features/auth/adapter/ui-adapter.tsx @@ -0,0 +1,59 @@ +/** + * Browser-side identity-provider slot. + * + * Companion to `session-adapter.ts`: the screens and the browser credential + * that differ per identity provider. A private composition replaces this module + * through a build alias to supply a richer set of screens (OAuth, passwords, + * email verification); the open-source default offers the single standalone + * sign-in screen and reports the rest as unavailable. + * + * A `null` screen means the route redirects to `/auth/login` instead of + * rendering — which is what self-host wants for every self-service flow, since + * it has exactly one user. + */ +import { StandaloneLoginScreen } from "../components/standalone-login-screen"; +import type { AuthScreenProps, AuthScreens } from "./auth-screens"; + +export type { AuthScreenProps, AuthScreens }; + +export const authScreens: AuthScreens = { + forgotPassword: null, + login: StandaloneLoginScreen, + resetPassword: null, + signUp: null, + verifyEmail: null, +}; + +/** + * Wraps the application root. The standalone provider keeps no client-side auth + * context, so the default is a passthrough. + */ +export function AuthProvider({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +/** + * Memoized for the lifetime of the document: the session only changes across a + * full navigation (sign-in and sign-out each perform one). + */ +let accessToken: Promise | undefined; + +/** + * Supplies the browser RPC client's bearer credential. The session cookie is + * `HttpOnly`, so the token is read back from the session endpoint rather than + * from `document.cookie`. + */ +export function useBrowserAccessTokenProvider(): () => Promise { + return () => { + accessToken ??= fetch("/api/auth/session", { credentials: "include" }) + .then((response) => (response.ok ? response.json() : null)) + .then((body: { accessToken?: string | null } | null) => body?.accessToken ?? undefined) + .catch(() => undefined); + return accessToken; + }; +} + +/** Drops the memoized credential when the bridge unmounts. */ +export function resetBrowserAccessToken(): void { + accessToken = undefined; +} diff --git a/apps/www/src/features/auth/components/auth-lenticular-background.tsx b/apps/www/src/features/auth/components/auth-lenticular-background.tsx index 6ace73fe9..c92abc7eb 100644 --- a/apps/www/src/features/auth/components/auth-lenticular-background.tsx +++ b/apps/www/src/features/auth/components/auth-lenticular-background.tsx @@ -1,6 +1,6 @@ "use client"; -import { HeroShader } from "@/features/www/hero/hero-shader"; +import { HeroShader } from "@/components/hero-shader"; import { cn } from "@/lib/utils"; /** Renders the landing lenticular composition with the animated Perlin surface as its source. */ diff --git a/apps/www/src/features/auth/components/auth-screen-layout.tsx b/apps/www/src/features/auth/components/auth-screen-layout.tsx new file mode 100644 index 000000000..e71134dbb --- /dev/null +++ b/apps/www/src/features/auth/components/auth-screen-layout.tsx @@ -0,0 +1,24 @@ +import { Link } from "@tanstack/react-router"; +import { Logo } from "@voidhash/ui"; +import type { ReactNode } from "react"; + +import { AuthLenticularBackground } from "./auth-lenticular-background"; + +/** Shared page chrome for the auth screens: background, brand mark, centered column. */ +export function AuthScreenLayout({ children }: { children: ReactNode }) { + return ( +
+ +
+
+ + + +
+
+
{children}
+
+
+
+ ); +} diff --git a/apps/www/src/features/auth/components/standalone-login-screen.tsx b/apps/www/src/features/auth/components/standalone-login-screen.tsx new file mode 100644 index 000000000..afbc1f948 --- /dev/null +++ b/apps/www/src/features/auth/components/standalone-login-screen.tsx @@ -0,0 +1,14 @@ +import { AuthScreenLayout } from "./auth-screen-layout"; +import { StandaloneSignInForm } from "./standalone-sign-in-form"; + +/** The one sign-in screen the standalone provider offers. */ +export function StandaloneLoginScreen({ next }: { next?: string | undefined }) { + return ( + +
+

Welcome back!

+
+ +
+ ); +} diff --git a/apps/www/src/features/auth/components/standalone-sign-in-form.tsx b/apps/www/src/features/auth/components/standalone-sign-in-form.tsx new file mode 100644 index 000000000..cbaca3ecf --- /dev/null +++ b/apps/www/src/features/auth/components/standalone-sign-in-form.tsx @@ -0,0 +1,93 @@ +import { useMutation } from "@tanstack/react-query"; +import { Alert, AlertDescription, AlertTitle, Button, Input, Label } from "@voidhash/ui"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { type FormEvent, useState } from "react"; + +import { signInWithRootCredentials } from "@/features/auth/lib/auth-api"; + +/** + * Sign-in for the standalone identity provider: the single root account, whose + * username and password come from the deployment's environment. There is no + * sign-up, no password reset, and no second user. + */ +export function StandaloneSignInForm({ next }: { next?: string | undefined }) { + const [error, setError] = useState(); + const signInMutation = useMutation({ + mutationFn: signInWithRootCredentials, + onError: (mutationError) => { + setError(mutationError.message); + }, + onMutate: () => { + setError(undefined); + }, + onSuccess: (payload) => { + // A full navigation so the new cookie is picked up by the server render + // and the access-token bridge re-seeds from the session endpoint. + window.location.href = payload.redirectTo ?? "/studio"; + }, + }); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + signInMutation.mutate({ + password: String(formData.get("password") ?? ""), + returnPathname: next, + username: String(formData.get("username") ?? ""), + }); + }; + + return ( +
+ {error && ( + + + Sign-in failed + {error} + + )} + +
+
+ + +
+
+ + +
+ +
+ +

+ Voidhash self-host signs in with the root credentials from your + environment (VOIDHASH_ROOT_USERNAME and{" "} + VOIDHASH_ROOT_PASSWORD). +

+
+ ); +} diff --git a/apps/www/src/features/auth/globals.css b/apps/www/src/features/auth/globals.css index 629e41a6d..b8795c885 100644 --- a/apps/www/src/features/auth/globals.css +++ b/apps/www/src/features/auth/globals.css @@ -1,4 +1,3 @@ -/** biome-ignore-all lint/nursery/noUnknownAtRule: tailwind */ @import "tailwindcss"; @import "tw-animate-css"; diff --git a/apps/www/src/features/auth/lib/auth-api.ts b/apps/www/src/features/auth/lib/auth-api.ts index bba5ad99c..7c81476ee 100644 --- a/apps/www/src/features/auth/lib/auth-api.ts +++ b/apps/www/src/features/auth/lib/auth-api.ts @@ -1,71 +1,11 @@ -type AuthApiErrorPayload = { - error?: string; -}; - -type AuthRedirectResponse = { - redirectTo?: string; -}; - /** - * Fields returned by sign-up / sign-in when the account exists but its email is - * not yet verified. The client uses these to drive the verify-email step. + * Browser client for the dashboard's own auth endpoints. + * + * Provider-specific flows (OAuth, passwords, email verification) live with the + * screens that use them, behind the auth adapter slot. */ -type EmailVerificationChallenge = { - email?: string; - emailVerificationRequired?: boolean; - pendingAuthenticationToken?: string; - userId?: string; -}; - -type SignUpResponse = AuthRedirectResponse & EmailVerificationChallenge; - -type SignInResponse = AuthRedirectResponse & EmailVerificationChallenge; - -type VerifyEmailResponse = AuthRedirectResponse & { - verified?: boolean; -}; - -type ResendVerificationResponse = { - ok?: boolean; -}; - -type ForgotPasswordResponse = { - message?: string; -}; - -type SignInPasswordInput = { - email: string; - password: string; - returnPathname?: string; -}; - -type SignUpPasswordInput = { - email: string; - firstName: string; - lastName: string; - password: string; - returnPathname?: string; -}; - -type ForgotPasswordInput = { - email: string; -}; - -type ResetPasswordInput = { - password: string; - token: string; -}; - -type VerifyEmailInput = { - code: string; - email?: string; - pendingAuthenticationToken?: string; - returnPathname?: string; -}; - -type ResendVerificationInput = { - email?: string; - userId?: string; +type AuthApiErrorPayload = { + error?: string; }; const postAuthJson = async ( @@ -87,40 +27,21 @@ const postAuthJson = async ( return payload; }; -export const signInWithPassword = (input: SignInPasswordInput) => - postAuthJson("/api/auth/password/sign-in", input, "We could not sign you in."); - -export const verifyEmailCode = (input: VerifyEmailInput) => - postAuthJson( - "/api/auth/email/verify", - input, - "We could not verify your email.", - ); - -export const resendVerificationCode = (input: ResendVerificationInput) => - postAuthJson( - "/api/auth/email/resend", - input, - "We could not send a new code.", - ); - -export const signUpWithPassword = (input: SignUpPasswordInput) => - postAuthJson( - "/api/auth/password/sign-up", - input, - "We could not create your account.", - ); +export type RootSignInInput = { + username: string; + password: string; + returnPathname?: string; +}; -export const requestPasswordReset = (input: ForgotPasswordInput) => - postAuthJson( - "/api/auth/password/forgot-password", - input, - "We could not send a reset link.", - ); +export type RootSignInResponse = { + accessToken: string; + redirectTo: string; +}; -export const resetPassword = (input: ResetPasswordInput) => - postAuthJson( - "/api/auth/password/reset-password", +/** Standalone sign-in with the deployment's configured root credentials. */ +export const signInWithRootCredentials = (input: RootSignInInput) => + postAuthJson( + "/api/auth/sign-in", input, - "We could not reset your password.", + "We could not sign you in.", ); diff --git a/apps/www/src/features/auth/lib/http.ts b/apps/www/src/features/auth/lib/http.ts new file mode 100644 index 000000000..5fae94af2 --- /dev/null +++ b/apps/www/src/features/auth/lib/http.ts @@ -0,0 +1,64 @@ +/** + * Provider-neutral HTTP helpers shared by every auth route handler. + * + * Extracted so the standalone routes and any private identity-provider adapter + * can build responses the same way without depending on each other. + */ +import { toSafeReturnPathname } from "./validation"; + +export type JsonBody = Record; + +const appendHeaders = (target: Headers, source?: Record) => { + if (!source) { + return; + } + + for (const [key, value] of Object.entries(source)) { + if (Array.isArray(value)) { + for (const item of value) { + target.append(key, item); + } + continue; + } + + target.append(key, value); + } +}; + +/** JSON response with optional extra headers (`Set-Cookie` may repeat). */ +export const jsonResponse = ( + body: JsonBody, + init: ResponseInit = {}, + extraHeaders?: Record, +) => { + const headers = new Headers(init.headers); + headers.set("Content-Type", "application/json"); + appendHeaders(headers, extraHeaders); + return new Response(JSON.stringify(body), { ...init, headers }); +}; + +/** Uniform `{ error }` body for a failed authentication attempt. */ +export const authErrorResponse = (message: string, status = 400) => + jsonResponse({ error: message }, { status }); + +/** Parses a JSON request body, treating malformed input as empty. */ +export const getJsonBody = async (request: Request): Promise> => { + try { + return (await request.json()) as Partial; + } catch { + return {} as Partial; + } +}; + +/** Same-origin `returnPathname` from the query string, or the fallback. */ +export const getSafeReturnPathnameFromRequest = (request: Request, fallback = "/studio") => { + const url = new URL(request.url); + return toSafeReturnPathname(url.searchParams.get("returnPathname"), url.origin) ?? fallback; +}; + +/** Same-origin `returnPathname` from an arbitrary value, or the fallback. */ +export const getSafeReturnPathname = ( + request: Request, + value: string | null | undefined, + fallback = "/studio", +) => toSafeReturnPathname(value, new URL(request.url).origin) ?? fallback; diff --git a/apps/www/src/features/auth/lib/session.ts b/apps/www/src/features/auth/lib/session.ts new file mode 100644 index 000000000..e164e7ede --- /dev/null +++ b/apps/www/src/features/auth/lib/session.ts @@ -0,0 +1,15 @@ +/** + * Provider-neutral session access for the dashboard. + * + * Route guards and loaders import from here rather than from any provider SDK, + * so the same code path serves whichever identity provider a deployment runs. + * The implementation lives behind the adapter slot in + * `features/auth/adapter/session-adapter.ts`. + */ +export { + authRequestMiddleware, + clearSession, + getSessionUser, + performSignOut, + type SessionUser, +} from "@/features/auth/adapter/session-adapter"; diff --git a/apps/www/src/features/auth/lib/workos.ts b/apps/www/src/features/auth/lib/sign-out.ts similarity index 100% rename from apps/www/src/features/auth/lib/workos.ts rename to apps/www/src/features/auth/lib/sign-out.ts diff --git a/apps/www/src/features/auth/lib/standalone-session.ts b/apps/www/src/features/auth/lib/standalone-session.ts new file mode 100644 index 000000000..c797852fb --- /dev/null +++ b/apps/www/src/features/auth/lib/standalone-session.ts @@ -0,0 +1,105 @@ +/** + * Server-side helpers for the standalone root session. + * + * Deliberately not named `*.server.ts`: that pattern is import-protected from + * any client-reachable module, and these helpers are needed by the server-fn + * handlers the dashboard imports. Every export is only ever called from a + * server route handler or a `createServerFn` handler, both of which the + * framework strips from the client bundle. + * + * The signed token minted here is the same one the backend verifies, so the + * dashboard and the API agree on the identity with no shared state beyond the + * signing key. + */ +import { resolveStandaloneAuthConfig } from "@voidhash/core/services/auth/StandaloneAuthConfig"; +import { + STANDALONE_AUTH_COOKIE_NAME, + STANDALONE_AUTH_DEFAULT_TTL_SECONDS, + readCookieValue, + secretsMatch, + signStandaloneAuthToken, + verifyStandaloneAuthToken, + type StandaloneAuthTokenClaims, +} from "@voidhash/core/utils/crypto/standalone-auth-token"; +import { Effect } from "effect"; + +export interface StandaloneSessionUser { + readonly email: string; + readonly id: string; + readonly name: string; +} + +const toUser = (claims: StandaloneAuthTokenClaims): StandaloneSessionUser => ({ + email: claims.email, + id: claims.sub, + name: claims.name ?? claims.email, +}); + +/** + * Verifies the submitted root credentials. + * + * Both fields are compared in constant time, and the username is checked even + * when it is wrong, so a failed attempt takes the same work regardless of which + * field was incorrect. + */ +export const verifyRootCredentials = async (input: { + readonly username: string; + readonly password: string; +}): Promise => { + const config = resolveStandaloneAuthConfig(); + const [usernameMatches, passwordMatches] = await Promise.all([ + Effect.runPromise(secretsMatch(input.username.trim(), config.rootUsername)), + Effect.runPromise(secretsMatch(input.password, config.rootPassword)), + ]); + return usernameMatches && passwordMatches; +}; + +/** Mints a session token for the configured root identity. */ +export const mintStandaloneSessionToken = (): Promise => { + const config = resolveStandaloneAuthConfig(); + return Effect.runPromise( + signStandaloneAuthToken({ + email: config.rootEmail, + name: config.rootUsername, + secret: config.secret, + }), + ); +}; + +/** Reads and verifies the session from a request, or `null` when absent. */ +export const readStandaloneSession = async ( + request: Request, +): Promise<{ token: string; user: StandaloneSessionUser } | null> => { + const token = readCookieValue(request.headers.get("cookie"), STANDALONE_AUTH_COOKIE_NAME); + if (!token) return null; + + const claims = await Effect.runPromise( + verifyStandaloneAuthToken(token, resolveStandaloneAuthConfig().secret).pipe( + Effect.map((value): StandaloneAuthTokenClaims | null => value), + Effect.catch(() => Effect.succeed(null)), + ), + ); + + return claims === null ? null : { token, user: toUser(claims) }; +}; + +/** + * Serializes the session cookie. `Secure` tracks the request scheme so the + * cookie is still accepted over plain-HTTP loopback during evaluation, while a + * real HTTPS deployment always gets it. + */ +export const standaloneSessionCookie = (token: string, request: Request): string => { + const secure = new URL(request.url).protocol === "https:"; + return [ + `${STANDALONE_AUTH_COOKIE_NAME}=${encodeURIComponent(token)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${STANDALONE_AUTH_DEFAULT_TTL_SECONDS}`, + ...(secure ? ["Secure"] : []), + ].join("; "); +}; + +/** Serializes the cookie that clears the session. */ +export const clearedStandaloneSessionCookie = (): string => + `${STANDALONE_AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; diff --git a/apps/www/src/features/auth/lib/workos-user-management.server.test.ts b/apps/www/src/features/auth/lib/workos-user-management.server.test.ts deleted file mode 100644 index 7bf198296..000000000 --- a/apps/www/src/features/auth/lib/workos-user-management.server.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - authenticateWithOrganizationSelectionChallenge, - getOrganizationSelectionChallenge, -} from "./workos-user-management.server"; - -describe("getOrganizationSelectionChallenge", () => { - it("extracts the WorkOS organization-selection challenge", () => { - expect( - getOrganizationSelectionChallenge({ - code: "organization_selection_required", - pendingAuthenticationToken: "pat_123", - rawData: { - organizations: [ - { id: "org_1", name: "Acme" }, - { id: "org_2", name: "Beta" }, - ], - }, - }), - ).toEqual({ - organizations: [ - { id: "org_1", name: "Acme" }, - { id: "org_2", name: "Beta" }, - ], - pendingAuthenticationToken: "pat_123", - }); - }); - - it("reads the pending token from rawData and ignores malformed organizations", () => { - expect( - getOrganizationSelectionChallenge({ - rawData: { - code: "organization_selection_required", - organizations: [{ id: "org_1" }, { name: "Missing id" }, null], - pending_authentication_token: "pat_456", - }, - }), - ).toEqual({ - organizations: [{ id: "org_1", name: "org_1" }], - pendingAuthenticationToken: "pat_456", - }); - }); - - it("returns null for non-organization-selection errors", () => { - expect( - getOrganizationSelectionChallenge({ - code: "email_verification_required", - pendingAuthenticationToken: "pat_123", - }), - ).toBeNull(); - }); -}); - -describe("authenticateWithOrganizationSelectionChallenge", () => { - it("authenticates against the first organization returned by WorkOS", async () => { - const authenticateWithOrganizationSelection = async (payload: unknown) => { - expect(payload).toEqual({ - clientId: "client_123", - organizationId: "org_1", - pendingAuthenticationToken: "pat_123", - session: { - cookiePassword: "cookie_secret", - sealSession: true, - }, - }); - - return { sealedSession: "sealed_session" }; - }; - - const result = await authenticateWithOrganizationSelectionChallenge( - { - userManagement: { - authenticateWithOrganizationSelection, - }, - } as never, - { - code: "organization_selection_required", - pendingAuthenticationToken: "pat_123", - rawData: { - organizations: [ - { id: "org_1", name: "Acme" }, - { id: "org_2", name: "Beta" }, - ], - }, - }, - { clientId: "client_123", cookiePassword: "cookie_secret" }, - ); - - expect(result).toEqual({ sealedSession: "sealed_session" }); - }); -}); diff --git a/apps/www/src/features/auth/lib/workos-user-management.server.ts b/apps/www/src/features/auth/lib/workos-user-management.server.ts deleted file mode 100644 index 48d86d272..000000000 --- a/apps/www/src/features/auth/lib/workos-user-management.server.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { getAuthkit } from "@workos/authkit-tanstack-react-start"; -import { WorkOS, type AuthenticationResponse } from "@workos-inc/node"; - -import { toSafeReturnPathname } from "./validation"; - -type JsonBody = Record; - -export type WorkosAuthConfig = { - apiKey: string; - clientId: string; - cookiePassword: string; -}; - -export type WorkosOrganizationSelectionChallenge = { - organizations: ReadonlyArray<{ - id: string; - name: string; - }>; - pendingAuthenticationToken: string; -}; - -const providerBySlug = { - github: "GitHubOAuth", - google: "GoogleOAuth", - microsoft: "MicrosoftOAuth", -} as const; - -export type OAuthProviderSlug = keyof typeof providerBySlug; - -const getRequiredEnv = (name: string) => { - const value = process.env[name]?.trim(); - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); - } - return value; -}; - -export const getWorkosAuthConfig = (): WorkosAuthConfig => ({ - apiKey: getRequiredEnv("WORKOS_API_KEY"), - clientId: getRequiredEnv("WORKOS_CLIENT_ID"), - cookiePassword: getRequiredEnv("WORKOS_COOKIE_PASSWORD"), -}); - -export const createWorkosClient = (config = getWorkosAuthConfig()) => { - const { apiKey, clientId } = config; - return new WorkOS(apiKey, { clientId }); -}; - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const getRawData = (error: unknown) => { - if (!isRecord(error) || !isRecord(error.rawData)) { - return undefined; - } - - return error.rawData; -}; - -const getErrorCode = (error: unknown) => { - if (!isRecord(error)) { - return undefined; - } - - const rawData = getRawData(error); - return error.code ?? rawData?.code ?? rawData?.error; -}; - -const getPendingAuthenticationToken = (error: unknown) => { - if (!isRecord(error)) { - return undefined; - } - - const rawData = getRawData(error); - if (typeof error.pendingAuthenticationToken === "string") { - return error.pendingAuthenticationToken; - } - - return typeof rawData?.pending_authentication_token === "string" - ? rawData.pending_authentication_token - : undefined; -}; - -const getOrganizations = (error: unknown) => { - const rawData = getRawData(error); - if (!Array.isArray(rawData?.organizations)) { - return []; - } - - return rawData.organizations.flatMap((organization) => { - if (!isRecord(organization) || typeof organization.id !== "string") { - return []; - } - - return [ - { - id: organization.id, - name: typeof organization.name === "string" ? organization.name : organization.id, - }, - ]; - }); -}; - -/** - * Extracts WorkOS's organization-selection challenge from an authentication - * error. Multi-organization users receive this challenge before WorkOS can mint - * a session-bound access token. - */ -export const getOrganizationSelectionChallenge = ( - error: unknown, -): WorkosOrganizationSelectionChallenge | null => { - if (getErrorCode(error) !== "organization_selection_required") { - return null; - } - - const pendingAuthenticationToken = getPendingAuthenticationToken(error); - const organizations = getOrganizations(error); - - if (!pendingAuthenticationToken || organizations.length === 0) { - return null; - } - - return { organizations, pendingAuthenticationToken }; -}; - -/** - * Completes a WorkOS organization-selection challenge using the first - * organization WorkOS returned. The app loads all local memberships after the - * session is saved, so this selection only provides the org context WorkOS - * needs to issue the AuthKit session. - */ -export const authenticateWithOrganizationSelectionChallenge = async ( - workos: WorkOS, - error: unknown, - options: Pick, -): Promise => { - const challenge = getOrganizationSelectionChallenge(error); - const organizationId = challenge?.organizations[0]?.id; - - if (!challenge || !organizationId) { - return null; - } - - return workos.userManagement.authenticateWithOrganizationSelection({ - clientId: options.clientId, - organizationId, - pendingAuthenticationToken: challenge.pendingAuthenticationToken, - session: { - cookiePassword: options.cookiePassword, - sealSession: true, - }, - }); -}; - -const appendHeaders = (target: Headers, source?: Record) => { - if (!source) { - return; - } - - for (const [key, value] of Object.entries(source)) { - if (Array.isArray(value)) { - for (const item of value) { - target.append(key, item); - } - continue; - } - - target.append(key, value); - } -}; - -export const jsonResponse = ( - body: JsonBody, - init: ResponseInit = {}, - extraHeaders?: Record, -) => { - const headers = new Headers(init.headers); - headers.set("Content-Type", "application/json"); - appendHeaders(headers, extraHeaders); - return new Response(JSON.stringify(body), { ...init, headers }); -}; - -export const authErrorResponse = (message: string, status = 400) => - jsonResponse({ error: message }, { status }); - -/** - * Detects whether a WorkOS authentication error means "the user exists but - * their email is not yet verified". WorkOS surfaces this two ways depending on - * the environment's email-verification configuration: - * - * - the structured `email_verification_required` auth-error code (carries a - * `pendingAuthenticationToken` for the inline-code step-up), or - * - a `GenericServerException` whose message is - * "Email ownership must be verified before authentication." (email-link mode). - * - * Both mean the same thing for sign-up: the account was created and the user - * must verify their email before they can authenticate. - */ -export const isEmailVerificationRequiredError = (error: unknown): boolean => { - if (typeof error !== "object" || error === null) { - return false; - } - const candidate = error as { - code?: unknown; - rawData?: { code?: unknown } | null; - message?: unknown; - }; - if ( - candidate.code === "email_verification_required" || - candidate.rawData?.code === "email_verification_required" - ) { - return true; - } - return ( - typeof candidate.message === "string" && - /email ownership must be verified|email[_\s-]?verification|verify your email/i.test( - candidate.message, - ) - ); -}; - -/** - * The pieces of an "email verification required" error that the client needs to - * complete the inline 6-digit-code step-up. - * - * - `pendingAuthenticationToken` lets us call `authenticateWithEmailVerification`, - * which both verifies the code and mints a session (the user lands logged in). - * - `userId` / `email` let us (re)send a verification code via - * `sendVerificationEmail` and look the account up later. - */ -export type EmailVerificationChallenge = { - email?: string; - pendingAuthenticationToken?: string; - userId?: string; -}; - -/** - * Extracts the email-verification challenge from a WorkOS authentication error. - * Returns `null` when the error is not an email-verification-required error. - * - * The structured `email_verification_required` error (code mode) carries the - * `pending_authentication_token` and the `user` object; link-mode - * (`GenericServerException`) carries neither, so the corresponding fields are - * left undefined and the caller falls back to an email lookup. - */ -export const getEmailVerificationChallenge = ( - error: unknown, -): EmailVerificationChallenge | null => { - if (!isEmailVerificationRequiredError(error)) { - return null; - } - const candidate = error as { - pendingAuthenticationToken?: unknown; - rawData?: { - pending_authentication_token?: unknown; - user?: { id?: unknown; email?: unknown } | null; - } | null; - }; - const token = - typeof candidate.pendingAuthenticationToken === "string" - ? candidate.pendingAuthenticationToken - : typeof candidate.rawData?.pending_authentication_token === "string" - ? candidate.rawData.pending_authentication_token - : undefined; - const user = candidate.rawData?.user ?? null; - return { - email: typeof user?.email === "string" ? user.email : undefined, - pendingAuthenticationToken: token, - userId: typeof user?.id === "string" ? user.id : undefined, - }; -}; - -/** - * Looks up a WorkOS user id by email. Used to (re)send verification codes when - * we don't already have the user id from an authentication error. - */ -export const findUserIdByEmail = async ( - workos: WorkOS, - email: string, -): Promise => { - if (!email) { - return undefined; - } - const { data } = await workos.userManagement.listUsers({ email }); - return data[0]?.id; -}; - -export const getJsonBody = async (request: Request): Promise> => { - try { - return (await request.json()) as Partial; - } catch { - return {} as Partial; - } -}; - -export const getSafeReturnPathnameFromRequest = (request: Request, fallback = "/studio") => { - const url = new URL(request.url); - return toSafeReturnPathname(url.searchParams.get("returnPathname"), url.origin) ?? fallback; -}; - -export const getSafeReturnPathname = ( - request: Request, - value: string | null | undefined, - fallback = "/studio", -) => toSafeReturnPathname(value, new URL(request.url).origin) ?? fallback; - -export const responseWithSession = async ( - authResponse: AuthenticationResponse, - body: JsonBody, - init: ResponseInit = {}, -) => { - if (!authResponse.sealedSession) { - throw new Error("WorkOS did not return a sealed session"); - } - - const authkit = await getAuthkit(); - const sessionResult = await authkit.saveSession(undefined, authResponse.sealedSession); - const headers = - sessionResult.headers ?? - (sessionResult.response?.headers.get("Set-Cookie") - ? { "Set-Cookie": sessionResult.response.headers.get("Set-Cookie") as string } - : undefined); - - return jsonResponse(body, init, headers); -}; - -export const redirectWithSession = async ( - authResponse: AuthenticationResponse, - location: string, - status = 303, -) => { - if (!authResponse.sealedSession) { - throw new Error("WorkOS did not return a sealed session"); - } - - const authkit = await getAuthkit(); - const sessionResult = await authkit.saveSession(undefined, authResponse.sealedSession); - const headers = new Headers({ Location: location }); - appendHeaders(headers, sessionResult.headers); - - const setCookie = sessionResult.response?.headers.get("Set-Cookie"); - if (setCookie) { - headers.append("Set-Cookie", setCookie); - } - - return new Response(null, { headers, status }); -}; - -export const getProviderForSlug = (slug: string | undefined) => { - if (!slug || !(slug in providerBySlug)) { - return undefined; - } - - return providerBySlug[slug as OAuthProviderSlug]; -}; diff --git a/apps/www/src/features/auth/middleware/auth.ts b/apps/www/src/features/auth/middleware/auth.ts index 1874aa84e..f459ad163 100644 --- a/apps/www/src/features/auth/middleware/auth.ts +++ b/apps/www/src/features/auth/middleware/auth.ts @@ -1,11 +1,11 @@ import { redirect } from "@tanstack/react-router"; import { createMiddleware } from "@tanstack/react-start"; -import { getAuth } from "@workos/authkit-tanstack-react-start"; +import { getSessionUser } from "../lib/session"; export const authMiddleware = createMiddleware().server(async ({ next }) => { - const auth = await getAuth(); + const user = await getSessionUser(); - if (!auth.user) { + if (!user) { throw redirect({ to: "/auth/login" }); } diff --git a/apps/www/src/features/design/components/docs/colors/index.tsx b/apps/www/src/features/design/components/docs/colors/index.tsx deleted file mode 100644 index 0b50c638b..000000000 --- a/apps/www/src/features/design/components/docs/colors/index.tsx +++ /dev/null @@ -1,282 +0,0 @@ -"use client"; - -import { CheckIcon, CopyIcon } from "lucide-react"; -import { useCallback, useState } from "react"; - -import { cn } from "@/features/design/lib/cn"; - -import { - BRAND_SCALES, - isLightColor, - resolveToken, - scaleSteps, - SEMANTIC_GROUPS, - type SemanticToken, - type ThemeName, -} from "./tokens"; - -const THEMES: ThemeName[] = ["light", "dark"]; - -const useCopyValue = () => { - const [copied, setCopied] = useState(undefined); - - const copy = useCallback((value: string) => { - void navigator.clipboard.writeText(value).then(() => { - setCopied(value); - setTimeout(() => setCopied((current) => (current === value ? undefined : current)), 1200); - }); - }, []); - - return { copied, copy }; -}; - -interface CopyButtonProps { - className?: string; - copied: boolean; - label: string; - onCopy: () => void; - value: string; -} - -function CopyButton({ className, copied, label, onCopy, value }: CopyButtonProps) { - return ( - - ); -} - -interface TokenSwatchProps { - theme: ThemeName; - token: SemanticToken; -} - -function TokenSwatch({ theme, token }: TokenSwatchProps) { - const resolved = resolveToken(token.name, theme); - if (!resolved) { - return null; - } - - const on = token.on ? resolveToken(token.on, theme) : undefined; - const fallbackText = isLightColor(resolved.value) ? "oklch(0% 0 0)" : "oklch(100% 0 0)"; - - return ( -
- - {token.on ? "Aa" : theme === "light" ? "L" : "D"} - -
- ); -} - -interface TokenValueProps { - copiedValue: string | undefined; - onCopy: (value: string) => void; - theme: ThemeName; - token: SemanticToken; -} - -function TokenValue({ copiedValue, onCopy, theme, token }: TokenValueProps) { - const resolved = resolveToken(token.name, theme); - if (!resolved) { - return null; - } - - return ( -
- {theme} - onCopy(resolved.value)} - value={resolved.value} - /> - {resolved.alias ? via {resolved.alias} : null} -
- ); -} - -interface SemanticTokenCardProps { - copiedValue: string | undefined; - onCopy: (value: string) => void; - token: SemanticToken; -} - -function SemanticTokenCard({ copiedValue, onCopy, token }: SemanticTokenCardProps) { - const variable = `var(--${token.name})`; - - return ( -
-
- {THEMES.map((theme) => ( - - ))} -
- -
-
- onCopy(variable)} - value={`--${token.name}`} - /> - {token.on ? ( - - on --{token.on} - - ) : null} -
- -

{token.meaning}

- -
- {token.utilities.map((utility) => ( - - {utility} - - ))} -
- -
- {THEMES.map((theme) => ( - - ))} -
-
-
- ); -} - -/** - * Renders every semantic theme token grouped by intent, with its light and dark - * value, the alias it resolves through, and the Tailwind utilities that map to - * it. Values are read from `@voidhash/ui/styles/brand-theme.css`, so this page - * cannot drift from the theme. - */ -export function SemanticColorTokens() { - const { copied, copy } = useCopyValue(); - - return ( -
- {SEMANTIC_GROUPS.map((group) => ( -
-
-

{group.title}

-

{group.description}

-
- -
- {group.tokens.map((token) => ( - - ))} -
-
- ))} -
- ); -} - -/** - * Renders the raw brand ramps every semantic token is built from. Scales are - * theme-independent — only the semantic tokens above remap between light and - * dark. - */ -export function BrandColorScales() { - const { copied, copy } = useCopyValue(); - - return ( -
- {BRAND_SCALES.map((scale) => { - const steps = scaleSteps(scale.prefix); - - return ( -
-
-

{scale.title}

-

{scale.meaning}

-
- -
- {steps.map((step) => { - const name = `${scale.prefix}-${step}`; - const resolved = resolveToken(name, "light"); - if (!resolved) { - return null; - } - - const variable = `var(--${name})`; - - return ( - - ); - })} -
-
- ); - })} -
- ); -} diff --git a/apps/www/src/features/design/components/docs/colors/tokens.ts b/apps/www/src/features/design/components/docs/colors/tokens.ts deleted file mode 100644 index 9f9124708..000000000 --- a/apps/www/src/features/design/components/docs/colors/tokens.ts +++ /dev/null @@ -1,452 +0,0 @@ -import brandThemeCss from "@voidhash/ui/styles/brand-theme.css?raw"; - -export type ThemeName = "light" | "dark"; - -export interface ResolvedToken { - /** Final color value with every `var()` indirection followed. */ - value: string; - /** The variable this token points at, when it is defined as an alias. */ - alias?: string; -} - -export interface SemanticToken { - /** Custom property name without the leading dashes, e.g. `primary`. */ - name: string; - /** What the token is for and when to reach for it. */ - meaning: string; - /** Tailwind utilities that map onto this token. */ - utilities: string[]; - /** Token used for text/icons placed on top of this one, if there is a pair. */ - on?: string; -} - -export interface SemanticGroup { - title: string; - description: string; - tokens: SemanticToken[]; -} - -export interface BrandScale { - /** Custom property prefix, e.g. `blue-ribbon` for `--blue-ribbon-500`. */ - prefix: string; - title: string; - meaning: string; -} - -const SELECTOR_LIGHT = ":root"; -const SELECTOR_DARK = ".dark"; -const ALIAS_PATTERN = /^var\((--[\w-]+)\)$/; - -/** - * Reads the custom properties declared in a single flat rule block. The brand - * theme keeps `:root` and `.dark` free of nested rules, so a brace scan is - * enough — no CSS parser needed. - */ -const readDeclarations = (css: string, selector: string): Record => { - const selectorStart = css.indexOf(`${selector} {`); - if (selectorStart === -1) { - return {}; - } - - const blockStart = css.indexOf("{", selectorStart); - const blockEnd = css.indexOf("}", blockStart); - const declarations: Record = {}; - - for (const declaration of css.slice(blockStart + 1, blockEnd).split(";")) { - const separator = declaration.indexOf(":"); - if (separator === -1) { - continue; - } - - const name = declaration.slice(0, separator).trim(); - if (name.startsWith("--")) { - declarations[name] = declaration.slice(separator + 1).trim(); - } - } - - return declarations; -}; - -const LIGHT_DECLARATIONS = readDeclarations(brandThemeCss, SELECTOR_LIGHT); -const DARK_DECLARATIONS = { - ...LIGHT_DECLARATIONS, - ...readDeclarations(brandThemeCss, SELECTOR_DARK), -}; - -const declarationsFor = (theme: ThemeName) => - theme === "dark" ? DARK_DECLARATIONS : LIGHT_DECLARATIONS; - -const follow = (declarations: Record, value: string, depth = 0): string => { - const alias = ALIAS_PATTERN.exec(value); - const target = alias ? declarations[alias[1]] : undefined; - if (!target || depth > 10) { - return value; - } - - return follow(declarations, target, depth + 1); -}; - -/** - * Resolves a theme token to its literal color, following alias chains such as - * `--primary` → `--blue-ribbon-600` → `oklch(…)`. Returns `undefined` when the - * token is not declared for the given theme. - */ -export const resolveToken = (name: string, theme: ThemeName): ResolvedToken | undefined => { - const declarations = declarationsFor(theme); - const raw = declarations[`--${name}`]; - if (!raw) { - return undefined; - } - - const alias = ALIAS_PATTERN.exec(raw); - return { - alias: alias?.[1], - value: follow(declarations, raw), - }; -}; - -/** Lists the steps declared for a scale prefix, ordered light to dark. */ -export const scaleSteps = (prefix: string): number[] => - Object.keys(LIGHT_DECLARATIONS) - .map((name) => { - const match = new RegExp(`^--${prefix}-(\\d+)$`).exec(name); - return match ? Number(match[1]) : undefined; - }) - .filter((step): step is number => step !== undefined) - .sort((a, b) => a - b); - -/** - * Estimates whether a color is light enough to need dark text on top. Handles - * the two literal formats used by the theme: `oklch(L% C H)` and hex. - */ -export const isLightColor = (value: string): boolean => { - const oklch = /^oklch\(\s*([\d.]+)%/.exec(value); - if (oklch) { - return Number(oklch[1]) >= 62; - } - - const hex = /^#([\da-f]{6})$/i.exec(value); - if (hex) { - const int = Number.parseInt(hex[1], 16); - const luminance = - (0.2126 * ((int >> 16) & 0xff) + 0.7152 * ((int >> 8) & 0xff) + 0.0722 * (int & 0xff)) / 255; - return luminance >= 0.55; - } - - return true; -}; - -export const SEMANTIC_GROUPS: SemanticGroup[] = [ - { - description: - "The stack of neutral surfaces, from the page canvas up to floating layers. Pick the one that matches how far the element is lifted off the page, not the color you want.", - title: "Surfaces", - tokens: [ - { - meaning: - "The app canvas. Everything else sits on top of it. Set once on `body` — components should not repaint it.", - name: "background", - on: "foreground", - utilities: ["bg-background"], - }, - { - meaning: - "A neutral surface raised off the canvas: toolbars, inspector rails, list rows that need separation without a card border.", - name: "surface", - on: "foreground", - utilities: ["bg-surface"], - }, - { - meaning: - "A recessed surface for wells and tracks — slider rails, progress backgrounds, inset code blocks.", - name: "surface-muted", - on: "foreground", - utilities: ["bg-surface-muted"], - }, - { - meaning: - "Content containers. Use with `--border` for the outline; in dark mode it reads lighter than the canvas so cards float.", - name: "card", - on: "card-foreground", - utilities: ["bg-card"], - }, - { - meaning: "Text and icons inside a card.", - name: "card-foreground", - utilities: ["text-card-foreground"], - }, - { - meaning: - "App chrome around the workspace — designer and editor panels. Slightly darker than `--card` in dark mode so tooling recedes behind content.", - name: "panel", - on: "foreground", - utilities: ["bg-panel"], - }, - { - meaning: - "Layers that float above the page: dropdowns, menus, tooltips, comboboxes, date pickers.", - name: "popover", - on: "popover-foreground", - utilities: ["bg-popover"], - }, - { - meaning: "Text and icons inside a popover layer.", - name: "popover-foreground", - utilities: ["text-popover-foreground"], - }, - ], - }, - { - description: - "Text and icon colors. Body copy is `--foreground`; anything quieter steps down to `--muted-foreground` rather than lowering opacity.", - title: "Content", - tokens: [ - { - meaning: "Default body text, headings, and icons on the canvas.", - name: "foreground", - utilities: ["text-foreground"], - }, - { - meaning: - "Secondary text: labels, helper copy, placeholders, timestamps, inactive icons. The lowest-emphasis text that still meets contrast.", - name: "muted-foreground", - utilities: ["text-muted-foreground"], - }, - { - meaning: - "Quiet neutral fill for badges, skeletons, disabled controls, and hovered table rows.", - name: "muted", - on: "muted-foreground", - utilities: ["bg-muted"], - }, - ], - }, - { - description: - "Interactive intent. One primary action per view; everything competing with it drops to secondary or ghost styling.", - title: "Actions", - tokens: [ - { - meaning: - "The primary action and brand accent — solid buttons, selected states, links, active nav items.", - name: "primary", - on: "primary-foreground", - utilities: ["bg-primary", "text-primary", "border-primary"], - }, - { - meaning: "Text and icons on a primary fill. Stays white in both themes.", - name: "primary-foreground", - utilities: ["text-primary-foreground"], - }, - { - meaning: "Neutral, lower-emphasis actions that sit next to a primary button.", - name: "secondary", - on: "secondary-foreground", - utilities: ["bg-secondary"], - }, - { - meaning: "Text and icons on a secondary fill.", - name: "secondary-foreground", - utilities: ["text-secondary-foreground"], - }, - { - meaning: - "Hover and highlight state for list-like surfaces: menu items, command results, sidebar rows, ghost buttons.", - name: "accent", - on: "accent-foreground", - utilities: ["bg-accent", "hover:bg-accent"], - }, - { - meaning: "Text and icons on an accent highlight.", - name: "accent-foreground", - utilities: ["text-accent-foreground"], - }, - ], - }, - { - description: - "Status colors. Reserved for outcomes and risk — never used decoratively, so their appearance always carries meaning.", - title: "Feedback", - tokens: [ - { - meaning: - "Destructive and irreversible actions, error states, invalid fields. Pair with a confirmation for anything unrecoverable.", - name: "destructive", - on: "destructive-foreground", - utilities: ["bg-destructive", "text-destructive", "border-destructive"], - }, - { - meaning: "Text and icons on a destructive fill.", - name: "destructive-foreground", - utilities: ["text-destructive-foreground"], - }, - { - meaning: "Successful outcomes and healthy status — completed steps, live deployments.", - name: "success", - on: "success-foreground", - utilities: ["bg-success", "text-success"], - }, - { - meaning: "Text and icons on a success fill.", - name: "success-foreground", - utilities: ["text-success-foreground"], - }, - ], - }, - { - description: - "Hairlines, control outlines, and focus. These are the only tokens allowed to draw structure — do not fake borders with a background color.", - title: "Borders and focus", - tokens: [ - { - meaning: - "Default hairline between surfaces. Applied globally by the base layer, so most elements inherit it without a border utility.", - name: "border", - utilities: ["border-border"], - }, - { - meaning: "Outline of form controls — inputs, textareas, selects, checkboxes.", - name: "input", - utilities: ["border-input"], - }, - { - meaning: - "Keyboard focus ring. The base layer renders it at 50% opacity (`outline-ring/50`), so focus reads clearly without shouting.", - name: "ring", - utilities: ["ring-ring", "outline-ring/50"], - }, - ], - }, - { - description: - "The sidebar runs its own surface stack so navigation can go darker than the app without dragging the rest of the UI with it.", - title: "Sidebar", - tokens: [ - { - meaning: "Sidebar background. Pure black in dark mode, pinning navigation to the far edge.", - name: "sidebar", - on: "sidebar-foreground", - utilities: ["bg-sidebar"], - }, - { - meaning: "Sidebar labels and icons.", - name: "sidebar-foreground", - utilities: ["text-sidebar-foreground"], - }, - { - meaning: - "Active navigation item. Deliberately neutral rather than brand blue so the sidebar does not compete with in-page primary actions.", - name: "sidebar-primary", - on: "sidebar-primary-foreground", - utilities: ["bg-sidebar-primary"], - }, - { - meaning: "Text on an active navigation item.", - name: "sidebar-primary-foreground", - utilities: ["text-sidebar-primary-foreground"], - }, - { - meaning: "Hovered navigation item.", - name: "sidebar-accent", - on: "sidebar-accent-foreground", - utilities: ["bg-sidebar-accent"], - }, - { - meaning: "Text on a hovered navigation item.", - name: "sidebar-accent-foreground", - utilities: ["text-sidebar-accent-foreground"], - }, - { - meaning: "Dividers inside the sidebar and the seam against the app canvas.", - name: "sidebar-border", - utilities: ["border-sidebar-border"], - }, - { - meaning: "Focus ring for sidebar controls.", - name: "sidebar-ring", - utilities: ["ring-sidebar-ring"], - }, - ], - }, - { - description: - "Categorical series colors, ordered by how they should be assigned. Use them in sequence so the same series index keeps the same color across charts.", - title: "Data visualization", - tokens: [ - { - meaning: "First series — the metric the chart is about.", - name: "chart-1", - utilities: ["fill-chart-1", "stroke-chart-1"], - }, - { - meaning: "Second series.", - name: "chart-2", - utilities: ["fill-chart-2", "stroke-chart-2"], - }, - { - meaning: "Third series.", - name: "chart-3", - utilities: ["fill-chart-3", "stroke-chart-3"], - }, - { - meaning: "Fourth series.", - name: "chart-4", - utilities: ["fill-chart-4", "stroke-chart-4"], - }, - { - meaning: "Fifth series. Beyond five categories, group the tail into an “Other” bucket.", - name: "chart-5", - utilities: ["fill-chart-5", "stroke-chart-5"], - }, - ], - }, -]; - -export const BRAND_SCALES: BrandScale[] = [ - { - meaning: - "The Voidhash brand hue. Step 600 is `--primary`, step 500 is `--ring`. Lighter steps back tinted surfaces; darker steps are for text on tinted backgrounds.", - prefix: "blue-ribbon", - title: "Blue Ribbon", - }, - { - meaning: - "Secondary brand hue. Used for the second chart series and for AI/agent surfaces that need to read as distinct from primary actions.", - prefix: "electric-violet", - title: "Electric Violet", - }, - { - meaning: "Third chart series and accent illustrations. Not used for interactive states.", - prefix: "fuchsia-pink", - title: "Fuchsia Pink", - }, - { - meaning: - "Danger. Step 600 is `--destructive` in light mode, step 500 in dark mode where the surface is darker.", - prefix: "radical-red", - title: "Radical Red", - }, - { - meaning: - "Reserved for high-urgency, non-destructive states. No semantic token maps to it yet, so reference the scale directly and document the usage.", - prefix: "blaze-orange", - title: "Blaze Orange", - }, - { - meaning: - "Warnings and pending states — there is no `--warning` token, so use `amber-500`/`amber-600` when you need caution without danger. Also the fourth chart series.", - prefix: "amber", - title: "Amber", - }, - { - meaning: "Success and healthy status. Step 600 is `--success`; step 500 is the fifth series.", - prefix: "pistachio", - title: "Pistachio", - }, - { - meaning: - "The neutral ramp every surface, border, and text token is built from. Light mode maps 50–200 to surfaces and 500–900 to text; dark mode inverts that.", - prefix: "zinc", - title: "Zinc", - }, -]; diff --git a/apps/www/src/features/design/components/docs/component-overview.tsx b/apps/www/src/features/design/components/docs/component-overview.tsx deleted file mode 100644 index f2488d0cc..000000000 --- a/apps/www/src/features/design/components/docs/component-overview.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import Preview02Example from "./component-overview/index"; - -/** - * Renders shadcn's preview-02 composition using the Voidhash UI primitives. - */ -export function ComponentOverview() { - return ( -
- -
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx deleted file mode 100644 index 24981f391..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Field, FieldGroup, FieldLabel } from "@voidhash/ui"; -import { Input } from "@voidhash/ui"; -import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function AccountAccess() { - return ( - - - Account Access - Update your credentials or re-authenticate. - - - - - Email Address - - - -
- - - - - - - - - - - - - Danger Zone - - Archive account and remove catalog - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx deleted file mode 100644 index 74c81fe07..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import { Bar, BarChart, XAxis } from "recharts"; - -import { Badge } from "@voidhash/ui"; -import { Button } from "@voidhash/ui"; -import { Card, CardContent, CardDescription, CardTitle } from "@voidhash/ui"; -import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; - -const activityData = [ - { month: "Jan", amount: 40 }, - { month: "Feb", amount: 55 }, - { month: "Mar", amount: 35 }, - { month: "Apr", amount: 60 }, - { month: "May", amount: 45 }, - { month: "Jun", amount: 50 }, - { month: "Jul", amount: 65 }, - { month: "Aug", amount: 40 }, - { month: "Sep", amount: 55 }, - { month: "Oct", amount: 70 }, - { month: "Nov", amount: 45 }, - { month: "Dec", amount: 80 }, -]; - -const chartConfig = { - amount: { - label: "Activity", - color: "var(--chart-2)", - }, -} satisfies ChartConfig; - -export function CardOverview() { - return ( -
- - - Card Balance - US$12.94 - US$11,337.06 Available - - - - -
- Payment Due - 1 Apr -
- -
-
- - -
- Yearly Activity - +US$0.25 Daily Cash -
- - - String(v).slice(0, 1)} - className="text-[10px]" - /> - } /> - - - -
-
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx deleted file mode 100644 index 1c7e3f98f..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Badge } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Item, ItemContent } from "@voidhash/ui"; -import { Separator } from "@voidhash/ui"; - -export function ClaimableBalance() { - return ( - - - Claimable Balance - $0.00 - - - Pending Setup - - - - - -
- Net Royalties - $0.00 -
-
- Processing Fee - -$0.00 -
- -
- Total Ready to Claim - $0.00 USD -
-
-
-
- - - Once your bank is connected, balances over $10.00 are automatically eligible for monthly - distribution on the 15th of each month. - - -
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx deleted file mode 100644 index 120fb2ecf..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { Bar, BarChart, XAxis } from "recharts"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; -import { Item, ItemContent, ItemDescription } from "@voidhash/ui"; -import { useDesignSystemSearchParams } from "../preview-config"; - -const chartData = [ - { month: "Dec", amount: 800 }, - { month: "Jan", amount: 1100 }, - { month: "Feb", amount: 900 }, - { month: "Mar", amount: 1300 }, - { month: "Apr", amount: 750 }, - { month: "May", amount: 1400 }, -]; - -const chartConfig = { - amount: { - label: "Contribution", - color: "var(--chart-2)", - }, -} satisfies ChartConfig; - -export function ContributionHistory() { - const [params] = useDesignSystemSearchParams(); - const isRounded = !["lyra", "sera"].includes(params.style); - - return ( - - - Contribution History - Last 6 months of activity - - - - - - } - /> - - - - - -
- - - - Upcoming - - May 25, 2024 - $1,000 scheduled - - - - - - Auto-Save Plan - - Accelerated - Recurring weekly - - -
-
- - - -
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx deleted file mode 100644 index 383abf71e..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Button } from "@voidhash/ui"; -import { Card, CardContent, CardDescription, CardFooter } from "@voidhash/ui"; -import { Item } from "@voidhash/ui"; -import { Label } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function CoverArt() { - return ( - - - - - - - - - - - - Minimum 3000 × 3000px -
- JPEG or PNG only -
-
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx deleted file mode 100644 index d1c67686c..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { Bar, BarChart } from "recharts"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; -import { Item, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@voidhash/ui"; -import { useDesignSystemSearchParams } from "../preview-config"; -import { IconPlaceholder } from "../icon-placeholder"; - -const HOLDINGS = [ - { - name: "Vanguard VIG", - shares: "450 Shares", - amount: "$1,842.10", - data: [ - { q: "Q1", value: 380 }, - { q: "Q2", value: 420 }, - { q: "Q3", value: 390 }, - { q: "Q4", value: 652 }, - ], - }, - { - name: "S&P 500 VOO", - shares: "112 Shares", - amount: "$928.40", - data: [ - { q: "Q1", value: 180 }, - { q: "Q2", value: 210 }, - { q: "Q3", value: 320 }, - { q: "Q4", value: 218 }, - ], - }, - { - name: "Apple AAPL", - shares: "85 Shares", - amount: "$340.00", - data: [ - { q: "Q1", value: 60 }, - { q: "Q2", value: 70 }, - { q: "Q3", value: 120 }, - { q: "Q4", value: 90 }, - ], - }, - { - name: "Realty Income", - shares: "320 Shares", - amount: "$1,139.50", - data: [ - { q: "Q1", value: 240 }, - { q: "Q2", value: 260 }, - { q: "Q3", value: 280 }, - { q: "Q4", value: 360 }, - ], - }, -]; - -const miniChartConfig = { - value: { - label: "Dividend", - color: "var(--chart-2)", - }, -} satisfies ChartConfig; - -export function DividendIncome() { - const [params] = useDesignSystemSearchParams(); - const isRounded = !["lyra", "sera"].includes(params.style); - - return ( - - - Q2 Dividend Income - - Quarterly dividend payouts across your portfolio holdings. - - - - - - - - {HOLDINGS.map((holding) => ( - - - {holding.name} - {holding.shares} - - - - } /> - - - - - {holding.amount} - - - ))} - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx deleted file mode 100644 index 9f37a47ed..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button } from "@voidhash/ui"; -import { Card, CardContent } from "@voidhash/ui"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function EmptyConnectBank() { - return ( - - - - - - - - Connect Bank - - Link your payout method to receive monthly royalty distributions automatically. - - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx deleted file mode 100644 index 7f79c0ed0..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Button } from "@voidhash/ui"; -import { Card, CardContent } from "@voidhash/ui"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function EmptyDistributeTrack() { - return ( - - - - - - - - Distribute Track - - Upload your first master to start reaching listeners on Spotify, Apple Music, and - more. - - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx deleted file mode 100644 index 7411baae8..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button } from "@voidhash/ui"; -import { Card, CardContent } from "@voidhash/ui"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function EmptyExploreCatalog() { - return ( - - - - - - - - Explore Catalog - - Check your ISRC codes, metadata, and visual assets before going live. - - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx deleted file mode 100644 index b895b6033..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@voidhash/ui"; -import { Button } from "@voidhash/ui"; -import { Card, CardContent, CardFooter } from "@voidhash/ui"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@voidhash/ui"; - -const GENERAL_QUESTIONS = [ - { - q: "How secure is my financial data with Ledger?", - a: "We use bank-level AES-256 encryption, SOC 2 Type II certified infrastructure, and never store your credentials. All connections use read-only access tokens. We are a SEC registered investment advisor.", - }, - { - q: "How do I connect my bank or investment accounts?", - a: "Go to Settings > Linked Accounts and search for your institution. We support over 12,000 banks and brokerages via Plaid and MX.", - }, - { - q: "Can I export my data for tax purposes?", - a: "Yes. Navigate to Reports > Tax Export to download a CSV or PDF summary of your transactions, dividends, and capital gains for any tax year.", - }, -]; - -const SYNC_QUESTIONS = [ - { - q: "How often does data refresh?", - a: "Connected institutions sync every six hours, and you can pull a manual refresh from the account detail view at any time.", - }, - { - q: "Why is a transaction missing?", - a: "Pending transactions appear once the institution posts them. If a posted transaction is still missing after 48 hours, reconnect the account from Settings.", - }, - { - q: "Can I import a statement manually?", - a: "Yes. Upload a CSV or OFX file from the account detail view and map the columns once — the mapping is remembered for later imports.", - }, -]; - -const GOALS_QUESTIONS = [ - { - q: "How do I set up a custom financial goal?", - a: "Click New Goal from the Savings Targets card. Choose a category, set a target amount and date, and we'll calculate the monthly contribution needed.", - }, - { - q: "Can I track multiple goals at once?", - a: "Yes. Pro accounts can track unlimited goals. Basic accounts support up to 3 active goals.", - }, - { - q: "How are monthly contributions calculated?", - a: "We divide the remaining amount by the number of months until your target date, adjusted for your current savings rate and any auto-transfer schedules.", - }, -]; - -function QuestionList({ questions }: { questions: { q: string; a: string }[] }) { - return ( - - {questions.map((item, index) => ( - - {item.q} - {item.a} - - ))} - - ); -} - -export function Faq() { - return ( - - - - - - General - - - Sync - - - Goals - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx deleted file mode 100644 index 240d3a142..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Badge } from "@voidhash/ui"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function FrontDoor() { - return ( - - - Front Door - Smart Lock Pro - -
- Locked - -
-
-
- -
- - Live - -
-
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx deleted file mode 100644 index d5d5b2cc3..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@voidhash/ui"; - -export function IndexInvesting() { - return ( - - - Dollar-Cost Averaging - A strategy for building wealth over time. - - - - - Over time - - , this smooths out the average cost of your investments. When prices drop, your fixed - amount buys more shares. When prices rise, you buy fewer. The result is a lower average - cost per share compared to lump-sum investing during volatile periods. - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx deleted file mode 100644 index db97fc696..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle } from "@voidhash/ui"; -import { Slider } from "@voidhash/ui"; -import { Switch } from "@voidhash/ui"; -import { ToggleGroup, ToggleGroupItem } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -const SCENES = { - cooking: { brightness: [90], colorTemp: [70], volume: [30], fade: [0] }, - dining: { brightness: [50], colorTemp: [40], volume: [20], fade: [60] }, - nightlight: { brightness: [15], colorTemp: [20], volume: [0], fade: [80] }, - focus: { brightness: [100], colorTemp: [85], volume: [0], fade: [0] }, -} as const; - -export function KitchenIsland() { - const [enabled, setEnabled] = React.useState(true); - const [scene, setScene] = React.useState("cooking"); - const [brightness, setBrightness] = React.useState([90]); - const [colorTemp, setColorTemp] = React.useState([70]); - const [volume, setVolume] = React.useState([30]); - const [fade, setFade] = React.useState([0]); - - const handleSceneChange = (value: string) => { - if (!value) return; - setScene(value); - const preset = SCENES[value as keyof typeof SCENES]; - setBrightness([...preset.brightness]); - setColorTemp([...preset.colorTemp]); - setVolume([...preset.volume]); - setFade([...preset.fade]); - }; - - return ( - - - Kitchen Island - Hue Color Ambient - - - - - -
- Scenes - - - Cooking - - - Dining - - - Nightlight - - - Focus - - -
- - - - - - - Brightness - - - - - - - - - - - Color Temp - - - - - - - - - - - Volume - - - - - - - - - - - Fade - - - - - - -
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx deleted file mode 100644 index 38dc57158..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Card, CardContent, CardHeader } from "@voidhash/ui"; -import { Skeleton } from "@voidhash/ui"; - -export function LoadingCard() { - return ( - - - - - - - -
- - - -
-
- - -
-
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx deleted file mode 100644 index 0bc6c3d2f..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Field, FieldGroup, FieldLabel } from "@voidhash/ui"; -import { Input } from "@voidhash/ui"; - -export function NewMilestone() { - return ( - - - Set a new milestone - - Define your financial target and we'll help you pace your savings. - - - - - - Goal Name - - -
- - Target Amount - - - - Target Date - - -
-
-
- - - - -
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx deleted file mode 100644 index 3074bebb6..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Checkbox } from "@voidhash/ui"; -import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel } from "@voidhash/ui"; - -const NOTIFICATIONS = [ - { - id: "transactions", - label: "Transaction alerts", - description: "Deposits, withdrawals, and transfers.", - defaultChecked: true, - }, - { - id: "security", - label: "Security alerts", - description: "Login attempts and account changes.", - defaultChecked: true, - }, - { - id: "goals", - label: "Goal milestones", - description: "Updates at 25%, 50%, 75%, and 100%.", - defaultChecked: false, - }, - { - id: "market", - label: "Market updates", - description: "Daily portfolio summary and price alerts.", - defaultChecked: false, - }, -]; - -export function NotificationSettings() { - const [checked, setChecked] = React.useState>( - Object.fromEntries(NOTIFICATIONS.map((n) => [n.id, n.defaultChecked])), - ); - - const allChecked = NOTIFICATIONS.every((n) => checked[n.id]); - const someChecked = NOTIFICATIONS.some((n) => checked[n.id]) && !allChecked; - - const handleSelectAll = (value: boolean) => { - setChecked(Object.fromEntries(NOTIFICATIONS.map((n) => [n.id, value]))); - }; - - const handleToggle = (id: string, value: boolean) => { - setChecked((prev) => ({ ...prev, [id]: value })); - }; - - return ( - - - Notifications - Choose what you want to be notified about. - - - - - handleSelectAll(!!v)} - /> - - Select all - - - {NOTIFICATIONS.map((n) => ( - - handleToggle(n.id, !!v)} - /> - - {n.label} - {n.description} - - - ))} - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx deleted file mode 100644 index 0e7be017d..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@voidhash/ui"; -import { Button } from "@voidhash/ui"; -import { Card, CardContent, CardHeader } from "@voidhash/ui"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@voidhash/ui"; -import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function Payments() { - return ( - - - - - - Home - - - - - - - - - - Profile - Statements - Documents - - - - - - - Payments - - - - - - - - - - - - - Change transfer limit - Adjust how much you can send from your balance. - - - - - - - - - - - Scheduled transfers - Set up a transfer to send at a later date. - - - - - - - - - - - Direct Debits - Set up and manage regular payments. - - - - - - - - - - - Recurring card payments - Manage your repeated card transactions. - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx deleted file mode 100644 index a8a402735..000000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Field, FieldDescription, FieldGroup, FieldLabel } from "@voidhash/ui"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@voidhash/ui"; -import { Slider } from "@voidhash/ui"; -import { Textarea } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function PayoutThreshold() { - const [amount, setAmount] = React.useState([2500]); - - return ( - - - Payout Threshold - - Set the minimum balance required before a payout is triggered. - - - - - - - - - Preferred Currency - - - -
- Minimum Payout Amount - ${amount[0].toFixed(2)} -
- -
- $50 (MIN) - $10,000 (MAX) -
-
- - Notes -