diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 000000000..d0f47742c --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "voidhash", + "interface": { + "displayName": "Voidhash" + }, + "plugins": [ + { + "name": "voidhash", + "source": { + "source": "local", + "path": "./plugins/voidhash" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..d68adbb21 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "studio", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@voidhash/studio", "dev", "--port", "4830"], + "port": 4830 + }, + { + "name": "deployed", + "runtimeExecutable": "python3", + "runtimeArgs": [ + "-m", + "http.server", + "4831", + "--directory", + "examples/react-native-example/.voidhash/.build" + ], + "port": 4831 + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..c0962f05e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.turbo +**/.alchemy +**/.next +**/.output +**/.turbo +**/coverage +**/dist +**/node_modules +**/.env +**/.env.* +*.log diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..c6d043880 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,75 @@ +name: Bug report +description: Report a reproducible Community product or self-hosting defect. +title: "bug: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Do not report security vulnerabilities here. Follow SECURITY.md and use the private reporting channel. + Voidhash is currently in private alpha; older commits and unpublished artifacts are unsupported. + - type: dropdown + id: area + attributes: + label: Affected area + options: + - Self-host runtime + - Backend API + - Dashboard or designer + - React Native SDK + - Web or Node SDK + - CLI or Studio + - Mimic + - Documentation + - Other Community package + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Provide the smallest reproducible sequence, project, or repository. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: input + id: revision + attributes: + label: Voidhash revision or package version + placeholder: Commit SHA or exact package version + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Include OS, Node, pnpm, Docker, SDK platform, and relevant provider versions. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs or diagnostics + description: Remove credentials, personal data, API keys, and customer content. + render: shell + - type: checkboxes + id: checks + attributes: + label: Checklist + options: + - label: I reproduced this on the latest preview revision. + required: true + - label: This report does not contain a vulnerability or sensitive data. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..cbe773c91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/voidhashcom/voidhash/security/policy + about: Report vulnerabilities privately; never disclose them in a public issue. + - name: Product documentation + url: https://voidhash.com/docs + about: Read the product and SDK documentation. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..b2a6e91ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: Feature request +description: Propose an improvement to the Community product or self-hosting experience. +title: "feature: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: External pull-request intake is currently closed, but well-scoped product proposals are welcome. + - type: textarea + id: problem + attributes: + label: Problem + description: Explain the user problem or operational limitation without prescribing an implementation. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed outcome + description: Describe the observable behavior and who benefits. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: edition + attributes: + label: Intended edition + options: + - Community + - Cloud operations + - Enterprise + - Unsure + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..52a319b63 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## Summary + + + +## Validation + +- [ ] I ran the smallest relevant tests. +- [ ] I ran `pnpm typecheck` and `pnpm test`, or documented why they do not apply. +- [ ] Self-host or runtime changes pass the relevant Compose smoke suites. +- [ ] I updated public JSDoc and user-facing documentation where behavior changed. + +## Publication boundary + +- [ ] `pnpm check:publication` passes. +- [ ] Community code uses only `@voidhash/*` scopes and provider-neutral platform interfaces. +- [ ] Every changed package retains the correct MIT or AGPL license metadata and license text. +- [ ] The change contains no credentials, customer data, internal hostnames, or private operations/Enterprise code. + +## Security + +- [ ] I considered tenant isolation, authentication, replay/idempotency, storage ownership, and untrusted input where relevant. +- [ ] I did not disclose a suspected vulnerability in this pull request. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..31d0c5c62 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: Repository CI + +on: + push: + branches: [main, preview] + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: repository-ci-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + run_install: false + + - name: Check publication boundary + run: node scripts/check-publication-boundary.mjs + + - name: Setup Node.js + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Restore Turborepo cache + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + + - name: Install dependencies + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + run: pnpm install --frozen-lockfile + + - name: Validate purchase and restore contracts + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + run: pnpm test:purchase-restore + + - name: Build + 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 + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + run: pnpm test --concurrency=2 + + storekit-purchase-validation: + name: StoreKit purchase validation + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: macos-15 + timeout-minutes: 15 + env: + DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Test StoreKit transaction retention + run: swift test --package-path libraries/react-native + + - name: Test StoreKit purchase and restore + working-directory: libraries/react-native + run: bash ios-storekit-tests/run-tests.sh diff --git a/.github/workflows/job_test_api_local.yml b/.github/workflows/job_test_api_local.yml deleted file mode 100644 index 976a4600b..000000000 --- a/.github/workflows/job_test_api_local.yml +++ /dev/null @@ -1,61 +0,0 @@ -# name: Test API Local -# on: -# workflow_call: - -# jobs: -# test: -# name: API Test Local -# timeout-minutes: 60 -# runs-on: blacksmith-2vcpu-ubuntu-2404 -# steps: -# - uses: actions/checkout@v4 - -# - name: Delete huge unnecessary tools folder -# run: rm -rf /opt/hostedtoolcache - -# - name: Run containers -# run: docker compose -f ./deployment/docker-compose.yaml up -d - -# - name: Install -# uses: ./.github/actions/install -# with: -# ts: true -# go: true - -# - name: Build -# run: pnpm turbo run build --filter=./apps/api... - -# - name: Load Schema into MySQL -# run: pnpm drizzle-kit push -# working-directory: internal/db -# env: -# DRIZZLE_DATABASE_URL: "mysql://unkey:password@localhost:3306/unkey" - -# - name: Migrate ClickHouse -# run: goose up -# env: -# GOOSE_DRIVER: clickhouse -# GOOSE_DBSTRING: "tcp://default:password@127.0.0.1:9000" -# GOOSE_MIGRATION_DIR: ./internal/clickhouse/schema - -# - name: Test -# run: pnpm vitest run -c vitest.integration.ts -# working-directory: apps/api -# env: -# UNKEY_BASE_URL: http://localhost:8787 -# DATABASE_HOST: localhost:3900 -# DATABASE_USERNAME: unkey -# DATABASE_PASSWORD: password -# TEST_LOCAL: true - -# - name: Dump logs -# if: always() -# run: docker compose -f ./deployment/docker-compose.yaml logs --no-color > ./docker.logs - -# - name: Upload logs -# uses: actions/upload-artifact@v4 -# if: always() -# with: -# name: ${{github.run_id}}-${{github.run_number}}-api.logs -# path: docker.logs -# retention-days: 7 diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml new file mode 100644 index 000000000..10f7135ca --- /dev/null +++ b/.github/workflows/osv-scanner.yml @@ -0,0 +1,50 @@ +name: OSV Scanner + +on: + pull_request: + paths: + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - apps/**/package.json + - examples/**/package.json + - libraries/**/package.json + - packages/**/package.json + - selfhost/**/package.json + - .github/workflows/osv-scanner.yml + push: + branches: [main, preview] + paths: + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - apps/**/package.json + - examples/**/package.json + - libraries/**/package.json + - packages/**/package.json + - selfhost/**/package.json + - .github/workflows/osv-scanner.yml + schedule: + - cron: "30 12 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: osv-scanner-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Production lockfile + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Reject known vulnerabilities + uses: google/osv-scanner-action/osv-scanner-action@8dc09193bb540e09b23da07ad7e30bd33bf87018 + with: + scan-args: |- + --lockfile=pnpm-lock.yaml diff --git a/.github/workflows/pr-packages.yml b/.github/workflows/pr-packages.yml new file mode 100644 index 000000000..5bcb6fc1a --- /dev/null +++ b/.github/workflows/pr-packages.yml @@ -0,0 +1,115 @@ +# Publishes per-commit tarballs of all publishable packages to the pr-package +# service at pkg.voidha.sh so any commit can be tried out without an npm +# release: +# +# pnpm add @voidhash/node@https://pkg.voidha.sh/node/ +# +# - push to main/preview → tarballs tagged with sha + branch name +# - pull_request → tarballs tagged with sha + branch + `pr-` (1 week +# TTL), plus a sticky install-instructions comment on +# the PR +# +# Tags persist past PR close so install URLs keep resolving; the bucket's TTL +# handles cleanup. All packages publish on every run — with this few packages +# that is cheaper than maintaining diff-driven change detection, and it +# guarantees the workspace-dep URL rewriting always has a same-sha tarball to +# point at. The package list lives in scripts/publish-pr-packages.mjs. + +name: PR Packages + +on: + push: + branches: [main, preview] + paths: + - packages/generated-clients/** + - packages/shared/** + - apps/cli/** + - apps/studio/** + - libraries/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/pr-packages.yml + - scripts/publish-pr-packages.mjs + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - packages/generated-clients/** + - packages/shared/** + - apps/cli/** + - apps/studio/** + - libraries/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/pr-packages.yml + - scripts/publish-pr-packages.mjs + +permissions: + contents: read + pull-requests: write + +# Cancel superseded PR runs; let branch-push runs finish so a published sha is +# never left half-uploaded. +concurrency: + group: pr-packages-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + publish: + # Fork PRs can't access PR_PACKAGE_TOKEN — skip them entirely. + if: >- + github.event_name == 'push' || + (github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build and publish pr-packages + env: + PR_PACKAGE_TOKEN: ${{ secrets.PR_PACKAGE_TOKEN }} + SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_PACKAGE_TTL: ${{ github.event_name == 'pull_request' && '1 week' || '' }} + COMMENT_FILE: ${{ runner.temp }}/pr-packages-comment.md + run: node scripts/publish-pr-packages.mjs + + - name: Comment install instructions on PR + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + COMMENT_FILE: ${{ runner.temp }}/pr-packages-comment.md + run: | + set -euo pipefail + MARKER="" + BODY=$(cat "$COMMENT_FILE") + + existing=$(gh api --paginate \ + "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | startswith(\"${MARKER}\")) | .id" \ + | head -1) + + if [ -n "$existing" ]; then + gh api -X PATCH "repos/${REPO}/issues/comments/${existing}" -f body="$BODY" + else + gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" -f body="$BODY" + fi diff --git a/.github/workflows/publish-api-spec-canary-preview.yml b/.github/workflows/publish-api-spec-canary-preview.yml deleted file mode 100644 index 00782bfeb..000000000 --- a/.github/workflows/publish-api-spec-canary-preview.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Publish API Spec Canary (Preview) - -on: - push: - branches: - - preview - paths: - - packages/api-spec/** - - pnpm-lock.yaml - - pnpm-workspace.yaml - - package.json - - .github/workflows/publish-api-spec-canary-preview.yml - -permissions: - contents: read - -concurrency: api-spec-canary-preview-${{ github.ref }} - -jobs: - publish-canary: - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: https://registry.npmjs.org - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.19.0 - run_install: false - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck api-spec - run: pnpm --filter @voidhash/api-spec typecheck - - - name: Publish canary from preview - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - set -euo pipefail - short_sha="${GITHUB_SHA::7}" - canary_version="$(SHORT_SHA="$short_sha" node -e 'const fs=require("node:fs"); const p="./packages/api-spec/package.json"; const pkg=JSON.parse(fs.readFileSync(p,"utf8")); const run=process.env.GITHUB_RUN_NUMBER; const attempt=process.env.GITHUB_RUN_ATTEMPT; const sha=process.env.SHORT_SHA; pkg.version=`${pkg.version}-canary.${run}.${attempt}.${sha}`; fs.writeFileSync(p, JSON.stringify(pkg,null,2)+"\n"); process.stdout.write(pkg.version);')" - echo "Publishing @voidhash/api-spec@${canary_version}" - cd packages/api-spec - npm publish --tag canary --access public diff --git a/.github/workflows/publish-api-spec-stable.yml b/.github/workflows/publish-api-spec-stable.yml deleted file mode 100644 index 57b4bbc77..000000000 --- a/.github/workflows/publish-api-spec-stable.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Publish API Spec Stable - -on: - workflow_dispatch: - -permissions: - contents: read - -concurrency: api-spec-stable-${{ github.ref }} - -jobs: - publish-stable: - runs-on: blacksmith-2vcpu-ubuntu-2404 - outputs: - published_version: ${{ steps.version.outputs.version }} - steps: - - name: Ensure main branch trigger - run: | - if [ "${GITHUB_REF}" != "refs/heads/main" ]; then - echo "This workflow can only run from main. Current ref: ${GITHUB_REF}" >&2 - exit 1 - fi - - - name: Checkout main - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: https://registry.npmjs.org - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.19.0 - run_install: false - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck api-spec - run: pnpm --filter @voidhash/api-spec typecheck - - - name: Resolve package version - id: version - run: | - version="$(node -p "require('./packages/api-spec/package.json').version")" - echo "version=${version}" >> "$GITHUB_OUTPUT" - - - name: Fail if version already exists - run: | - if npm view "@voidhash/api-spec@${{ steps.version.outputs.version }}" version >/dev/null 2>&1; then - echo "@voidhash/api-spec@${{ steps.version.outputs.version }} already exists on npm." >&2 - exit 1 - fi - - - name: Publish stable - working-directory: packages/api-spec - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --tag latest --access public diff --git a/.github/workflows/secret-scanning.yml b/.github/workflows/secret-scanning.yml new file mode 100644 index 000000000..77f3b2636 --- /dev/null +++ b/.github/workflows/secret-scanning.yml @@ -0,0 +1,41 @@ +name: Secret scanning + +on: + push: + branches: [main, preview] + pull_request: + schedule: + - cron: "15 11 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: secret-scanning-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Secret scanners + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Scan the published tree + run: >- + docker run --rm + --volume "$GITHUB_WORKSPACE:/repo:ro" + ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f + dir /repo --config /repo/.gitleaks.toml --redact --no-banner --exit-code 1 + + - name: Scan verified secrets in published history + run: >- + docker run --rm + --volume "$GITHUB_WORKSPACE:/repo:ro" + ghcr.io/trufflesecurity/trufflehog:3.95.9@sha256:59b244249d1a1aef4baa24fe73d3c931616264482580d806d77f6c74d26b3e42 + git file:///repo --fail --only-verified --no-update diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 000000000..08c803a2f --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,57 @@ +name: Security audit + +on: + push: + branches: [main, preview] + paths: + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - apps/**/package.json + - examples/**/package.json + - libraries/**/package.json + - packages/**/package.json + - selfhost/**/package.json + - .github/workflows/security-audit.yml + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + paths: + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - apps/**/package.json + - examples/**/package.json + - libraries/**/package.json + - packages/**/package.json + - selfhost/**/package.json + - .github/workflows/security-audit.yml + +permissions: + contents: read + +concurrency: + group: security-audit-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + production-dependencies: + name: Production dependencies + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + run_install: false + + - name: Reject Critical and High advisories + run: pnpm audit --prod --audit-level high diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml new file mode 100644 index 000000000..ba96f6926 --- /dev/null +++ b/.github/workflows/selfhost.yml @@ -0,0 +1,79 @@ +name: Self-host Compose + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + push: + branches: + - main + - preview + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: selfhost-compose-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + COMPOSE_PROJECT_NAME: voidhash-selfhost-ci-${{ github.run_id }}-${{ github.run_attempt }} + +jobs: + smoke: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Start stateful stores + run: docker compose -f selfhost/docker-compose.yml --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 + + - 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 + + - name: Reclaim image build cache + run: docker builder prune --all --force + + - name: Run self-host smoke + run: pnpm exec tsx selfhost/smoke.mts + + - name: Run self-host release smoke + run: pnpm exec tsx selfhost/release-smoke.mts + + - 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 + + - name: Stop Compose + if: always() + run: docker compose -f selfhost/docker-compose.yml --profile analytics down --volumes --remove-orphans diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 9a53c189c..aa7191d3e 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -6,7 +6,6 @@ on: - opened - reopened - edited - - synchronize permissions: pull-requests: write diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml deleted file mode 100644 index 5f40c3361..000000000 --- a/.github/workflows/tinybird-cd.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Tinybird - CD Workflow - -on: - push: - branches: - - main - -concurrency: ${{ github.workflow }}-${{ github.event.ref }} - -env: - TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} - TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} - -jobs: - cd: - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v3 - - name: Install Tinybird CLI - run: curl https://tinybird.co | sh - - name: Deploy project - run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml deleted file mode 100644 index 76ee177c6..000000000 --- a/.github/workflows/tinybird-ci.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Tinybird - CI Workflow - -on: - workflow_dispatch: - pull_request: - branches: - - main - types: [opened, reopened, labeled, unlabeled, synchronize] - -concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} - -env: - TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} - TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} - -jobs: - ci: - runs-on: blacksmith-2vcpu-ubuntu-2404 - defaults: - run: - working-directory: "." - services: - tinybird: - image: tinybirdco/tinybird-local:latest - ports: - - 7181:7181 - steps: - - uses: actions/checkout@v3 - - name: Install Tinybird CLI - run: curl https://tinybird.co | sh - - name: Build project - run: tb build - - name: Test project - run: tb test run - - name: Deployment check - run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check diff --git a/.gitignore b/.gitignore index ff58f646d..934547fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ lerna-debug.log* # Caches .cache +.build/ +.gradle/ +.kotlin/ +.swiftpm/ # Diagnostic reports (https://nodejs.org/api/report.html) @@ -105,6 +109,8 @@ web_modules/ .env.test.local .env.production.local .env.local +.env.prod +.env.preview # parcel-bundler cache (https://parceljs.org/) @@ -143,6 +149,7 @@ dist # Serverless directories .serverless/ +.alchemy/ # FuseBox cache @@ -294,4 +301,4 @@ yalc.lock # Typescript **/tsconfig.tsbuildinfo -# resources/ \ No newline at end of file +resources/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..bf430e3b7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,54 @@ +[extend] +useDefault = true + +[[allowlists]] +description = "UUID-shaped App Store and React Native SDK test tokens" +condition = "AND" +targetRules = ["generic-api-key"] +regexTarget = "secret" +regexes = ['''(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'''] +paths = [ + '''(?:^|/)libraries/react-native/tests/''', + '''(?:^|/)packages/app-store-server-sdk/tests/''', +] + +[[allowlists]] +description = "Synthetic Stripe credentials used by config-provider tests" +condition = "AND" +targetRules = ["generic-api-key"] +regexTarget = "secret" +regexes = ['''^(?:(?:sk|rk)_(?:live|test)|whsec_)[a-z0-9_]+$'''] +paths = ['''(?:^|/)packages/core/src/services/paymentProviders/stripe/config-provider\.test\.ts$'''] + +[[allowlists]] +description = "Public UUIDv5 namespace used for deterministic account tokens" +condition = "AND" +targetRules = ["generic-api-key"] +regexTarget = "secret" +regexes = ['''^3919eb4e-3466-593c-8c1e-84554e13a0a6$'''] +paths = [ + '''(?:^|/)libraries/react-native/src/core/utils/account-token\.ts$''', + '''(?:^|/)packages/core/src/utils/crypto/account-token\.ts$''', +] + +[[allowlists]] +description = "Throwaway private keys used only by crypto unit tests" +targetRules = ["private-key"] +paths = [ + '''(?:^|/)packages/core/src/utils/crypto/SecretBox\.test\.ts$''', + '''(?:^|/)packages/core/test/services/paymentProviders/appStore/sdk-context\.test\.ts$''', +] + +[[allowlists]] +description = "Byte-identical public fixtures from Apple's App Store Server Library" +targetRules = ["jwt"] +paths = [ + '''(?:^|/)packages/app-store-server-sdk/tests/resources/mock_signed_data/missingX5CHeaderClaim$''', + '''(?:^|/)packages/app-store-server-sdk/tests/resources/mock_signed_data/renewalInfo$''', + '''(?:^|/)packages/app-store-server-sdk/tests/resources/mock_signed_data/testNotification$''', + '''(?:^|/)packages/app-store-server-sdk/tests/resources/mock_signed_data/transactionInfo$''', + '''(?:^|/)packages/app-store-server-sdk/tests/resources/mock_signed_data/wrongBundleId$''', + '''(?:^|/)packages/app-store-server-sdk/tests/resources/xcode/xcode-signed-app-transaction$''', + '''(?:^|/)packages/app-store-server-sdk/tests/resources/xcode/xcode-signed-renewal-info$''', + '''(?:^|/)packages/app-store-server-sdk/tests/resources/xcode/xcode-signed-transaction$''', +] diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 000000000..fa68c4937 --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,11 @@ +module.exports = { + hooks: { + readPackage(pkg) { + if (pkg.name !== "@earendil-works/pi-ai" || pkg.version !== "0.80.7") return pkg; + // Google is a lazily loaded provider and is not part of the supported host model set. + const dependencies = { ...pkg.dependencies }; + delete dependencies["@google/genai"]; + return { ...pkg, dependencies }; + }, + }, +}; diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a85510b12 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +You have access to read-only clones of some key dependencies we use and get inspired by in ./resources + +- /effect-smol - Effect v4 codebase. Great to find all primitives available in Effect. + +Pull request titles must follow Conventional Commits (for example, `feat: add query aliases` or `fix: preserve tenant scope`) because CI validates the title. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..46317f28e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +You have access to read-only clones of some key dependencies we use and get inspired by in ./resources + +- /effect-smol - Effect v4 codebase. Great to find all primitives available in Effect. + +Avoid adding unneccessary comments. Add jsdoc comments to public functions and explaination comments if doing something unorthodox / uncommon. + +Pull request titles must follow Conventional Commits (for example, `feat: add query aliases` or `fix: preserve tenant scope`) because CI validates the title. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7042443d..87569c154 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,57 @@ # Contributing to voidhash -Thanks for taking the time to improve voidhash! This is a small document to get you started. +Thanks for taking the time to improve voidhash! -Please refer to the [getting-started documentation](https://voidhash.com/docs/contribute/getting-started) specific to contributing for more information. +During the repository unification, pull requests are limited to repository +collaborators and external patches are not yet accepted. Issues and private +security reports remain welcome. -## Security Issues +Before external contributions reopen, Voidhash will enable an acceptance +process for the [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). +Every external contributor will need to accept that agreement before a +contribution can be merged. -If you see any security issue we prefer you to disclose it via an email (security@voidhash.com). All reports will be promptly addressed, and you'll be credited accordingly. +## Security issues -Learn more about our [security issues documentation](https://voidhash.com/docs/contribute/security-issues). +Report suspected vulnerabilities privately to +[security@voidhash.com](mailto:security@voidhash.com). Do not open a public +issue or pull request for a vulnerability. See the [Security Policy](SECURITY.md) +for reporting guidance. -## A Few Guidelines to keep in mind +## Guidelines - Rather than extensive configurations, focus instead on providing opinionated, best-practice defaults. -- Try to make a consistent and predictable API across all supported frameworks -- Everything should be type-safe and embrace typescript magic when necessary. +- Keep APIs consistent and predictable across supported frameworks. +- Preserve end-to-end type safety. +- Add JSDoc to public functions and keep existing documentation current. +- Keep infrastructure adapters behind the platform interfaces used by product code. ## Development -Read more about development in the [getting-started documentation](https://voidhash.com/docs/contribute/getting-started#development-setup). -...TODO +Voidhash uses Node.js 22 and pnpm 11. From the repository root: + +```sh +corepack enable +corepack prepare pnpm@11.1.3 --activate +pnpm install --frozen-lockfile +pnpm typecheck +pnpm test +``` + +Use `pnpm check:publication` to validate license metadata and the public/private +repository boundary. The [self-hosting guide](selfhost/README.md) documents the +local Compose environment and its smoke tests. ## Testing -Read more about testing in the [testing guide](https://voidhash.com/docs/contribute/testing). +Run the smallest relevant package tests while iterating, then run the repository +typecheck and test graph before requesting review. Changes to the Node runtime +or Compose configuration should also pass both self-host smoke tests documented +in [selfhost/README.md](selfhost/README.md#smoke-test). + +## License zones + +By contributing, you agree that your contribution is licensed under the license +that applies to the files you change. See [LICENSE.md](LICENSE.md) for the MIT +and AGPL zones. The Contributor License Agreement is an additional requirement +once external contributions reopen. diff --git a/CONTRIBUTOR_LICENSE_AGREEMENT.md b/CONTRIBUTOR_LICENSE_AGREEMENT.md new file mode 100644 index 000000000..cc76b0e04 --- /dev/null +++ b/CONTRIBUTOR_LICENSE_AGREEMENT.md @@ -0,0 +1,117 @@ +# Voidhash Contributor License Agreement + +Thank you for contributing to projects managed by Voidhash s.r.o. ("Voidhash"). +This Contributor License Agreement (the "Agreement") records the rights you +grant for contributions and protects your continued ownership of them. It is a +legally binding agreement; please read it carefully before accepting it. + +## 1. Definitions + +"You" means the individual accepting this Agreement or the legal entity on +whose behalf it is accepted. If You are a legal entity, You includes entities +that You control, that control You, or that are under common control with You. +"Control" means ownership of more than 50 percent of the voting interests or +the power to direct management or policies. + +"Contribution" means an original work of authorship, including source code, +documentation, designs, or modifications to existing material, that You +intentionally submit to Voidhash for inclusion in a Voidhash-managed project. + +"Submit" means any electronic, written, or verbal communication sent to a +Voidhash-managed source-control system, issue tracker, mailing list, or other +channel for the purpose of discussing or improving a project. Communication +conspicuously marked "Not a Contribution" is excluded. + +"Material" means the project to which You Submit a Contribution. + +## 2. Copyright license + +You retain ownership of Your Contribution and may use or license it for any +other purpose. + +You grant Voidhash a perpetual, worldwide, non-exclusive, transferable, +royalty-free, irrevocable copyright license, with the right to sublicense +through multiple tiers, to use, reproduce, modify, prepare derivative works of, +publicly display, publicly perform, make available, distribute, and otherwise +exploit Your Contribution as part of any Voidhash product or project. + +This grant permits Voidhash to license Your Contribution under open-source, +source-available, commercial, or proprietary terms. If Voidhash includes Your +Contribution in Material that was publicly available on the Submission date, +Voidhash will also continue to make that Contribution available under the +license that applied to that Material on the Submission date. + +## 3. Patent license + +You grant Voidhash and recipients of software distributed by Voidhash a +perpetual, worldwide, non-exclusive, royalty-free, irrevocable patent license +to make, have made, use, offer to sell, sell, import, and otherwise transfer the +Contribution and the Material, but only for patent claims You can license that +are necessarily infringed by Your Contribution alone or by its combination +with the Material to which it was Submitted. + +If You or an entity acting on Your behalf initiates patent litigation alleging +that a Contribution or Material incorporating it infringes a patent, the patent +licenses granted to that entity under this section terminate on the filing date +of that claim. + +## 4. Moral rights + +To the maximum extent permitted by applicable law, You waive and agree not to +assert moral rights in Your Contribution against Voidhash, its successors, or +its licensees. Where a waiver is not permitted, You grant the permissions +needed for Voidhash to exercise the rights in this Agreement. + +## 5. Your representations + +You represent that: + +1. You have the legal authority to enter into this Agreement and grant these + rights; +2. each Contribution is Your original creation, except for third-party material + that You identify as described below; +3. if an employer or another party has rights in Your Contribution, that party + has authorized the Contribution or waived those rights; and +4. You will disclose any third-party license, patent, or other restriction of + which You are aware and will clearly identify the source and license of any + third-party material. + +## 6. No support or warranty + +You are not required to provide support for Your Contribution. Unless You agree +otherwise in writing or applicable law requires it, You provide each +Contribution "AS IS", WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, +MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + +## 7. Project discretion + +Voidhash is not required to use or distribute any Contribution. Except for the +rights granted in this Agreement, You reserve all rights in Your Contribution. + +## 8. General terms + +This Agreement applies to Contributions Submitted after You accept it and to +earlier Contributions You identify when accepting it. It may be accepted by an +electronic record that identifies You and the account used to Submit the +Contribution. + +This Agreement is governed by the laws of the Czech Republic, excluding its +conflict-of-law rules. Courts with subject-matter jurisdiction in Prague, Czech +Republic, have exclusive jurisdiction over disputes arising from this +Agreement. If a provision is unenforceable, it will be limited to the minimum +extent necessary and the remaining provisions will continue in effect. + +This Agreement is the entire agreement concerning Contributions between You +and Voidhash and may be amended only in a writing accepted by both parties. + +## Acceptance record + +- Full legal name: +- GitHub username: +- Email address: +- Acting as: individual / authorized representative of a legal entity +- Legal entity, if applicable: +- Earlier Contributions covered, if any: +- Date: +- Signature or electronic acceptance: diff --git a/LICENSE.md b/LICENSE.md index ef2d9cee1..fcd140313 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,663 +1,37 @@ -Copyright (c) 2025-present Voidhash s.r.o. - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. -Everyone is permitted to copy and distribute verbatim copies -of this license document, but changing it is not allowed. - - Preamble - -The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - -The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - -When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - -Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - -A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - -The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - -An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - -The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - -0. Definitions. - -"This License" refers to version 3 of the GNU Affero General Public License. - -"Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based -on the Program. - -To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - -To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - -1. Source Code. - -The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - -A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - -The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - -The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - -The Corresponding Source for a work in source code form is that -same work. - -2. Basic Permissions. - -All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. - -No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - -When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - -4. Conveying Verbatim Copies. - -You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. - -You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - -A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - -6. Conveying Non-Source Forms. - -You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. +# Voidhash licensing -"Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - -If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - -The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - -7. Additional Terms. - -"Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - -All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - -8. Termination. - -You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - -However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - -Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - -9. Acceptance Not Required for Having Copies. - -You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. - -Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - -11. Patents. - -A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - -If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - -A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. - -If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - -13. Remote Network Interaction; Use with the GNU General Public License. - -Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - -Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - -14. Revised Versions of this License. - -The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - -Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - -15. Disclaimer of Warranty. - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. - -If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS +Copyright (c) 2025-present Voidhash s.r.o. - How to Apply These Terms to Your New Programs +This repository uses license zones. The license for a file is determined by the +closest license notice in its directory, the package metadata, or an SPDX +license identifier in the file. A more specific notice takes precedence over +this repository-level map. -If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. +## MIT code -To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. +SDKs, client libraries, the CLI, Studio, examples, and supporting packages that +declare `MIT` in their package metadata or carry a local MIT license notice are +licensed under the MIT License. - - Copyright (C) +The full MIT License is in [LICENSES/MIT.txt](LICENSES/MIT.txt). - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. +## AGPL service code - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. +The backend, dashboard, service packages, and self-hosting code that declare +`AGPL-3.0-only` in their package metadata or carry a local AGPL notice are +licensed under the GNU Affero General Public License, version 3 only. The full +license is in [LICENSES/AGPL-3.0-only.txt](LICENSES/AGPL-3.0-only.txt). - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . +## Enterprise code -Also add information on how to contact you by electronic and paper mail. +Enterprise code is not included in this repository and remains in Voidhash's +private cloud repository. The +[Voidhash Enterprise License](LICENSES/Voidhash-Enterprise.md) is retained here +as the canonical text for any separately distributed Enterprise Software, but +it does not apply to code unless a file or directory expressly says so. -If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. +Third-party components remain under their respective licenses. Their notices +take precedence for those components. -You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +Questions about commercial licensing may be sent to support@voidhash.com. No +pricing terms are stated or implied by this file. diff --git a/LICENSES/AGPL-3.0-only.txt b/LICENSES/AGPL-3.0-only.txt new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/LICENSES/AGPL-3.0-only.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 000000000..f44bb5bdf --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-present Voidhash s.r.o. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSES/Voidhash-Enterprise.md b/LICENSES/Voidhash-Enterprise.md new file mode 100644 index 000000000..be0be621c --- /dev/null +++ b/LICENSES/Voidhash-Enterprise.md @@ -0,0 +1,111 @@ +# Voidhash Enterprise License + +Copyright (c) 2025-present Voidhash s.r.o. All rights reserved. + +## 1. Acceptance and scope + +This license governs only software that expressly identifies the Voidhash +Enterprise License as its license (the "Enterprise Software"). By exercising +any permission granted below, you agree to these terms. + +## 2. Evaluation and development license + +Voidhash s.r.o. ("Voidhash") grants you a non-exclusive, worldwide, +non-transferable, non-sublicensable, royalty-free license to copy, run, and +modify the Enterprise Software solely for evaluation, development, and testing +in a non-production environment. + +This permission does not include production use, providing the Enterprise +Software to third parties as a hosted or managed service, or distributing the +Enterprise Software except as expressly allowed by section 4. + +## 3. Production use + +You may use the Enterprise Software in production only while you and every +entity on whose behalf you use it: + +1. have a current written commercial agreement with Voidhash that authorizes + that use; +2. comply with that agreement; and +3. where the Enterprise Software implements license-key enforcement, use a + valid license key issued by Voidhash for the authorized deployment. + +The commercial agreement controls if it conflicts with this license. + +## 4. Modifications and patches + +Subject to sections 2 and 3, you may modify the Enterprise Software and publish +patches that describe your modifications. You may not distribute a complete or +substantial copy of the Enterprise Software, whether modified or unmodified, +unless a written agreement with Voidhash expressly permits it. + +Your modifications remain yours. You grant Voidhash a perpetual, worldwide, +non-exclusive, royalty-free license to use, reproduce, modify, distribute, +sublicense, and otherwise exploit any patch you intentionally submit to a +Voidhash-managed repository for inclusion in the Enterprise Software. + +## 5. Restrictions + +Except where a written agreement with Voidhash expressly permits it, you may +not: + +- use the Enterprise Software in production; +- sell, sublicense, or distribute the Enterprise Software; +- provide the Enterprise Software or a substantial set of its functionality to + third parties as a hosted or managed service; +- remove, disable, avoid, or circumvent license-key functionality or a feature + protected by it; or +- remove or obscure copyright, license, attribution, or other proprietary + notices. + +## 6. Third-party software + +Third-party components incorporated into or distributed with the Enterprise +Software remain governed by their respective licenses. This license does not +limit rights granted under those licenses. + +## 7. No implied rights + +Voidhash and its licensors retain all rights not expressly granted by this +license. This license grants no rights in any name, logo, service mark, or other +brand identifier except as required by applicable law to describe the origin of +the Enterprise Software. + +## 8. Termination and cure + +Your rights under this license terminate automatically if you violate its +terms. If Voidhash notifies you of a first violation and you cure it within 30 +days after receiving notice, your rights are reinstated retroactively. A later +violation after reinstatement terminates your rights permanently unless +Voidhash agrees otherwise in writing. + +Sections 4 through 10 survive termination to the extent necessary to give them +effect. + +## 9. Disclaimer of warranty + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE ENTERPRISE SOFTWARE IS PROVIDED +"AS IS" AND WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, WHETHER EXPRESS, +IMPLIED, OR STATUTORY, INCLUDING WARRANTIES OF TITLE, NON-INFRINGEMENT, +MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. + +## 10. Limitation of liability + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, VOIDHASH AND ITS LICENSORS WILL NOT BE +LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR +PUNITIVE DAMAGES, OR FOR ANY LOSS OF USE, DATA, BUSINESS, REVENUE, OR PROFITS, +ARISING FROM OR RELATED TO THE ENTERPRISE SOFTWARE OR THIS LICENSE, UNDER ANY +LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF THOSE DAMAGES. + +## 11. Definitions + +"Production" means use to operate a business, provide a service to internal or +external users, process live data, or support any activity other than +evaluation, development, or testing. + +"You" means the individual or legal entity exercising rights under this +license and any entity it controls, is controlled by, or is under common +control with. "Control" means ownership of more than 50 percent of the voting +interests or the power to direct management or policies. + +Questions about commercial authorization may be sent to support@voidhash.com. diff --git a/PAYWALLS-MVP-SERVER-SPEC.md b/PAYWALLS-MVP-SERVER-SPEC.md new file mode 100644 index 000000000..de7742c66 --- /dev/null +++ b/PAYWALLS-MVP-SERVER-SPEC.md @@ -0,0 +1,429 @@ +# Paywalls MVP — Server Protocol Spec + +This document specifies the backend Voidhash must implement so the paywall system +(`@voidhash/paywalls`, the Studio, and the `voidhash-cli deploy`/`studio` +commands) integrates end-to-end. It is the contract between three actors: + +| Actor | Role | +| ----------------------------------------- | --------------------------------------------------------------------------------------- | +| **CLI** (`voidhash-cli deploy`) | Builds paywalls and **uploads** the deploy payload. | +| **Server** (this spec) | Stores deploys, serves the live paywall to devices, resolves placements + entitlements. | +| **Device SDK** (`@voidhash/react-native`) | Presents a paywall in a WebView, **injects** runtime config, handles the **bridge**. | + +The client side is implemented today. Everything marked **[server]** is what this +task hands off; everything marked **[done]** already exists in this repo and the +server must remain compatible with it. + +> **Source of truth for shapes.** The TypeScript contracts referenced here are +> real and version-locked: +> +> - Deploy payload: [`apps/cli/src/domain/schema/paywall-deploy.ts`](apps/cli/src/domain/schema/paywall-deploy.ts) (`DeployManifest`, `schemaVersion: 1`). +> - Runtime config: [`libraries/paywalls/src/runtime/config.ts`](libraries/paywalls/src/runtime/config.ts) (`PaywallRuntimeConfig`). +> - Bridge protocol: [`libraries/paywalls/src/runtime/bridge.ts`](libraries/paywalls/src/runtime/bridge.ts) (`PaywallOutboundMessage`, `PaywallInboundMessage`). +> The server MUST keep these in sync; bump `schemaVersion` on any breaking change. + +--- + +## 1. Concepts & glossary + +- **Paywall** — a code-driven screen authored in `.voidhash/paywalls/.tsx`. + Compiles to a self-contained HTML+JS bundle. +- **Component** — a reusable piece in `.voidhash/components/.tsx`. Not served + standalone; shipped as raw source only (for the future GUI builder + diffing). +- **Asset** — a binary (image/font) referenced by a paywall, content-addressed. +- **Deploy** — one immutable upload of all paywalls/components/assets for a + project, produced by `voidhash-cli deploy`. +- **Placement** (a.k.a. _location_) — a named slot in the app (e.g. `onboarding`, + `settings_upgrade`) that the SDK presents. A placement is _assigned_ a paywall. + This indirection is what lets customers swap paywalls without an app release. +- **Content hash** — lowercase hex SHA-256. Every file and every paywall has one; + identical content ⇒ identical hash ⇒ dedupe + cache key. +- **Release / channel** — a pointer that maps placements → paywall versions for a + given audience (e.g. `production`, `staging`). The device resolves through a + channel, never directly to a deploy. + +--- + +## 2. The full flow + +``` + author .voidhash/*.tsx + │ voidhash-cli deploy + ▼ + ┌──────────────┐ POST /v1/paywalls/deploys (manifest + files) ┌──────────┐ + │ CLI │ ───────────────────────────────────────────▶ │ Server │ + └──────────────┘ ◀─── 201 { deployId, missingFiles? } └────┬─────┘ + │ PUT missing blobs (content-addressed) │ store deploy (immutable) + │ POST .../finalize │ assign placements (manual or auto) + ▼ ▼ + dashboard: assign placement → paywall, publish to a channel + │ + ▼ + ┌──────────────┐ GET /v1/paywalls/resolve?placement=onboarding ┌──────────┐ + │ Device SDK │ ───────────────────────────────────────────────▶│ Server │ + └──────┬───────┘ ◀── { url, contentHash, products, variables } └──────────┘ + │ open WebView(url), inject window.__VOIDHASH_PAYWALL__ + │ bundle boots → renders paywall + ▼ + user taps "Subscribe" → bridge postMessage → SDK runs StoreKit/Billing + │ SDK pushes status back into the WebView + ▼ + purchase complete → SDK dismisses paywall, unlocks entitlement +``` + +--- + +## 3. Deploy API **[server]** + +The CLI today builds the payload and writes it to `.voidhash/.build/` with a +`manifest.json`; the upload call is the only missing piece (see the `// Upload` +block in [`apps/cli/src/cli/commands/deploy.ts`](apps/cli/src/cli/commands/deploy.ts)). +Implement the endpoints below; wiring the CLI to them is a one-function change. + +### 3.1 Authentication + +All deploy endpoints require a **secret** API key (`x-api-key: vh_sk_…`), the same +scheme the CLI already uses (see [`apps/cli/src/utils/api-client.ts`](apps/cli/src/utils/api-client.ts)). +The key authorizes a single `{team, project}`. Reject if `manifest.team` / +`manifest.project` don't match the key's scope (`403`). + +### 3.2 Content-addressed upload (two-phase) + +To avoid re-uploading unchanged bundles/assets on every deploy, uploads are +content-addressed and two-phase. + +**Phase 1 — create the deploy.** The CLI POSTs the **manifest only**. + +``` +POST /v1/paywalls/deploys +Content-Type: application/json +x-api-key: vh_sk_… + + // exactly the manifest.json the CLI produced +``` + +The manifest lists every file with its `sha256`. The server responds with the +deploy id and the subset of hashes it does **not** already have stored: + +``` +201 Created +{ + "deployId": "dep_…", + "missing": ["", "", …] // upload only these +} +``` + +**Phase 2 — upload missing blobs.** For each missing hash, the CLI uploads the +raw bytes. Content-addressed, so the path is the hash: + +``` +PUT /v1/paywalls/deploys/dep_…/blobs/ +Content-Type: application/octet-stream +x-api-key: vh_sk_… + + +``` + +The server MUST verify `sha256(body) === ` and reject mismatches (`422`). +A blob already present returns `200`/`204` (idempotent). + +**Finalize.** Once all blobs are present: + +``` +POST /v1/paywalls/deploys/dep_…/finalize +→ 200 { "deployId", "paywalls": [{ "id", "contentHash" }], "status": "ready" } +``` + +The server validates that every file referenced by the manifest now resolves to a +stored blob; if any are missing it returns `409 { missing: […] }`. Finalize is the +commit point — a deploy is **immutable** afterward. + +> A simple server MAY accept the whole payload in one multipart request instead of +> the two-phase flow (see §3.4). The two-phase flow is recommended because most +> deploys change one paywall, so most blobs are already stored. + +### 3.3 What's in the payload + +The `DeployManifest` (schema v1) groups everything (paths are relative to the +project root, POSIX-separated): + +```jsonc +{ + "schemaVersion": 1, + "cliVersion": "0.0.1-alpha.1", + "runtimeVersion": "0.0.1-alpha.1", // @voidhash/paywalls version built against + "team": "voidhash-dev-sro", + "project": "dev-proj", + "createdAt": "2026-06-03T10:00:00.000Z", + "paywalls": [ + { + "id": "onboarding-green", + "title": "Onboarding (Green)", + "description": "Full-screen onboarding paywall with selectable plans.", + "source": { "path": ".voidhash/paywalls/onboarding-green.tsx", "bytes": 4096, "sha256": "…" }, + "artifacts": { + "html": { + "path": ".voidhash/.build/onboarding-green/index.html", + "bytes": 900, + "sha256": "…", + "contentType": "text/html; charset=utf-8", + }, + "js": { + "path": ".voidhash/.build/onboarding-green/bundle.js", + "bytes": 201000, + "sha256": "…", + "contentType": "text/javascript; charset=utf-8", + }, + }, + "assets": [".voidhash/.build/onboarding-green/assets/hero-AB12CD.png"], + "contentHash": "5b00934c90ee…", // identity of the deployable paywall + }, + ], + "components": [ + { + "id": "product-option", + "source": { "path": ".voidhash/components/product-option.tsx", "bytes": 1500, "sha256": "…" }, + }, + ], + "config": { "path": "voidhash.config.ts", "bytes": 120, "sha256": "…" }, + "assets": [ + { + "path": ".voidhash/.build/onboarding-green/assets/hero-AB12CD.png", + "bytes": 88000, + "sha256": "…", + "contentType": "image/png", + }, + ], +} +``` + +Three classes of content, all in one deploy: + +1. **Compiled artifacts** — `paywalls[].artifacts.html` + `.js`: the WebView-ready + bundle (the paywall React tree + the DOM renderer + React, IIFE, minified, + `NODE_ENV=production`, targeting `es2019`/`safari13`). This is what the device + renders. +2. **Raw source** — `paywalls[].source`, `components[].source`, `config`: the + author's `.tsx`/config. Stored for the future GUI builder, deploy diffing, and + support. **Not** served to devices. +3. **Assets** — `assets[]`: binaries referenced by bundles, content-addressed. + +### 3.4 Single-request fallback + +For a minimal first server, accept `multipart/form-data` at +`POST /v1/paywalls/deploys` with one `manifest` part (JSON) and one part per file +keyed by its `sha256`. Server validates hashes and finalizes atomically. Same +result, fewer round-trips; lose the dedupe optimization. + +### 3.5 `contentHash` semantics + +`paywalls[].contentHash = sha256( sha256(html) : sha256(js) : sorted(asset hashes) )` +(see `buildPaywalls` in [`apps/cli/src/domain/services/paywall-build.ts`](apps/cli/src/domain/services/paywall-build.ts)). +The server MUST treat it as the deployable paywall's identity: dedupe storage by +it, use it as the device cache key (§4.3), and expose it in resolve responses. + +--- + +## 4. Delivery API **[server]** + +How a device gets a paywall to show. The SDK never references a deploy or a raw +paywall id directly — it asks for a **placement**, and the server resolves the +placement through the active **channel** to a concrete paywall version + the +products/variables to inject. + +### 4.1 Resolve a placement + +``` +GET /v1/paywalls/resolve?placement=onboarding&platform=ios&locale=en-US +x-api-key: vh_pk_… // PUBLISHABLE key (safe to ship in the app) +``` + +```jsonc +200 OK +{ + "placement": "onboarding", + "paywall": { + "id": "onboarding-green", + "contentHash": "5b00934c90ee…", + "url": "https://paywalls.voidhash.com/p/5b00934c90ee…/index.html", + "assetBaseUrl": "https://paywalls.voidhash.com/p/5b00934c90ee…/" + }, + "runtime": { // becomes window.__VOIDHASH_PAYWALL__ (see §5.1) + "products": [ + { "id": "com.app.pro.yearly", "displayName": "Yearly", "priceString": "$59.99", + "price": 59.99, "currencyCode": "USD", "period": "year", "trialPeriod": "7d" } + ], + "variables": { "accentColor": "#16a34a" }, + "locale": "en-US", + "platform": "ios", + "defaultSelectedProductId": "com.app.pro.yearly" + }, + "presentation": { "style": "fullScreen" } // optional SDK presentation hints +} +``` + +- **No paywall assigned** to the placement → `204 No Content`. The SDK shows + nothing (or a hardcoded fallback). Never error the app over a missing paywall. +- **`products`** — the server resolves the placement's product group to store + product ids. The SDK enriches localized price/title from StoreKit/Billing and + presents; the server's `priceString`/`price` are display fallbacks. (The + `products`/`variables` map 1:1 onto `PaywallRuntimeConfig`; see §5.1.) +- **`variables`** — author-overridable values, the seam for A/B tests & + remote config. Returning different paywall ids or variables per audience is how + experiments are run — opaque to this MVP, but `resolve` is the hook. + +### 4.2 Serving the bundle + +`paywall.url` serves the stored `index.html`; `assetBaseUrl` + the asset filename +serves each asset. The HTML references `./bundle.js` and `./assets/…` relatively, +so **all three must be served under the same path prefix** (`/p//`). +Recommended: object storage + CDN, immutable + long `Cache-Control` (content is +addressed by hash, so it never changes for a given URL). + +`Content-Type` per the manifest. Set permissive CORS / WebView-friendly headers. +The SDK MAY also pre-fetch and serve the bundle from a local cache (see §4.3). + +### 4.3 Caching & offline + +- The bundle URL is immutable per `contentHash`; the SDK SHOULD cache by it and + reuse across launches, only re-downloading when `resolve` returns a new hash. +- The SDK SHOULD pre-warm the current paywall on app start so presentation is + instant and works offline. +- `resolve` responses are short-lived (products/prices change); the bundle is + effectively permanent. + +--- + +## 5. Runtime contract **[done — server must conform]** + +Once the WebView loads the bundle, two channels connect it to the native app. + +### 5.1 Config injection + +Before the bundle script runs, the SDK MUST set the global +`window.__VOIDHASH_PAYWALL__` to the `runtime` object from §4.1. On a React Native +WebView this is `injectedJavaScriptBeforeContentLoaded`: + +```js +`window.__VOIDHASH_PAYWALL__ = ${JSON.stringify(runtime)};`; +``` + +The bundle reads it via `readInjectedConfig()` and exposes it to author code +through `usePaywallProducts()`, `usePaywallVariables()`, `useSelectedProduct()`, +`usePaywallStatus()`. The shape is `PaywallRuntimeConfig` +([`config.ts`](libraries/paywalls/src/runtime/config.ts)) — the server's `resolve` +`runtime` block MUST match it field-for-field. If injection is skipped the paywall +still mounts with no products (safe default). + +### 5.2 The native bridge + +Author actions (`usePaywallActions()`) are **requests** the paywall sends up to +the native host; the host owns the actual store transaction and pushes status +back down. The wire format is fixed in +[`bridge.ts`](libraries/paywalls/src/runtime/bridge.ts). + +**Outbound — WebView → native** (delivered via `window.ReactNativeWebView.postMessage(json)`). +The SDK parses `event.nativeEvent.data` as JSON: + +| `type` | Payload | Native action | +| --------------------------- | ----------------------- | ------------------------------------------------------------- | +| `voidhash.paywall.ready` | — | Paywall mounted; safe to inject status. | +| `voidhash.paywall.purchase` | `{ productId }` | Start StoreKit/Billing purchase for `productId`. | +| `voidhash.paywall.restore` | — | Restore entitlements. | +| `voidhash.paywall.close` | — | Dismiss the paywall. | +| `voidhash.paywall.openUrl` | `{ url }` | Open `url` (terms/privacy) in a browser. | +| `voidhash.paywall.event` | `{ name, properties? }` | Forward to analytics (ties into existing Voidhash analytics). | + +**Inbound — native → WebView.** The SDK calls the function the runtime installs on +`window`: + +```js +webview.injectJavaScript(`window.__voidhashPaywallReceive(${JSON.stringify(msg)});`); +``` + +| `type` | Payload | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `voidhash.paywall.status` | `{ status, productId?, error? }` where `status ∈ idle \| purchasing \| restoring \| purchased \| restored \| cancelled \| failed` | + +The paywall reflects `status` (e.g. disables the CTA while `purchasing`). On +`purchased`/`restored` the SDK validates the receipt (existing Voidhash +server-side validation), unlocks the entitlement, and dismisses. + +### 5.3 Purchase → entitlement (ties into existing platform) + +The bridge only conveys intent. Receipt validation, entitlement state, and +revenue tracking continue to flow through the **existing** Voidhash subscription +APIs and analytics — this spec does not change them. The paywall is purely the +presentation + intent layer. + +--- + +## 6. Suggested data model **[server]** + +``` +deploys (id, team_id, project_id, schema_version, cli_version, + runtime_version, created_at, status, manifest_json) +blobs (sha256 PK, bytes, content_type, storage_key) -- content-addressed +deploy_files (deploy_id, role, logical_path, sha256 → blobs) -- role: html|js|asset|source|config +paywalls (id, deploy_id, slug, title, description, content_hash) +placements (id, project_id, key) -- e.g. "onboarding" +channels (id, project_id, key) -- e.g. "production" +placement_assignments(channel_id, placement_id, paywall_content_hash, product_group_id, variables_json, updated_at) +``` + +- `blobs` deduped by `sha256` across all deploys ⇒ unchanged bundles/assets stored + once. +- A **deploy** is immutable; **assignments** are the only mutable, audience-facing + state (what `resolve` reads). This cleanly separates "what was built" from "what + is live", enabling instant rollback (repoint an assignment) without a rebuild. + +--- + +## 7. Cross-cutting **[server]** + +- **Versioning.** Honor `manifest.schemaVersion`; reject unknown majors with a + clear "upgrade the CLI" error. Echo a server `apiVersion`. +- **Idempotency.** Re-POSTing an identical manifest (same file set) returns the + same `deployId` (key on team+project+manifest hash). Blob PUTs are idempotent by + hash. +- **Limits.** Cap bundle size (e.g. 5 MB) and asset size; return `413` with the + offending path. Validate `contentType` against an allowlist. +- **Validation.** On finalize, recompute each paywall's `contentHash` from stored + blobs and reject mismatches — never serve an unverified bundle. +- **Auth split.** Deploy needs a **secret** key (`vh_sk_`); `resolve` + bundle/asset + serving accept the **publishable** key (`vh_pk_`). Bundles/assets are public, + immutable, content-addressed — no secrets ever go in a paywall bundle. +- **Errors.** JSON `{ error: { code, message, details? } }`; `4xx` for client + faults (bad hash, scope mismatch, missing blob), `5xx` for server faults. + +--- + +## 8. Out of scope (future) + +- **GUI paywall builder** — will read the stored **raw source** and prop schemas + (`defineComponent` editor metadata: kind/label/default/options) to render an + editor. The deploy already ships everything it needs. +- **Native renderers** — the renderer is abstracted behind a host-component + registry (`RendererProvider`), so a future native target reuses the same authored + paywalls without server changes; delivery would serve a native bundle instead of + HTML/JS, keyed by the same `contentHash` model. +- **Experiments / targeting** — `resolve` is the designed hook (return different + paywall/variables per audience); the allocation engine is a later task. + +--- + +## 9. Server implementation checklist + +- [ ] `POST /v1/paywalls/deploys` — accept `DeployManifest`, return `{ deployId, missing[] }`. +- [ ] `PUT /v1/paywalls/deploys/:id/blobs/:sha256` — verify hash, store blob. +- [ ] `POST /v1/paywalls/deploys/:id/finalize` — validate completeness + hashes, mark ready. +- [ ] Object storage + CDN for `/p//index.html|bundle.js|assets/*` (immutable, CORS, correct `Content-Type`). +- [ ] `GET /v1/paywalls/resolve` — placement → `{ paywall.url, contentHash, runtime{products,variables,…} }`, `204` when unassigned. +- [ ] Dashboard: assign placement → paywall, publish to a channel, rollback. +- [ ] Enforce key scopes (`vh_sk_` deploy, `vh_pk_` resolve) + size/type limits. +- [ ] Keep `runtime` (resolve) and the bridge message types in lockstep with `@voidhash/paywalls`; bump `schemaVersion` on breaking changes. +- [ ] Wire the CLI upload: replace the `// Upload` placeholder in `deploy.ts` with the phase-1/2 calls. + +``` + +``` diff --git a/PAYWALLS-MVP.md b/PAYWALLS-MVP.md new file mode 100644 index 000000000..5313aa024 --- /dev/null +++ b/PAYWALLS-MVP.md @@ -0,0 +1,35 @@ +# Paywalls MVP + +## Context + +Voidhash is a Google Play and App Store subscription management platform. It includes analytics, revenue tracking, server side recipe validation and much more. + +## Objective + +In this task, we want to add highly requested feature - Paywalls. Similar to Superwall, we want to enable our customers to quickly change and test their paywalls without re-deploying their app. In the future, we will have a GUI paywall builder but for now, we will have fully code driven paywall building experience. For this task, I have scaffolded an MVP version of the code, that will define both paywalls and re-usable components in ./examples/react-native-example (in a .voidhash folder) + +## Paywalls + +Paywalls are screens in a mobile app where the app user can purchase something - this could be subscription, one time items etc. Each app needs it's own distinct look and have a different use-case for it. In our MVP, paywalls are code driven and they are defined via a createPaywall. Both paywalls and components will live in .voidhash folder, that will be scaffolded by the CLI. There will be 2 folders - components for re-usable component definitions and paywalls for individual paywall designs. + +## Components + +As mentioned previously, components are re-usable primitives across paywalls. They are mostly designed to add dynamic, interactive logic (carousels, sheets etc.). + +## How will they work, custom react, renderers + +Paywalls will be powered by react. Instead of using react-dom, we will impement our own renderer (that will use HTML DOM under the hood). We want to build a scoped, abstract way for building paywalls to allow as to build native renderers in the future as well. The API will be similar to react-native with View, Text, Pressable, ScrollView etc. + +## Studio + +Studio is vite js application, that is ran via our CLI. It previews the paywalls and refreshes in real-time as the paywall changes. There will be a sidepanel on the right with list of all paywalls. We can switch between them. In the middle, there will be 9:16 aspect ratio (phone) preview. It will use tailwind for styling and shadcn for UI primitives. + +## CLI + +We need to extend our CLI with few commands +`voidhash-cli studio` will launch the paywall preview studio +`voidhash-cli deploy` will build and deploy everything to Voidhash + +# Bundling and deployment + +As part of this task, we need to define the entire flow and specify, what will be sent to our server and in what format. Modifying the backend service will be the next task. We need to create final html / js, that will be server and rendered in a mobile webview as the paywall itself. We also want to send the raw source code files to the server (both, components and paywalls) and we need to deploy assets. Create PAYWALLS-MVP-SERVER-SPEC.md that will map all the requirements and protocols the server should implement for it to integrate together well. diff --git a/README.md b/README.md index 75ba56d74..a6d7b17df 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@

+> [!IMPORTANT] +> This private validation branch contains the complete Community platform, +> including the backend and self-hosting composition. The repository remains +> private through alpha and beta security validation and must not be described +> as publicly launched until the publication gate is complete. + ## ✨ Features - **🏪 Multi-Platform Support** - Seamless integration with Google Play and App Store @@ -50,15 +56,26 @@ voidhash-cli init ## 📚 Documentation -For detailed documentation, visit [voidhash.com](https://voidhash.com/docs) +For product documentation, visit [voidhash.com](https://voidhash.com/docs). To +run the Community platform locally, see the [self-hosting guide](selfhost/README.md). +The [architecture overview](docs/architecture.md) explains the Community, +Cloud, and Enterprise composition boundaries, and the +[licensing and self-hosting FAQ](docs/licensing-and-self-hosting-faq.md) covers +AGPL and the current BYO WorkOS requirement. ## 🤝 Contributing -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. +External pull requests are closed during private validation. Issues and private +security reports remain welcome; see the [Contributing Guide](CONTRIBUTING.md) +and [Security Policy](SECURITY.md). ## 📄 License -Licensed under the AGPL-3.0 License. See [LICENSE.md](LICENSE.md) for more information. +This repository uses explicit license zones. SDKs and client libraries are +MIT-licensed; the backend, dashboard, service packages, and self-hosting code +are AGPL-3.0-only. Closed Enterprise implementation remains in the private +cloud repository and is not included here. See [LICENSE.md](LICENSE.md) for the +authoritative map and full texts. ## 🔗 Links diff --git a/SECURITY.md b/SECURITY.md index 1a6e72b72..d15e04a76 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,25 @@ # Security Policy +## Supported Versions + +Voidhash is currently in private alpha and is not yet supported for production +use. Security fixes are applied to the latest `preview` branch. Older commits, +preview builds, and unpublished alpha artifacts are not supported. + +This policy will be updated with a version support table before the first +public release. + ## Reporting a Vulnerability -If you discover a security vulnerability within Chiron, please send an e-mail to security@chiron.sh. +Please report suspected vulnerabilities privately to +[security@voidhash.com](mailto:security@voidhash.com). Do not open a public +issue, discussion, or pull request for a vulnerability. + +Include the affected component, reproduction steps, expected impact, and any +suggested mitigation. We will acknowledge the report, investigate it, and +coordinate remediation and disclosure with the reporter. Please avoid +accessing data that is not yours, disrupting services, or exploiting a finding +beyond what is necessary to demonstrate it. -All reports will be promptly addressed, and you'll be credited accordingly. +The current backend threat model and publication risks are documented in +[`docs/security/backend-threat-model.md`](docs/security/backend-threat-model.md). diff --git a/apps/backend/LICENSE.md b/apps/backend/LICENSE.md new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/apps/backend/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 000000000..40b1a3fa5 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,49 @@ +{ + "name": "@voidhash/backend", + "version": "0.1.0", + "private": true, + "license": "AGPL-3.0-only", + "type": "module", + "scripts": { + "test": "vp test run -c vitest.unit.mts", + "typecheck": "tsc --noEmit", + "typecheck-go": "tsgo --noEmit", + "lint": "vp lint .", + "format": "vp fmt ." + }, + "dependencies": { + "@voidhash/agent": "workspace:*", + "@voidhash/ai-shared": "workspace:*", + "@voidhash/api-contracts": "workspace:*", + "@voidhash/app-store-server-sdk": "workspace:*", + "@voidhash/clickhouse-db": "workspace:*", + "@voidhash/core": "workspace:*", + "@voidhash/db": "workspace:*", + "@voidhash/google-play-server-sdk": "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", + "effect": "catalog:", + "jose": "catalog:", + "just-bash": "3.1.0", + "preact": "^10.25.4", + "zod": "catalog:" + }, + "devDependencies": { + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", + "@types/bun": "latest", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@voidhash/platform": "workspace:*", + "@voidhash/tsconfig": "workspace:*", + "alchemy": "catalog:", + "typescript": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/apps/backend/src/ApiMiddlewares.ts b/apps/backend/src/ApiMiddlewares.ts new file mode 100644 index 000000000..0e9e57c6d --- /dev/null +++ b/apps/backend/src/ApiMiddlewares.ts @@ -0,0 +1,348 @@ +import { + ApiAuthSession, + AuthMiddleware, + type ApiPublishableKeySession, + type ApiSecretKeySession, + type ApiUserSession, +} from "@voidhash/api-contracts"; +import { + ApiAuthenticationError, + ApiNotAuthenticatedError, +} from "@voidhash/api-contracts/errors"; +import { ApiKeyService } from "@voidhash/core/services"; +import { LocalUserSessionService } from "@voidhash/core/services/auth/LocalUserSessionService"; +import { WorkosLocalSyncService } from "@voidhash/core/services/auth/WorkosLocalSyncService"; +import { Workos } from "@voidhash/core/services/auth/Workos"; +import { AuthSession } from "@voidhash/rpc"; +import { Db, type DbError } from "@voidhash/db"; +import { Effect, Layer, Option, pipe } from "effect"; +import * as HttpHeaders from "effect/unstable/http/Headers"; +import { HttpServerRequest } from "effect/unstable/http"; + +import { withIdentity } from "./Telemetry.ts"; + +type AuthMiddlewareError = ApiAuthenticationError | ApiNotAuthenticatedError; +type AuthMiddlewareSession = ApiUserSession | ApiSecretKeySession | ApiPublishableKeySession; + +type SelectedAuthMethod = + | { method: "api-key"; rawApiKey: string } + | { method: "secret-key"; rawSecretKey: string } + | { method: "workos-session"; headers: Headers } + | { method: "publishable-key"; rawPublishableKey: string; distinctId: string }; + +const publishableKeyInvalidMessage = "Publishable Key not found, is expired or is invalid."; + +const hasWorkosSessionCookieHeader = (headers: Headers): boolean => + headers.get("cookie")?.includes("wos-session=") ?? false; + +const toWebHeaders = (headers: HttpHeaders.Headers): Headers => + new Headers( + Object.entries(headers).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + +const selectAuthMethod = ( + req: HttpServerRequest.HttpServerRequest, +): Effect.Effect => + Effect.gen(function* () { + const rawApiKey = Option.getOrUndefined(HttpHeaders.get(req.headers, "x-api-key")); + if (rawApiKey) { + return { method: "api-key", rawApiKey } satisfies SelectedAuthMethod; + } + + const webHeaders = toWebHeaders(req.headers); + if (hasWorkosSessionCookieHeader(webHeaders)) { + return { headers: webHeaders, method: "workos-session" } satisfies SelectedAuthMethod; + } + + const rawSecretKey = Option.getOrUndefined(HttpHeaders.get(req.headers, "x-secret-key")); + if (rawSecretKey) { + return { method: "secret-key", rawSecretKey } satisfies SelectedAuthMethod; + } + + const rawPublishableKey = Option.getOrUndefined( + HttpHeaders.get(req.headers, "x-publishable-key"), + ); + if (rawPublishableKey) { + const distinctId = Option.getOrUndefined(HttpHeaders.get(req.headers, "x-distinct-id")); + if (!distinctId) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "Missing distinct-id header", + message: "Missing distinct-id header", + }), + ); + } + return { + distinctId, + method: "publishable-key", + rawPublishableKey, + } satisfies SelectedAuthMethod; + } + + return yield* Effect.fail( + new ApiNotAuthenticatedError({ message: "You are not authenticated" }), + ); + }); + +const mapDatabaseError = (e: DbError) => + new ApiAuthenticationError({ + cause: String(e.message), + message: "Failed to authenticate due to an internal error", + }); + +/** + * HTTP API authentication middleware. Resolves an `ApiAuthSession` from one of + * `x-api-key`, `x-secret-key`, `x-publishable-key` + `x-distinct-id`, or a + * WorkOS session cookie, matching the legacy parity surface. Failures collapse + * onto `ApiNotAuthenticatedError` / `ApiAuthenticationError`. + */ +export const AuthMiddlewareLive = Layer.effect( + AuthMiddleware, + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const localUserSessions = yield* LocalUserSessionService; + const workosLocalSync = yield* WorkosLocalSyncService; + const workosAuth = yield* Workos; + // Capture the per-request `Db` at middleware-layer build and provide it to + // the auth resolution below. Core service methods now require `Db` ambiently + // (native effect-postgres), but the middleware handler's requirement channel + // is sealed by the framework (`Provided`/`Scope` only), so `Db` is supplied + // here rather than leaking out of the handler. + const db = yield* Db; + const shouldBackfillWorkosLocalState = process.env.APP_ENV === "development"; + + const authenticateApiKey = (rawApiKey: string) => + pipe( + Effect.gen(function* () { + const record = yield* apiKeyService.validateUserApiKey(rawApiKey); + const access = yield* localUserSessions.loadUserAccess(record.user.id); + return localUserSessions.toUserSession(record.user, access, null, null); + }), + Effect.catchTags({ + ApiKeyNotFoundError: () => + Effect.fail(new ApiNotAuthenticatedError({ message: "You are not authenticated" })), + ApiKeyServiceError: (e) => + Effect.fail( + new ApiAuthenticationError({ + cause: e.cause, + message: "Failed to authenticate with api key due to an internal error", + }), + ), + EffectDrizzleQueryError: (e) => Effect.fail(mapDatabaseError(e)), + }), + ); + + const authenticateWorkosSession = (headers: Headers) => + pipe( + Effect.gen(function* () { + const session = yield* workosAuth.authenticateSessionCookie(headers); + if (!session) { + return yield* Effect.fail( + new ApiNotAuthenticatedError({ message: "You are not authenticated" }), + ); + } + + const localUser = yield* shouldBackfillWorkosLocalState + ? workosLocalSync + .syncAuthenticatedUser(session.user) + .pipe(Effect.map((synced) => synced.localUser)) + : localUserSessions.resolveLocalUser(session.user); + const access = yield* localUserSessions.loadUserAccess(localUser.id); + return localUserSessions.toUserSession( + localUser, + access, + headers.get("cookie"), + session.user.id, + ); + }), + Effect.catchTags({ + EffectDrizzleQueryError: (e) => Effect.fail(mapDatabaseError(e)), + WorkosAuthError: (e) => + Effect.fail( + new ApiAuthenticationError({ + cause: String(e.message), + message: "Failed to authenticate with WorkOS session", + }), + ), + WorkosLocalSyncServiceError: (e) => + Effect.fail( + new ApiAuthenticationError({ + cause: e.cause, + message: "Failed to authenticate with WorkOS session", + }), + ), + }), + ); + + const authenticateSecretKey = (rawSecretKey: string) => + pipe( + Effect.gen(function* () { + const record = yield* apiKeyService.validateSecretKey(rawSecretKey); + return { + cookie: null, + method: "secret-key", + name: `${record.project.name} API Key`, + organizations: [], + person: null, + projects: [ + { + id: record.project.id, + logo: null, + name: record.project.name, + organizationId: record.project.organizationId, + permissions: ["project:all"], + slug: record.project.slug, + }, + ], + user: null, + } satisfies ApiSecretKeySession; + }), + Effect.catchTags({ + ApiKeyNotFoundError: () => + Effect.fail( + new ApiAuthenticationError({ + cause: "Secret Key not found, is expired or is invalid.", + message: "Secret Key not found, is expired or is invalid.", + }), + ), + ApiKeyServiceError: (e) => + Effect.fail( + new ApiAuthenticationError({ + cause: e.cause, + message: "Failed to authenticate with secret key due to an internal error", + }), + ), + }), + ); + + const authenticatePublishableKey = (input: { distinctId: string; rawPublishableKey: string }) => + pipe( + Effect.gen(function* () { + const record = yield* apiKeyService.validatePublishableKey(input.rawPublishableKey); + return { + cookie: null, + method: "publishable-key", + name: `${record.project.name} API Key`, + organizations: [], + person: { distinctId: input.distinctId }, + projects: [ + { + id: record.project.id, + logo: null, + name: record.project.name, + organizationId: record.project.organizationId, + permissions: [], + slug: record.project.slug, + }, + ], + user: null, + } satisfies ApiPublishableKeySession; + }), + Effect.catchTags({ + ApiKeyNotFoundError: () => + Effect.fail( + new ApiAuthenticationError({ + cause: publishableKeyInvalidMessage, + message: publishableKeyInvalidMessage, + }), + ), + ApiKeyServiceError: (e) => + Effect.fail( + new ApiAuthenticationError({ + cause: e.cause, + message: "Failed to authenticate with publishable key due to an internal error", + }), + ), + }), + ); + + const authenticateSelectedMethod = ( + selected: SelectedAuthMethod, + ): Effect.Effect => { + switch (selected.method) { + case "api-key": + return authenticateApiKey(selected.rawApiKey); + case "workos-session": + return authenticateWorkosSession(selected.headers); + case "secret-key": + return authenticateSecretKey(selected.rawSecretKey); + case "publishable-key": + return authenticatePublishableKey({ + distinctId: selected.distinctId, + rawPublishableKey: selected.rawPublishableKey, + }); + } + }; + + return AuthMiddleware.of((httpEffect) => + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const selected = yield* selectAuthMethod(req); + const session = yield* Effect.provideService(authenticateSelectedMethod(selected), Db, db); + return yield* withIdentity( + session, + Effect.provideService(httpEffect, ApiAuthSession, session), + ); + }), + ); + }), +); + +/** + * Bridges {@link ApiAuthSession} into the canonical {@link AuthSession} + * tag that the core services consume. Use this wrapper around any route + * handler effect that depends on `AuthSession`. + */ +export const bridgeAuthSession = (effect: Effect.Effect) => + Effect.gen(function* () { + const session = yield* ApiAuthSession; + return yield* Effect.provideService(effect, AuthSession, session); + }); + +/** + * Translates the parsed SDK request headers into the per-call metadata bag the + * `SdkService` writes onto person traits. + */ +export const getPersonMetadataFromSdkHeaders = (parsedHeaders: { + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | undefined; + readonly "x-client-version"?: string | undefined; + readonly "x-distinct-id": string; + readonly "x-is-backgrounded": "false"; + readonly "x-is-debug-build": "true" | "false"; + readonly "x-nonce"?: string | undefined; + readonly "x-observer-mode": "true" | "false"; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | undefined; + readonly "x-platform-device"?: string | undefined; + readonly "x-platform-flavor": "native" | "browser"; + readonly "x-platform-flavor-version"?: string | undefined; + readonly "x-platform-version"?: string | undefined; + readonly "x-preferred-locales"?: string | undefined; + readonly "x-publishable-key": string; + readonly "x-sdk": "react-native" | "web"; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | undefined; +}) => ({ + clientBundleId: parsedHeaders["x-client-bundle-id"], + clientLocale: parsedHeaders["x-client-locale"], + clientVersion: parsedHeaders["x-client-version"], + distinctId: parsedHeaders["x-distinct-id"], + isBackgrounded: parsedHeaders["x-is-backgrounded"], + isDebugBuild: parsedHeaders["x-is-debug-build"], + nonce: parsedHeaders["x-nonce"], + observerMode: parsedHeaders["x-observer-mode"], + platform: parsedHeaders["x-platform"], + platformBrand: parsedHeaders["x-platform-brand"], + platformDevice: parsedHeaders["x-platform-device"], + platformFlavor: parsedHeaders["x-platform-flavor"], + platformFlavorVersion: parsedHeaders["x-platform-flavor-version"], + platformVersion: parsedHeaders["x-platform-version"], + preferredLocales: parsedHeaders["x-preferred-locales"], + publishableKey: parsedHeaders["x-publishable-key"], + sdk: parsedHeaders["x-sdk"], + sdkVersion: parsedHeaders["x-sdk-version"], + storefront: parsedHeaders["x-storefront"], +}); diff --git a/apps/backend/src/AuthSessionResolver.ts b/apps/backend/src/AuthSessionResolver.ts new file mode 100644 index 000000000..4069b924a --- /dev/null +++ b/apps/backend/src/AuthSessionResolver.ts @@ -0,0 +1,209 @@ +import { type DbError } from "@voidhash/db"; +import { + type AuthTokenVerifier, + extractBearerToken, + type JwtAuthError, +} from "@voidhash/core/services/auth/AuthTokenVerifier"; +import { LocalUserSessionService } from "@voidhash/core/services/auth/LocalUserSessionService"; +import { Workos, WorkosAuthError } from "@voidhash/core/services/auth/Workos"; +import { + RpcAuthenticationError, + RpcNotAuthenticatedError, + type UserSession, +} from "@voidhash/rpc"; +import type { Db } from "@voidhash/db"; +import * as HttpHeaders from "effect/unstable/http/Headers"; +import { Effect, Option } from "effect"; + +/** Union of the terminal failures the WorkOS session resolution can raise. */ +export type RpcAuthFailure = RpcAuthenticationError | RpcNotAuthenticatedError; + +const toWebHeaders = (headers: HttpHeaders.Headers): Headers => + new Headers( + Object.entries(headers).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + +const hasWorkosSessionCookieEffectHeader = (headers: HttpHeaders.Headers): boolean => + Option.exists(HttpHeaders.get(headers, "cookie"), (cookie) => cookie.includes("wos-session=")); + +const formatUnknownCause = (cause: unknown): string => { + if (cause instanceof Error) { + return cause.message; + } + + if (typeof cause === "string") { + return cause; + } + + try { + return JSON.stringify(cause); + } catch { + return String(cause); + } +}; + +const getDbErrorCause = (error: DbError): string => + error.cause === undefined ? error.message : formatUnknownCause(error.cause); + +/** + * Logs the underlying DB error and re-fails as a stable + * {@link RpcAuthenticationError} so the database cause never leaks to callers. + */ +export const mapAuthenticationDbError = ( + error: DbError, +): Effect.Effect => + Effect.logError("RPC authentication database error", { + cause: getDbErrorCause(error), + message: error.message, + }).pipe( + Effect.flatMap(() => + Effect.fail( + new RpcAuthenticationError({ + message: "Failed to authenticate due to a database error", + cause: error.message, + }), + ), + ), + ); + +/** + * Resolves an authenticated {@link UserSession} from the request headers, + * mirroring the exact logic shared by the RPC auth and admin-auth middlewares: + * + * - An `authorization` header → bearer-token path: the request's JWT is + * validated through {@link AuthTokenVerifier}, then + * the identity is materialised into a local user session. + * - Otherwise a `wos-session=` cookie → WorkOS session-cookie path. + * - Neither present → {@link RpcNotAuthenticatedError}. + * + * Failures from Db / WorkOS / JWT validation are normalised to + * {@link RpcAuthenticationError} / {@link RpcNotAuthenticatedError}. This is a + * behaviour-preserving extraction; the caller supplies `Db` (and the ambient + * {@link Workos} / {@link LocalUserSessionService}). + */ +export const resolveWorkosSession = ( + headers: HttpHeaders.Headers, + authTokenVerifier: AuthTokenVerifier["Service"], +): Effect.Effect => + Effect.gen(function* () { + const localUserSessions = yield* LocalUserSessionService; + const workosAuth = yield* Workos; + + const mapAuthenticationErrors = ( + effect: Effect.Effect< + A, + DbError | JwtAuthError | RpcNotAuthenticatedError | WorkosAuthError, + Db + >, + ): Effect.Effect => + effect.pipe( + Effect.catchTag("EffectDrizzleQueryError", mapAuthenticationDbError), + Effect.catchTag("JwtAuthError", (e) => + Effect.fail( + new RpcAuthenticationError({ + message: "Failed to authenticate: invalid or expired token", + cause: String(e.message), + }), + ), + ), + Effect.catchTag("WorkosAuthError", (e) => + Effect.fail( + new RpcAuthenticationError({ + message: "Failed to authenticate with WorkOS", + cause: String(e.message), + }), + ), + ), + ); + + const authenticateWorkosIdentity = ( + workosUserId: string, + cookie: string | null, + ): Effect.Effect => + Effect.gen(function* () { + const workosUser = yield* workosAuth.getUser(workosUserId); + const localUser = yield* localUserSessions.resolveLocalUser(workosUser); + + yield* !workosUser.externalId + ? workosAuth + .setUserExternalId(workosUser.id, localUser.id) + .pipe(Effect.catch(() => Effect.void)) + : Effect.void; + + const access = yield* localUserSessions.loadUserAccess(localUser.id); + return localUserSessions.toUserSession(localUser, access, cookie, workosUser.id); + }); + + const authenticateBearerToken = ( + requestHeaders: HttpHeaders.Headers, + ): Effect.Effect => + mapAuthenticationErrors( + Effect.gen(function* () { + const token = yield* extractBearerToken( + Option.getOrUndefined(HttpHeaders.get(requestHeaders, "authorization")), + ); + const validated = yield* authTokenVerifier.validateToken(token); + + if (!validated.payload.sub) { + return yield* Effect.fail( + new RpcNotAuthenticatedError({ + message: "Invalid token: missing subject", + }), + ); + } + + return yield* authenticateWorkosIdentity(validated.payload.sub, null); + }), + ); + + const authenticateWorkosSession = ( + requestHeaders: HttpHeaders.Headers, + ): Effect.Effect => + mapAuthenticationErrors( + Effect.gen(function* () { + const webHeaders = toWebHeaders(requestHeaders); + const session = yield* workosAuth.authenticateSessionCookie(webHeaders); + + if (!session) { + return yield* Effect.fail( + new RpcNotAuthenticatedError({ + message: "You are not authenticated", + }), + ); + } + + const localUser = yield* localUserSessions.resolveLocalUser(session.user); + yield* !session.user.externalId + ? workosAuth + .setUserExternalId(session.user.id, localUser.id) + .pipe(Effect.catch(() => Effect.void)) + : Effect.void; + + const access = yield* localUserSessions.loadUserAccess(localUser.id); + return localUserSessions.toUserSession( + localUser, + access, + webHeaders.get("cookie"), + session.user.id, + ); + }), + ); + + const authorization = Option.getOrUndefined(HttpHeaders.get(headers, "authorization")); + + if (authorization) { + return yield* authenticateBearerToken(headers); + } + + if (hasWorkosSessionCookieEffectHeader(headers)) { + return yield* authenticateWorkosSession(headers); + } + + return yield* Effect.fail( + new RpcNotAuthenticatedError({ + message: "You are not authenticated", + }), + ); + }); diff --git a/apps/backend/src/BackendApp.ts b/apps/backend/src/BackendApp.ts new file mode 100644 index 000000000..a3583e2e2 --- /dev/null +++ b/apps/backend/src/BackendApp.ts @@ -0,0 +1,1143 @@ +import { AppStoreServerSdk } from "@voidhash/app-store-server-sdk"; +import { VoidhashV1Api } from "@voidhash/api-contracts"; +import { Db } from "@voidhash/db"; +import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { PaymentConfigSecretCrypto } from "@voidhash/core/utils/crypto/PaymentConfigSecretCrypto"; +import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/PaywallAssetConfig"; +import { Workos } from "@voidhash/core/services/auth/Workos"; +import { + AnalyticsService, + ApiKeyService, + AuditLogPort, + AppStorePaymentProvider, + AppStorePaymentProviderEngine, + ApplePushNotificationServiceConfigLive, + AgentSessionIndexService, + AgentAttachmentService, + AppStorePaymentProviderConfigLive, + AppStorePaymentProviderServiceLive, + AppStoreTransactionVerifier, + ExperimentService, + FeatureFlagService, + FeedbackServiceLive, + FirebaseCloudMessagingServiceConfigLive, + FxRateService, + GooglePlayPaymentProvider, + GooglePlayPaymentProviderEngine, + GooglePlayPaymentProviderConfigLive, + GooglePlayPaymentProviderServiceLive, + GooglePlayPurchaseVerifier, + GooglePlayServerApi, + IdentityProjectionPublisher, + InternalFeatureFlagService, + LocalUserSessionService, + MimicHost, + MimicHostError, + NotificationSendingService, + NotificationsConfigurationService, + NotificationTokenService, + OrganizationBillingPort, + OrganizationMembershipSyncPort, + OrganizationMembershipWebhookPort, + OrganizationService, + PaymentProviderConfigurationService, + PaymentProviderProductService, + PaywallAssetService, + PersonNotificationTokenService, + PaywallArtifactStore, + PaywallArtifactStoreError, + PaywallDeployService, + PaywallEditSessionService, + PaywallLocationService, + PaywallReleaseService, + PaywallService, + PaywallThumbnailService, + PaywallWorkspaceService, + ComponentCompiler, + ComponentManifestCacheService, + CustomAnalyticsService, + PerkGrantService, + PerkService, + PersonIdentityService, + PersonService, + ProductPerkService, + ProductService, + ProjectSchemaCache, + ProjectService, + PublicFileStore, + PublicFileStoreError, + SnapshotImageRenderer, + SnapshotImageRenderError, + PushDeliveryDispatch, + PushNotificationSendService, + PurchaseProcessingService, + PurchaseService, + SchemaCacheInvalidationService, + SchemaService, + SdkService, + StripePaymentProvider, + StripePaymentProviderConfigLive, + StripePaymentProviderServiceLive, + UserService, + VoidQlService, + WebhookManagerService, + WorkosLocalSyncService, + WorkosOrgPort, +} from "@voidhash/core/services"; +import { AnalyticsWriterService } from "@voidhash/core/services/analyticsIngest/AnalyticsWriterService"; +import { createInitialPaywallDocumentInput, PaywallDesignerDocument } from "@voidhash/mimic-schema"; +import { AuthMiddleware } from "@voidhash/rpc"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import type * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { RpcSerialization } from "effect/unstable/rpc"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; + +import { BackendRpcGroups as RpcGroups } from "./BackendRpcGroups.ts"; +import { GooglePubSubPushVerifierLive } from "./GooglePubSubPushVerifier.ts"; +import { BackendSnapshotHtmlRendererLive } from "./PaywallSnapshotHtmlRenderer.ts"; + +import { AuthMiddlewareLive } from "./ApiMiddlewares.ts"; +import { McpRouteLayer } from "./routes/mcp.ts"; +import { McpAuthKitRouteLayer } from "./routes/mcp-authkit.ts"; +import { McpAuthKit } from "./McpAuthKit.ts"; +import { withRequestId } from "./Telemetry.ts"; +import { ApiKeysGroupLive } from "./routes/v1/api-keys.ts"; +import { AuthGroupLive } from "./routes/v1/auth.ts"; +import { NotificationsGroupLive } from "./routes/v1/notifications.ts"; +import { OrganizationsGroupLive } from "./routes/v1/organizations.ts"; +import { PaymentProviderConfigurationsGroupLive } from "./routes/v1/payment-provider-configurations.ts"; +import { PaymentProviderProductsGroupLive } from "./routes/v1/payment-provider-products.ts"; +import { PaywallDeploysGroupLive } from "./routes/v1/paywall-deploys.ts"; +import { PaywallLocationsGroupLive } from "./routes/v1/paywall-locations.ts"; +import { PerksGroupLive } from "./routes/v1/perks.ts"; +import { PersonsGroupLive } from "./routes/v1/persons.ts"; +import { ProductPerksGroupLive } from "./routes/v1/product-perks.ts"; +import { ProductsGroupLive } from "./routes/v1/products.ts"; +import { ProjectsGroupLive } from "./routes/v1/projects.ts"; +import { SchemaGroupLive } from "./routes/v1/schema.ts"; +import { SdkGroupLive } from "./routes/v1/sdk.ts"; +import { UsersGroupLive } from "./routes/v1/users.ts"; +import { WebhooksGroupLive } from "./routes/v1/webhooks.ts"; +import { AppleServerToServerNotificationRouteLayer } from "./routes/webhook-endpoints/apple-server-to-server.ts"; +import { GooglePlayRtdnNotificationRouteLayer } from "./routes/webhook-endpoints/google-play-rtdn.ts"; +import { StripeWebhookNotificationRouteLayer } from "./routes/webhook-endpoints/stripe.ts"; +import { PaywallServingRouteLayer } from "./routes/paywall-serving.ts"; +import { PublicFileServingRouteLayer } from "./routes/public-file-serving.ts"; +import { WorkosWebhookRouteLayer } from "./routes/webhooks/workos.ts"; +import { AnalyticsRpcsLive } from "./rpcs/analytics-rpcs.ts"; +import { ApiKeyRpcsLive } from "./rpcs/api-key-rpcs.ts"; +import { ExperimentRpcsLive } from "./rpcs/experiment-rpcs.ts"; +import { FeatureFlagRpcsLive } from "./rpcs/feature-flag-rpcs.ts"; +import { FeedbackRpcsLive } from "./rpcs/feedback-rpcs.ts"; +import { OrganizationRpcsLive } from "./rpcs/organization-rpcs.ts"; +import { PaymentProviderConfigurationRpcsLive } from "./rpcs/payment-provider-configuration-rpcs.ts"; +import { PaymentProviderProductRpcsLive } from "./rpcs/payment-provider-product-rpcs.ts"; +import { PushNotificationConfigurationRpcsLive } from "./rpcs/push-notification-configuration-rpcs.ts"; +import { PushNotificationSendRpcsLive } from "./rpcs/push-notification-send-rpcs.ts"; +import { AgentSessionRpcsLive } from "./rpcs/agent-session-rpcs.ts"; +import { PaywallAssetRpcsLive } from "./rpcs/paywall-asset-rpcs.ts"; +import { PaywallComponentRpcsLive } from "./rpcs/paywall-component-rpcs.ts"; +import { PaywallDeployRpcsLive } from "./rpcs/paywall-deploy-rpcs.ts"; +import { PaywallLocationRpcsLive } from "./rpcs/paywall-location-rpcs.ts"; +import { PaywallRpcsLive } from "./rpcs/paywall-rpcs.ts"; +import { PaywallWorkspaceRpcsLive } from "./rpcs/paywall-workspace-rpcs.ts"; +import { PerkRpcsLive } from "./rpcs/perk-rpcs.ts"; +import { PersonRpcsLive } from "./rpcs/person-rpcs.ts"; +import { ProductPerkRpcsLive } from "./rpcs/product-perk-rpcs.ts"; +import { ProductRpcsLive } from "./rpcs/product-rpcs.ts"; +import { ProjectRpcsLive } from "./rpcs/project-rpcs.ts"; +import { UserRpcsLive } from "./rpcs/user-rpcs.ts"; +import { VoidQlRpcsLive } from "./rpcs/voidql-rpcs.ts"; +import { WebhookRpcsLive } from "./rpcs/webhook-rpcs.ts"; + +/** + * The always-on infrastructure services the route graph builds on. Declaring + * this as a concrete union (rather than `any`) lets the type system verify that every + * `Layer.provide` / `HttpRouter.provideRequest` in the route graph is actually + * satisfied — without it, a missing service (e.g. the raw WorkOS webhook + * handler's `Db`) only surfaces as a runtime "Service not found" instead of a + * compile error. ClickHouse is deliberately not mandatory: analytics services + * use it when the runtime's layer provides it and degrade to empty results when + * absent. The caller's bound `InfraLayer` may be structurally wider than this + * contract, so the cloud composition can still carry its analytics clients. + */ +export type InfraServices = + | Db + | Workos + | WorkosOrgPort + | PaywallAssetConfig + | PaywallArtifactStore + | PublicFileStore + | StripePaymentProvider + | AppStorePaymentProvider + | GooglePlayPaymentProvider + | IdentityProjectionPublisher + | MimicHost + | ComponentCompiler + | SnapshotImageRenderer + | ProjectSchemaCache; + +export interface BackendRuntimeLayers< + RInfrastructure = never, + RFeatureRpcs extends Rpc.Any = never, + RFeatureServices = never, + RExtensionRpcs extends Rpc.Any = never, +> { + readonly auth: Layer.Layer; + readonly infrastructure: Layer.Layer; + readonly features: BackendFeatureComposition; + readonly routes?: Layer.Layer; + readonly webhookManager?: Layer.Layer; + /** + * The hardened, single-shared `analytics_query` ClickHouse client that backs the + * VoidQL read path (`readonly = 1` CONST profile, SELECT-only, no row policy — + * isolation is the compiler-injected bound predicate). When omitted, + * {@link VoidQlService} resolves the ambient (RLS readonly) client from + * `infrastructure`, which fail-closes to empty rows because VoidQL sets no + * `SQL_organization_id`. + */ + readonly analyticsQueryClient?: Layer.Layer; + /** + * Queue-backed push-delivery dispatcher. Defaults to {@link PushDeliveryDispatch.noop} + * (dev/smoke — rows are created but never delivered); the production worker + * overrides it with the `PushDeliveryQueue` producer bound at init. + */ + readonly pushDeliveryDispatch?: Layer.Layer; + readonly rpcExtension: BackendRpcExtension; +} + +/** + * Live App Store config-write provider registered under the public + * `AppStorePaymentProvider` tag, consumed by `PaymentProviderConfigurationService` + * and `PaymentProviderProductService` when an operator creates or updates an App + * Store configuration. It validates the configuration against the canonical + * schema and encrypts the secret Apple PKCS8 key on write (keyed by + * `ENCRYPTION_KEY`; a no-op when the env var is unset). Unlike the + * record engine (`BackendAppStorePaymentProviderServiceLive`) it needs no App + * Store REST SDK, FX, or purchase-processing graph — only the encryption key. + */ +export const BackendAppStorePaymentProviderConfigLive = AppStorePaymentProviderConfigLive.pipe( + Layer.provide( + PaymentConfigSecretCrypto.layer({ + key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + }), + ), +); + +/** + * Live Google Play config-write provider for the admin configuration and + * product-mapping flow. It validates package/service-account configuration and + * encrypts the service-account JSON on write; purchase ingestion remains gated + * behind the separate Google Play record-engine work. + */ +export const BackendGooglePlayPaymentProviderConfigLive = GooglePlayPaymentProviderConfigLive.pipe( + Layer.provide( + PaymentConfigSecretCrypto.layer({ + key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + }), + ), +); + +/** + * Live Stripe config-write provider for the admin configuration and revenue + * consolidation flow. It validates account/API/webhook settings and encrypts + * Stripe secrets on write; checkout/session creation is intentionally outside + * this provider path. + */ +export const BackendStripePaymentProviderConfigLive = StripePaymentProviderConfigLive.pipe( + Layer.provide( + PaymentConfigSecretCrypto.layer({ + key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + }), + ), +); + +export const BackendPaymentProviderStubsLive = Layer.mergeAll( + BackendStripePaymentProviderConfigLive, + BackendAppStorePaymentProviderConfigLive, + BackendGooglePlayPaymentProviderConfigLive, +); + +/** + * Live push delivery-provider tags (FCM + APNs), supplied at the app root EXACTLY + * like the payment-provider config adapters: each pipes the shared + * {@link PaymentConfigSecretCrypto} keyed by `ENCRYPTION_KEY`. In Phase 1 these + * carry only config validation + encrypt-on-write; the `deliver` engine is gated. + */ +export const BackendFirebaseCloudMessagingServiceLive = + FirebaseCloudMessagingServiceConfigLive.pipe( + Layer.provide( + PaymentConfigSecretCrypto.layer({ + key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + }), + ), + ); + +export const BackendApplePushNotificationServiceLive = ApplePushNotificationServiceConfigLive.pipe( + Layer.provide( + PaymentConfigSecretCrypto.layer({ + key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + }), + ), +); + +export const BackendPushProvidersLive = Layer.mergeAll( + BackendFirebaseCloudMessagingServiceLive, + BackendApplePushNotificationServiceLive, +); + +/** + * Live {@link FeedbackService} for the Cloudflare backend. Reads the Slack bot + * token and target channel from the `SLACK_BOT_TOKEN` / `SLACK_FEEDBACK_CHANNEL_ID` + * env bindings at worker boot; `Db` is supplied by the surrounding domain-services + * graph. Both default to empty so un-provisioned stages (dev, in-process smoke + * tests) boot — feedback is still persisted, the Slack relay simply no-ops. + */ +export const BackendFeedbackServiceLive = FeedbackServiceLive({ + botToken: Effect.sync(() => process.env.SLACK_BOT_TOKEN ?? ""), + defaultChannel: Effect.sync(() => process.env.SLACK_FEEDBACK_CHANNEL_ID ?? ""), +}); + +/** + * Live App Store payment-provider service for the Cloudflare backend, used by + * `SdkService` (`POST /api/v1/sdk/sync-transaction`) and the Apple + * server-to-server webhook route. Composes the ported provider engine, webhook + * handler, and queries (`AppStorePaymentProviderServiceLive`) with their + * dependencies: + * - the App Store REST SDK over `FetchHttpClient`, + * - `FxRateService` for money conversion (its API key is read from the + * `EXCHANGE_RATE_API_KEY` env binding), + * - `PurchaseProcessingService` (+ its `PerkGrantService` dep) for purchase + * state writes. + * `Db` and `PersonIdentityService` are supplied by the surrounding domain + * services graph. + */ +export const BackendAppStorePaymentProviderServiceLive = AppStorePaymentProviderServiceLive.pipe( + Layer.provide( + AppStoreTransactionVerifier.layer.pipe(Layer.provide(AppStorePaymentProviderEngine.layer)), + ), + Layer.provide(AppStoreServerSdk.layer.pipe(Layer.provide(FetchHttpClient.layer))), + Layer.provide( + FxRateService.layer({ + apiKey: Effect.sync(() => process.env.EXCHANGE_RATE_API_KEY ?? ""), + }), + ), + Layer.provide(PurchaseProcessingService.layer.pipe(Layer.provide(PerkGrantService.layer))), + Layer.provide( + PaymentConfigSecretCrypto.layer({ + key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + }), + ), +); + +/** + * The Google Play record-engine boundary for the Cloudflare backend. Mirrors + * {@link BackendAppStorePaymentProviderServiceLive}: composes the public + * `GooglePlayPaymentProviderService` (SDK path + RTDN webhook handler) with the + * Google Play Developer API SDK over `FetchHttpClient`, `FxRateService` for + * money conversion, `PurchaseProcessingService` (+ its `PerkGrantService` dep), + * and `PaymentConfigSecretCrypto` for decrypting the per-tenant service-account + * key. `Db` and `PersonIdentityService` are supplied by the surrounding domain + * layer. + */ +export const BackendGooglePlayPaymentProviderServiceLive = + GooglePlayPaymentProviderServiceLive.pipe( + Layer.provide( + GooglePlayPurchaseVerifier.layer.pipe(Layer.provide(GooglePlayPaymentProviderEngine.layer)), + ), + Layer.provide(GooglePlayServerApi.layer.pipe(Layer.provide(FetchHttpClient.layer))), + Layer.provide( + FxRateService.layer({ + apiKey: Effect.sync(() => process.env.EXCHANGE_RATE_API_KEY ?? ""), + }), + ), + Layer.provide(PurchaseProcessingService.layer.pipe(Layer.provide(PerkGrantService.layer))), + Layer.provide( + PaymentConfigSecretCrypto.layer({ + key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + }), + ), + ); + +/** + * Live Stripe payment-provider record service for the Cloudflare backend, used + * by the Stripe webhook route. Composes the record engine, webhook handler, and + * queries (`StripePaymentProviderServiceLive`) with their dependencies: + * - `FetchHttpClient` for per-tenant Stripe REST calls (fee / line-item lookups), + * - `FxRateService` for USD conversion (`EXCHANGE_RATE_API_KEY`), + * - `PurchaseProcessingService` (+ `PerkGrantService`) for purchase state writes, + * - `PaymentConfigSecretCrypto` to decrypt the per-tenant secret + signing keys. + * `Db` and `PersonIdentityService` are supplied by the surrounding domain graph. + */ +export const BackendStripePaymentProviderServiceLive = StripePaymentProviderServiceLive.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide( + FxRateService.layer({ + apiKey: Effect.sync(() => process.env.EXCHANGE_RATE_API_KEY ?? ""), + }), + ), + Layer.provide(PurchaseProcessingService.layer.pipe(Layer.provide(PerkGrantService.layer))), + Layer.provide( + PaymentConfigSecretCrypto.layer({ + key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + }), + ), +); + +/** + * Public URL bases for paywall release HTML: + * - `cdnUrl` — legacy visual-editor releases, `{cdnUrl}/{s3Bucket}/{s3Key}`. + * - `publicBaseUrl` — content-addressed code and current visual-editor releases + * (deploy contract §5), + * `{publicBaseUrl}/p//...`, served by this worker's public + * `GET /p/:contentHash/*` route. `PAYWALL_PUBLIC_BASE_URL` is set on the + * worker env by `stacks/backend/workers/BackendWorker.ts` (custom domain on + * production/preview, pinned dev port elsewhere, overridable to front it + * with a CDN exposing the same layout). + * Lazy (`Layer.sync`) so `process.env` is read at worker boot, not module + * load. + */ +export const BackendPaywallAssetConfigLive = Layer.sync(PaywallAssetConfig, () => ({ + cdnUrl: "https://assets.voidha.sh", + publicBaseUrl: process.env.PAYWALL_PUBLIC_BASE_URL ?? "https://api.voidhash.com", +})); + +/** + * Placeholder {@link PaywallArtifactStore} for harnesses that run the backend + * graph outside a Cloudflare Worker (test layers, in-process RPC smoke). The + * deployed worker provides the live R2 adapter instead + * (`stacks/backend/infrastructure/PaywallArtifactStore.ts`). Every operation + * fails with a stable `PaywallArtifactStoreError`, which `PaywallDeployService` + * surfaces as a 500-style service error — deploy creation still works + * (manifest registration is DB-only), but blob upload/finalize/serving report + * the store as unconfigured instead of dying. + */ +export const BackendPaywallArtifactStoreStubLive = Layer.succeed(PaywallArtifactStore, { + bucketName: "paywall-artifacts-unconfigured", + getObject: () => + Effect.fail( + new PaywallArtifactStoreError({ + cause: "unconfigured", + message: "Paywall artifact store is not configured in this backend yet", + }), + ), + head: () => + Effect.fail( + new PaywallArtifactStoreError({ + cause: "unconfigured", + message: "Paywall artifact store is not configured in this backend yet", + }), + ), + putObject: () => + Effect.fail( + new PaywallArtifactStoreError({ + cause: "unconfigured", + message: "Paywall artifact store is not configured in this backend yet", + }), + ), +}); + +/** + * Placeholder {@link PublicFileStore} for harnesses that run the backend graph + * outside a Cloudflare Worker (test layers, in-process RPC smoke). The deployed + * worker provides the live R2 adapter instead + * (`stacks/backend/infrastructure/PublicFileStore.ts`). Every storage operation + * fails with a stable `PublicFileStoreError` so avatar mutations report the + * store as unconfigured instead of dying; URL helpers return an `.invalid` base + * so any leaked value is obviously non-routable. + */ +export const BackendPublicFileStoreStubLive = Layer.succeed(PublicFileStore, { + publicBaseUrl: "https://public-files-unconfigured.invalid", + publicUrl: (key: string) => `https://public-files-unconfigured.invalid/files/${key}`, + deleteObject: () => + Effect.fail( + new PublicFileStoreError({ + cause: "unconfigured", + message: "Public file store is not configured in this backend yet", + }), + ), + getObject: () => + Effect.fail( + new PublicFileStoreError({ + cause: "unconfigured", + message: "Public file store is not configured in this backend yet", + }), + ), + putObject: () => + Effect.fail( + new PublicFileStoreError({ + cause: "unconfigured", + message: "Public file store is not configured in this backend yet", + }), + ), +}); + +/** + * Succeeding stub {@link MimicHost} for harnesses that run the backend graph + * outside a Cloudflare Worker (test layers, in-process RPC smoke). The + * deployed worker provides the live mimic-db adapter instead + * (`stacks/backend/infrastructure/MimicHost.ts`). Unlike the artifact-store + * stub above, this one succeeds with a fixed fake token so the + * `RequestPaywallEditToken` handler chain (permission check → ensure → mint) + * is exercised end-to-end in process. + */ +export const BackendMimicHostStubLive = Layer.succeed(MimicHost, { + closePaywallConnection: () => Effect.void, + createPaywallEditToken: () => + Effect.sync(() => ({ + expiresAt: new Date(Date.now() + 300_000), + token: "stub-token", + url: "wss://stub.invalid/ws", + })), + ensurePaywallDocument: () => Effect.void, + getPaywallSnapshot: () => + Effect.succeed( + PaywallDesignerDocument.decode( + PaywallDesignerDocument.encode(createInitialPaywallDocumentInput()), + )?.[0], + ), + getPaywallDocument: () => + Effect.fail( + new MimicHostError({ + cause: "getPaywallDocument is not available in the in-process backend harness", + message: "mimic host document read is not stubbed", + }), + ), + getConnectedPaywallDocument: () => + Effect.fail( + new MimicHostError({ + cause: "connected document reads are not available in the in-process backend harness", + message: "mimic host connected document read is not stubbed", + }), + ), + heartbeatPaywallConnection: () => Effect.void, + openPaywallConnection: () => + Effect.fail( + new MimicHostError({ + cause: "document connections are not available in the in-process backend harness", + message: "mimic host document connection is not stubbed", + }), + ), + submitConnectedPaywallTransaction: () => + Effect.fail( + new MimicHostError({ + cause: "connected transaction submits are not available in the in-process backend harness", + message: "mimic host connected transaction submit is not stubbed", + }), + ), + submitPaywallTransaction: () => + Effect.fail( + new MimicHostError({ + cause: "submitPaywallTransaction is not available in the in-process backend harness", + message: "mimic host transaction submit is not stubbed", + }), + ), +}); + +/** + * Stub {@link ComponentCompiler} for harnesses that run the backend graph + * outside a Node/container host: it reports `unavailable`. Workspace diagnostics + * for component files then degrade to cache-only (`unknown` on a miss), while + * composition diagnostics still validate purely. Callers can provide a compiler + * implementation when executable code checks are available. + */ +export const BackendComponentCompilerStubLive = Layer.succeed(ComponentCompiler, { + compileCheck: () => Effect.succeed({ status: "unavailable" as const }), + compileAndExtract: () => Effect.succeed({ status: "unavailable" as const }), +}); + +/** Renderer stub for backend harnesses without a headless browser binding. */ +export const BackendSnapshotImageRendererStubLive = Layer.succeed(SnapshotImageRenderer, { + render: () => + Effect.fail( + new SnapshotImageRenderError({ + cause: "headless browser is not configured", + message: "Paywall preview rendering is unavailable in this runtime", + }), + ), +}); + +export const BackendNoopIdentityProjectionPublisherLive = IdentityProjectionPublisher.noop; + +const isAllowedCorsOrigin = (origin: string): boolean => { + try { + const url = new URL(origin); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + + return ( + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname.endsWith(".localhost") || + url.hostname === "voidhash.com" || + url.hostname.endsWith(".voidhash.com") + ); + } catch { + return false; + } +}; + +const corsHeaders = (origin: string | undefined): Record => + origin && isAllowedCorsOrigin(origin) + ? { + "access-control-allow-credentials": "true", + "access-control-allow-origin": origin, + vary: "Origin", + } + : {}; + +const preflightCorsHeaders = ( + origin: string | undefined, + accessControlRequestHeaders: string | undefined, +): Record => ({ + ...corsHeaders(origin), + "access-control-allow-headers": accessControlRequestHeaders ?? "", + "access-control-allow-methods": "GET, HEAD, PUT, PATCH, POST, DELETE", + "access-control-max-age": "600", + vary: accessControlRequestHeaders ? "Origin, Access-Control-Request-Headers" : "Origin", +}); + +const CorsLayer = HttpRouter.middleware( + (httpApp) => + Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => { + if (request.method === "OPTIONS") { + return Effect.succeed( + HttpServerResponse.empty({ + headers: preflightCorsHeaders( + request.headers.origin, + request.headers["access-control-request-headers"], + ), + status: 204, + }), + ); + } + + return Effect.map(httpApp, (response) => + HttpServerResponse.setHeaders(response, corsHeaders(request.headers.origin)), + ); + }), + { global: true }, +); + +const HealthCheckRoute = Layer.effectDiscard( + Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + yield* router.add("GET", "/api/health", HttpServerResponse.text("OK")); + yield* router.add("GET", "/health", HttpServerResponse.text("OK")); + }), +); + +export interface BackendRuntimeCapabilities { + readonly auditLogs: boolean; + readonly billing: boolean; +} + +const RuntimeCapabilitiesRoute = (capabilities: BackendRuntimeCapabilities) => + Layer.effectDiscard( + Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + yield* router.add( + "GET", + "/api/runtime-capabilities", + HttpServerResponse.jsonUnsafe({ + enterprise: capabilities, + }), + ); + }), + ); + +export type BackendCoreFeatureServices = + | AuditLogPort + | OrganizationBillingPort + | OrganizationMembershipSyncPort + | OrganizationMembershipWebhookPort; + +const buildBackendFoundation = ( + infrastructure: Layer.Layer, +) => { + const FoundationSupportServicesLayer = Layer.mergeAll( + LocalUserSessionService.layer, + PersonIdentityService.layer, + SchemaCacheInvalidationService.layer, + ).pipe(Layer.provideMerge(infrastructure)); + + return { FoundationSupportServicesLayer, infrastructure }; +}; + +export type BackendFoundationGraph = ReturnType< + typeof buildBackendFoundation +>; + +/** + * Assembles the shared RPC handler + support/domain service graph used by both + * the production HTTP handler ({@link buildBackendFetch}) and the in-process RPC + * integration smoke ({@link buildBackendRpcServices}). Returns the three layers + * the route graph is built from so neither caller has to re-derive the intricate + * provide-ordering. + */ +const buildBackendServiceGraph = < + RInfrastructure = never, + RFeatureRpcs extends Rpc.Any = never, + RFeatureServices = never, +>( + layers: Pick< + BackendRuntimeLayers, + | "features" + | "infrastructure" + | "webhookManager" + | "analyticsQueryClient" + | "pushDeliveryDispatch" + >, +) => { + const RpcHandlersLayer = Layer.mergeAll( + AgentSessionRpcsLive, + AnalyticsRpcsLive, + ApiKeyRpcsLive, + ExperimentRpcsLive, + FeatureFlagRpcsLive, + FeedbackRpcsLive, + OrganizationRpcsLive, + PaymentProviderConfigurationRpcsLive, + PaymentProviderProductRpcsLive, + PushNotificationConfigurationRpcsLive, + PushNotificationSendRpcsLive, + PaywallAssetRpcsLive, + PaywallComponentRpcsLive, + PaywallDeployRpcsLive, + PaywallLocationRpcsLive, + PaywallRpcsLive, + PaywallWorkspaceRpcsLive, + PerkRpcsLive, + PersonRpcsLive, + ProductPerkRpcsLive, + ProductRpcsLive, + ProjectRpcsLive, + UserRpcsLive, + VoidQlRpcsLive, + WebhookRpcsLive, + ); + + const foundation = buildBackendFoundation(layers.infrastructure); + const FeatureSupportServicesLayer = layers.features.supportServices(foundation); + + const WorkosLocalSyncServiceLayer = WorkosLocalSyncService.layer.pipe( + Layer.provide(foundation.FoundationSupportServicesLayer), + Layer.provide(FeatureSupportServicesLayer), + Layer.provide(layers.infrastructure), + ); + + const SupportServicesLayer = Layer.mergeAll( + foundation.FoundationSupportServicesLayer, + FeatureSupportServicesLayer, + WorkosLocalSyncServiceLayer, + ); + + // ExperimentService depends on FeatureFlagService (its backing-flag engine), + // provided explicitly since `mergeAll` does not cross-wire siblings. Shared + // (memoized by layer reference) between the standalone entry below and + // PaywallLocationService, which depends on it at serve time. + const ExperimentServiceLayer = ExperimentService.layer.pipe( + Layer.provide(FeatureFlagService.layer), + ); + + // VoidQL runs under the hardened single-shared `analytics_query` user when the + // caller wires that client (Layer.provide satisfies its ClickhouseWebClient + // before the ambient RLS readonly client is merged in); without it, it resolves + // the ambient readonly client and fail-closes to empty rows. + const VoidQlServiceLive = layers.analyticsQueryClient + ? VoidQlService.layer.pipe(Layer.provide(layers.analyticsQueryClient)) + : VoidQlService.layer; + + const PaywallWorkspaceServiceLive = PaywallWorkspaceService.layer.pipe( + Layer.provide(PaywallService.layer), + Layer.provide(ComponentManifestCacheService.layer), + ); + + const PaywallThumbnailServiceLive = PaywallThumbnailService.layer.pipe( + Layer.provide(ComponentManifestCacheService.layer), + ); + + const AgentSessionIndexServiceLive = AgentSessionIndexService.layer; + const BaseDomainServicesLayer = Layer.mergeAll( + AgentSessionIndexServiceLive, + AgentAttachmentService.layer.pipe(Layer.provide(AgentSessionIndexServiceLive)), + AnalyticsService.layer, + CustomAnalyticsService.layer, + ApiKeyService.layer, + BackendFeedbackServiceLive, + BackendAppStorePaymentProviderServiceLive, + BackendGooglePlayPaymentProviderServiceLive, + BackendStripePaymentProviderServiceLive, + ExperimentServiceLayer, + FeatureFlagService.layer, + InternalFeatureFlagService.layer, + McpAuthKit.layer, + // Push notifications: the per-(project, provider) config CRUD consumes the + // two delivery-provider tags (supplied by BackendPushProvidersLive below) + // and reads PUSH_REQUIRE_ENCRYPTION so prod fails closed on plaintext secrets. + NotificationsConfigurationService.layer({ + requireEncryption: Effect.sync(() => process.env.PUSH_REQUIRE_ENCRYPTION === "true"), + }), + // Read-only send-history surface backing the "sent notifications" activity page. + PushNotificationSendService.layer, + PersonNotificationTokenService.layer, + OrganizationService.layer, + PaywallAssetService.layer, + PaymentProviderConfigurationService.layer, + PaymentProviderProductService.layer, + PaywallDeployService.layer, + PaywallLocationService.layer.pipe(Layer.provide(ExperimentServiceLayer)), + PaywallReleaseService.layer.pipe(Layer.provide(BackendSnapshotHtmlRendererLive)), + PaywallService.layer, + PaywallThumbnailServiceLive, + ComponentManifestCacheService.layer, + // The workspace service needs PaywallService (authz + slug resolution) and + // the manifest cache; `mergeAll` does not cross-wire siblings, so both are + // provided explicitly. MimicHost and ComponentCompiler come from the + // infrastructure layer. + PaywallWorkspaceServiceLive, + PaywallEditSessionService.layer.pipe(Layer.provide(PaywallWorkspaceServiceLive)), + PerkGrantService.layer, + PerkService.layer, + PersonService.layer, + ProductPerkService.layer, + ProductService.layer, + ProjectService.layer, + PurchaseService.layer, + SchemaService.layer, + UserService.layer, + VoidQlServiceLive, + layers.webhookManager ?? WebhookManagerService.layer, + ).pipe(Layer.provide(BackendPushProvidersLive), Layer.provide(SupportServicesLayer)); + + // The synchronous SDK person-attribute write projects into ClickHouse via the + // real `analyticsWriterLayer`. This is scoped to `SdkService` ONLY (provided + // innermost so it discharges the `IdentityProjectionPublisher` requirement + // first) — everywhere else, including `PersonIdentityService`'s own publisher + // and the async ingest processor, keeps the no-op binding so person rows are + // never double-written. + const SdkIdentityProjectionPublisherLayer = IdentityProjectionPublisher.analyticsWriterLayer.pipe( + Layer.provide(AnalyticsWriterService.layer), + Layer.provide(layers.infrastructure), + ); + + const DomainServicesLayer = Layer.mergeAll( + BaseDomainServicesLayer, + SdkService.layer.pipe( + Layer.provide(SdkIdentityProjectionPublisherLayer), + Layer.provide(BaseDomainServicesLayer), + Layer.provide(SupportServicesLayer), + ), + // The UUID seam depends on PersonNotificationTokenService (from Base) plus Db + // and AuditLogPort (from Support); build it on top of both. + NotificationTokenService.layer.pipe( + Layer.provide(BaseDomainServicesLayer), + Layer.provide(SupportServicesLayer), + ), + // The push send path depends on PersonNotificationTokenService (Base), + // PersonIdentityService + Db (Support), and the queue-backed dispatcher + // (from the worker, or the noop in dev/smoke). + NotificationSendingService.layer.pipe( + Layer.provide(layers.pushDeliveryDispatch ?? PushDeliveryDispatch.noop), + Layer.provide(BaseDomainServicesLayer), + Layer.provide(SupportServicesLayer), + ), + ); + + return { + DomainServicesLayer, + FeatureSupportServicesLayer, + RpcHandlersLayer, + SupportServicesLayer, + }; +}; + +export type BackendServiceGraph = ReturnType< + typeof buildBackendServiceGraph +>; + +/** + * Builds the support and domain services needed by long-lived agent hosts. + * Unlike the HTTP composition this returns the service context itself, allowing + * WebSocket runtimes to capture it once and execute workspace tools in-process. + */ +export const buildBackendAgentServices = < + RInfrastructure = never, + RFeatureRpcs extends Rpc.Any = never, + RFeatureServices = never, +>( + layers: Pick< + BackendRuntimeLayers, + | "features" + | "infrastructure" + | "webhookManager" + | "analyticsQueryClient" + | "pushDeliveryDispatch" + >, +) => { + const graph = buildBackendServiceGraph(layers); + return graph.DomainServicesLayer.pipe(Layer.provideMerge(graph.SupportServicesLayer)); +}; + +/** Private or enterprise RPC surface mounted by an application composition root. */ +export interface BackendRpcExtension { + readonly group: RpcGroup.RpcGroup; + readonly services: ( + graph: BackendServiceGraph, + ) => Layer.Layer< + Rpc.ToHandler | Rpc.Middleware, + never, + RInfrastructure + >; +} + +/** Product feature bundle that supplies core ports, RPCs, routes, and UI capabilities. */ +export interface BackendFeatureComposition { + readonly group: RpcGroup.RpcGroup; + readonly runtimeCapabilities: BackendRuntimeCapabilities; + readonly routes: ( + graph: BackendServiceGraph, + ) => Layer.Layer< + never, + never, + HttpRouter.HttpRouter | HttpRouter.Request | RInfrastructure + >; + readonly supportServices: ( + foundation: BackendFoundationGraph, + ) => Layer.Layer; + readonly services: ( + graph: BackendServiceGraph, + ) => Layer.Layer, never, RInfrastructure>; +} + +/** Core-only feature composition used when the enterprise source tree is absent. */ +export const NoBackendFeatures: BackendFeatureComposition = { + group: RpcGroup.make(), + routes: () => Layer.empty, + runtimeCapabilities: { auditLogs: false, billing: false }, + services: () => Layer.empty, + supportServices: () => + Layer.mergeAll( + AuditLogPort.noop, + OrganizationBillingPort.noop, + OrganizationMembershipSyncPort.noop, + OrganizationMembershipWebhookPort.noop, + ), +}; + +/** Explicitly disables additional RPC surfaces for a backend composition root. */ +export const NoBackendRpcExtension: BackendRpcExtension = { + group: RpcGroup.make(), + services: () => Layer.empty, +}; + +/** + * The RPC handlers + auth middleware, fed by the full support/domain service + * graph — i.e. exactly the context `RpcTest.makeClient(RpcGroups)` requires + * (`Rpc.ToHandler | AuthMiddleware`). Used by the + * in-process integration smoke to dispatch RPCs against the real handler graph + * without an HTTP transport. The only requirements that remain are the + * {@link InfraServices} (provided by the caller's `infrastructure` layer) and + * the asynchronous workflow ports the handlers resolve lazily at request time + * (`WebhookDeliveryWorkflow`, `IdentifyDistinctIdCompletionWorkflow`, + * `AppStoreReplayParkedNotificationsWorkflow`). + */ +export const buildBackendRpcServices = < + RInfrastructure = never, + RFeatureRpcs extends Rpc.Any = never, + RFeatureServices = never, + RExtensionRpcs extends Rpc.Any = never, +>( + layers: Pick< + BackendRuntimeLayers, + | "auth" + | "features" + | "infrastructure" + | "webhookManager" + | "analyticsQueryClient" + | "rpcExtension" + >, +) => { + const graph = buildBackendServiceGraph(layers); + const { DomainServicesLayer, RpcHandlersLayer, SupportServicesLayer } = graph; + const FeatureServicesLayer = layers.features.services(graph); + const ExtensionServicesLayer = layers.rpcExtension.services(graph); + + return Layer.mergeAll( + RpcHandlersLayer, + FeatureServicesLayer, + ExtensionServicesLayer, + layers.auth, + ).pipe(Layer.provide(DomainServicesLayer), Layer.provide(SupportServicesLayer)); +}; + +/** + * Builds the backend HTTP handler from the same RPC/API route graph used in production. + * + * @internal The monorepo composition roots consume this source entry directly. The + * inferred Effect route type exceeds TypeScript's declaration serializer limit, so + * consumers compile this exact source signature instead of a widened declaration. + */ +export const buildBackendFetch = < + RInfrastructure = never, + RFeatureRpcs extends Rpc.Any = never, + RFeatureServices = never, + RExtensionRpcs extends Rpc.Any = never, +>( + layers: BackendRuntimeLayers, +) => { + const graph = buildBackendServiceGraph(layers); + const { DomainServicesLayer, RpcHandlersLayer, SupportServicesLayer } = graph; + const FeatureServicesLayer = layers.features.services(graph); + const ExtensionServicesLayer = layers.rpcExtension.services(graph); + const RpcGroup = RpcGroups.merge(layers.features.group, layers.rpcExtension.group); + + const RpcRouteDependencies = Layer.mergeAll( + RpcSerialization.layerNdjson, + layers.auth, + RpcHandlersLayer, + FeatureServicesLayer, + ExtensionServicesLayer, + ); + + const RpcRoutesBase = RpcServer.layerHttp({ + group: RpcGroup, + path: "/rpc/*", + protocol: "http", + }); + + const RpcRoutesLayer = RpcRoutesBase.pipe( + Layer.provide(RpcRouteDependencies.pipe(Layer.provide(SupportServicesLayer))), + Layer.provide(DomainServicesLayer), + Layer.provide(SupportServicesLayer), + ); + + const V1GroupsLayer = Layer.mergeAll( + ApiKeysGroupLive, + AuthGroupLive, + NotificationsGroupLive, + OrganizationsGroupLive, + PaymentProviderConfigurationsGroupLive, + PaymentProviderProductsGroupLive, + PaywallDeploysGroupLive, + PaywallLocationsGroupLive, + PerksGroupLive, + PersonsGroupLive, + ProductPerksGroupLive, + ProductsGroupLive, + ProjectsGroupLive, + SchemaGroupLive, + SdkGroupLive, + UsersGroupLive, + WebhooksGroupLive, + ); + + // Provided to every group as the per-request `AuthMiddleware` impl. Itself + // depends on `ApiKeyService`, `LocalUserSessionService`, and `Workos` — + // wired here so the api-contracts middleware sees a populated context. + const HttpAuthMiddlewareLive = AuthMiddlewareLive.pipe( + Layer.provide(DomainServicesLayer), + Layer.provide(SupportServicesLayer), + ); + + const V1ApiRoutes = HttpApiBuilder.layer(VoidhashV1Api, { + openapiPath: "/api/docs/openapi.json", + }).pipe( + Layer.provide(V1GroupsLayer), + Layer.provide(HttpAuthMiddlewareLive), + Layer.provide(DomainServicesLayer), + Layer.provide(SupportServicesLayer), + ); + + // Webhook routes register raw handlers on the underlying `HttpRouter`. A raw + // `router.add` handler's service requirements (DB + WorkOS + + // LocalUserSessionService + OrganizationMembershipWebhookPort for the WorkOS + // one) surface as a deferred + // `Request.From<"Requires", …>` that resolves at request time, NOT a normal + // layer dependency — so `Layer.provide` does not discharge it (it would + // silently leak to the request handler and die with "Service not found"). + // `HttpRouter.provideRequest` is the combinator that satisfies these + // request-scoped requirements. The Autumn webhook handler resolves `Db` + + // `BillingService` at request time (both part of the merged graph below); + // the Apple handler resolves the live public App Store service the same way; + // the Google handler additionally resolves its Pub/Sub OIDC verifier. + const WebhookRoutesLayer = Layer.mergeAll( + AppleServerToServerNotificationRouteLayer, + GooglePlayRtdnNotificationRouteLayer, + StripeWebhookNotificationRouteLayer, + WorkosWebhookRouteLayer, + layers.features.routes(graph), + ).pipe( + HttpRouter.provideRequest( + Layer.mergeAll(GooglePubSubPushVerifierLive, SupportServicesLayer, DomainServicesLayer), + ), + ); + + // Public, unauthenticated serving of code-deployed paywall artifacts + // (deploy contract §5). A raw route like the webhooks, so its request-time + // requirement (`PaywallArtifactStore`, part of the infrastructure merged + // into `SupportServicesLayer`) needs `HttpRouter.provideRequest`. + const PaywallServingLayer = PaywallServingRouteLayer.pipe( + HttpRouter.provideRequest(SupportServicesLayer), + ); + + // Public, unauthenticated serving of stored public assets (`GET /files/*`, + // e.g. avatars). Like the paywall serving layer, its request-time requirement + // (`PublicFileStore`, part of the infrastructure merged into + // `SupportServicesLayer`) needs `HttpRouter.provideRequest`. + const PublicFileServingLayer = PublicFileServingRouteLayer.pipe( + HttpRouter.provideRequest(SupportServicesLayer), + ); + + // MCP endpoint (`POST /api/mcp`, stateless streamable HTTP). Its request-scoped + // requirements — `ApiKeyService` (secret-key auth) + `PaywallWorkspaceService` + // + `Db` (key validation) — are satisfied via `provideRequest`. + // `AuthSession` is provided in-handler from the validated secret key. Unlike + // the agent-session route, MCP needs no JWT namespace (it authenticates with v1 + // secret keys), so it registers unconditionally. + const McpRoutesLayer = McpRouteLayer.pipe( + HttpRouter.provideRequest(Layer.mergeAll(DomainServicesLayer, SupportServicesLayer)), + ); + + const McpAuthKitRoutesLayer = McpAuthKitRouteLayer.pipe( + HttpRouter.provideRequest(Layer.mergeAll(DomainServicesLayer, SupportServicesLayer)), + ); + + const RoutesLayer = Layer.mergeAll( + RpcRoutesLayer, + V1ApiRoutes, + WebhookRoutesLayer, + PaywallServingLayer, + PublicFileServingLayer, + McpRoutesLayer, + McpAuthKitRoutesLayer, + HealthCheckRoute, + RuntimeCapabilitiesRoute(layers.features.runtimeCapabilities), + layers.routes ?? Layer.empty, + ).pipe(Layer.provide(CorsLayer)); + + // `toHttpEffect` returns a *builder* effect that yields the per-request + // handler (built once at init); we map over it to wrap that inner handler. + // `HttpMiddleware.tracer` opens the per-request `server` span (reading W3C + // `traceparent` for distributed propagation and setting standard `http.*` / + // `url.*` attributes) and installs it as the parent of every downstream + // `Effect.fn` span; `withRequestId` runs inside it to stamp/echo the + // `x-request-id` correlation id. Both are no-ops under the default no-op + // tracer (tests / dev without the OTLP layer); the real tracer is provided at + // the Worker fetch-graph root. + const httpAppBuilder = RoutesLayer.pipe( + Layer.provide(HttpServer.layerServices), + HttpRouter.toHttpEffect, + ); + return Effect.map(httpAppBuilder, (handler) => HttpMiddleware.tracer(withRequestId(handler))); +}; diff --git a/apps/backend/src/BackendRpcGroups.test.ts b/apps/backend/src/BackendRpcGroups.test.ts new file mode 100644 index 000000000..a2ee4775a --- /dev/null +++ b/apps/backend/src/BackendRpcGroups.test.ts @@ -0,0 +1,16 @@ +import { RpcGroups } from "@voidhash/rpc"; +import { describe, expect, it } from "vitest"; + +import { BackendRpcGroups } from "./BackendRpcGroups.ts"; + +describe("backend RPC composition", () => { + it("uses only the Community product transport before extensions mount", () => { + expect([...BackendRpcGroups.requests.keys()]).toEqual([...RpcGroups.requests.keys()]); + }); + + it("does not mount private operations procedures", () => { + expect( + [...BackendRpcGroups.requests.keys()].filter((tag) => tag.startsWith("Admin")), + ).toEqual([]); + }); +}); diff --git a/apps/backend/src/BackendRpcGroups.ts b/apps/backend/src/BackendRpcGroups.ts new file mode 100644 index 000000000..f1cb78eb0 --- /dev/null +++ b/apps/backend/src/BackendRpcGroups.ts @@ -0,0 +1,4 @@ +import { RpcGroups } from "@voidhash/rpc"; + +/** Community product RPC transport before optional feature and private extensions mount. */ +export const BackendRpcGroups = RpcGroups; diff --git a/apps/backend/src/GooglePubSubPushVerifier.test.ts b/apps/backend/src/GooglePubSubPushVerifier.test.ts new file mode 100644 index 000000000..0f172d777 --- /dev/null +++ b/apps/backend/src/GooglePubSubPushVerifier.test.ts @@ -0,0 +1,103 @@ +import { Effect } from "effect"; +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +import { + GooglePubSubPushVerificationError, + makeGooglePubSubPushVerifier, + type GooglePubSubPushVerifierShape, +} from "./GooglePubSubPushVerifier.ts"; + +const audience = "https://api.example.test/google-pubsub"; +const serviceAccountEmail = "pubsub-push@example-project.iam.gserviceaccount.com"; + +let privateKey: Awaited>["privateKey"]; +let verifier: GooglePubSubPushVerifierShape; + +beforeAll(async () => { + const keyPair = await generateKeyPair("RS256", { extractable: true }); + privateKey = keyPair.privateKey; + const publicJwk = await exportJWK(keyPair.publicKey); + publicJwk.alg = "RS256"; + publicJwk.kid = "test-key"; + publicJwk.use = "sig"; + verifier = makeGooglePubSubPushVerifier({ + audience, + jwks: createLocalJWKSet({ keys: [publicJwk] }), + serviceAccountEmail, + }); +}); + +const token = (claims: Partial & { email?: string; email_verified?: boolean } = {}) => { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ + email: serviceAccountEmail, + email_verified: true, + ...claims, + }) + .setProtectedHeader({ alg: "RS256", kid: "test-key", typ: "JWT" }) + .setIssuer(claims.iss ?? "https://accounts.google.com") + .setAudience(claims.aud ?? audience) + .setSubject(claims.sub ?? "1234567890") + .setIssuedAt(claims.iat ?? now) + .setExpirationTime(claims.exp ?? now + 300) + .sign(privateKey); +}; + +const expectUnauthorized = async (authorizationHeader: string | undefined) => { + const error = await Effect.runPromise(verifier.verify(authorizationHeader).pipe(Effect.flip)); + expect(error).toBeInstanceOf(GooglePubSubPushVerificationError); + expect(error.kind).toBe("unauthorized"); +}; + +describe("GooglePubSubPushVerifier", () => { + it("accepts a Google-signed token bound to the configured audience and service account", async () => { + await expect( + Effect.runPromise(verifier.verify(`Bearer ${await token()}`)), + ).resolves.toBeUndefined(); + }); + + it("rejects a missing or malformed bearer token", async () => { + await expectUnauthorized(undefined); + await expectUnauthorized("Basic credentials"); + await expectUnauthorized("Bearer token with spaces"); + }); + + it("rejects a token for a different audience", async () => { + await expectUnauthorized(`Bearer ${await token({ aud: "https://wrong.example.test" })}`); + }); + + it("rejects a token with a tampered signature", async () => { + const signedToken = await token(); + const [header, payload, signature] = signedToken.split("."); + const tamperedSignature = `${signature?.startsWith("a") ? "b" : "a"}${signature?.slice(1)}`; + const tamperedToken = `${header}.${payload}.${tamperedSignature}`; + await expectUnauthorized(`Bearer ${tamperedToken}`); + }); + + it("rejects a token from a different issuer", async () => { + await expectUnauthorized(`Bearer ${await token({ iss: "https://issuer.example.test" })}`); + }); + + it("rejects a token for a different or unverified service account", async () => { + await expectUnauthorized(`Bearer ${await token({ email: "attacker@example.test" })}`); + await expectUnauthorized(`Bearer ${await token({ email_verified: false })}`); + }); + + it("rejects an expired token", async () => { + const now = Math.floor(Date.now() / 1000); + await expectUnauthorized(`Bearer ${await token({ exp: now - 1, iat: now - 600 })}`); + }); + + it("fails closed when authenticated push settings are absent", async () => { + const unconfigured = makeGooglePubSubPushVerifier({ + audience: "", + jwks: createLocalJWKSet({ keys: [] }), + serviceAccountEmail: "", + }); + const error = await Effect.runPromise( + unconfigured.verify(`Bearer ${await token()}`).pipe(Effect.flip), + ); + expect(error.kind).toBe("misconfigured"); + }); +}); diff --git a/apps/backend/src/GooglePubSubPushVerifier.ts b/apps/backend/src/GooglePubSubPushVerifier.ts new file mode 100644 index 000000000..bd44bfab2 --- /dev/null +++ b/apps/backend/src/GooglePubSubPushVerifier.ts @@ -0,0 +1,106 @@ +import { Context, Data, Effect, Layer, Schema } from "effect"; +import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; + +const GooglePubSubClaimsSchema = Schema.Struct({ + email: Schema.String, + email_verified: Schema.Boolean, +}); + +const GOOGLE_OIDC_ISSUERS = ["accounts.google.com", "https://accounts.google.com"] as const; +const GOOGLE_OIDC_JWKS_URL = new URL("https://www.googleapis.com/oauth2/v3/certs"); + +export class GooglePubSubPushVerificationError extends Data.TaggedError( + "GooglePubSubPushVerificationError", +)<{ + readonly kind: "misconfigured" | "unauthorized"; + readonly message: string; +}> {} + +export interface GooglePubSubPushVerifierShape { + /** Verifies the authenticated Pub/Sub push bearer token and its bound identity. */ + readonly verify: ( + authorizationHeader: string | undefined, + ) => Effect.Effect; +} + +/** Authenticates Google Pub/Sub push requests before their payload is processed. */ +export class GooglePubSubPushVerifier extends Context.Service< + GooglePubSubPushVerifier, + GooglePubSubPushVerifierShape +>()("@voidhash/backend/GooglePubSubPushVerifier") {} + +export interface GooglePubSubPushVerifierOptions { + readonly audience: string; + readonly serviceAccountEmail: string; + readonly jwks: JWTVerifyGetKey; +} + +/** Builds a verifier around an explicit JWK source for production and deterministic tests. */ +export const makeGooglePubSubPushVerifier = ( + options: GooglePubSubPushVerifierOptions, +): GooglePubSubPushVerifierShape => ({ + verify: (authorizationHeader) => + Effect.gen(function* () { + const audience = options.audience.trim(); + const serviceAccountEmail = options.serviceAccountEmail.trim(); + if (!audience || !serviceAccountEmail) { + return yield* new GooglePubSubPushVerificationError({ + kind: "misconfigured", + message: + "Google Pub/Sub push authentication requires an audience and service-account email", + }); + } + + const bearerMatch = authorizationHeader?.match(/^Bearer\s+(\S+)$/i); + if (!bearerMatch) { + return yield* new GooglePubSubPushVerificationError({ + kind: "unauthorized", + message: "Missing or malformed Pub/Sub bearer token", + }); + } + + const verification = yield* Effect.tryPromise({ + try: () => + jwtVerify(bearerMatch[1]!, options.jwks, { + algorithms: ["RS256"], + audience, + issuer: [...GOOGLE_OIDC_ISSUERS], + }), + catch: () => + new GooglePubSubPushVerificationError({ + kind: "unauthorized", + message: "Invalid Pub/Sub identity token", + }), + }); + + const claims = yield* Schema.decodeUnknownEffect(GooglePubSubClaimsSchema)( + verification.payload, + ).pipe( + Effect.mapError( + () => + new GooglePubSubPushVerificationError({ + kind: "unauthorized", + message: "Pub/Sub identity token is missing required claims", + }), + ), + ); + + if (!claims.email_verified || claims.email !== serviceAccountEmail) { + return yield* new GooglePubSubPushVerificationError({ + kind: "unauthorized", + message: "Pub/Sub identity token does not match the configured service account", + }); + } + }), +}); + +const googleOidcJwks = createRemoteJWKSet(GOOGLE_OIDC_JWKS_URL); + +/** Production verifier configured by the authenticated push subscription settings. */ +export const GooglePubSubPushVerifierLive = Layer.sync(GooglePubSubPushVerifier, () => + makeGooglePubSubPushVerifier({ + audience: process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE ?? "", + jwks: googleOidcJwks, + serviceAccountEmail: process.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT_EMAIL ?? "", + }), +); diff --git a/apps/backend/src/McpAuthKit.test.ts b/apps/backend/src/McpAuthKit.test.ts new file mode 100644 index 000000000..a142d1992 --- /dev/null +++ b/apps/backend/src/McpAuthKit.test.ts @@ -0,0 +1,65 @@ +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 new file mode 100644 index 000000000..551669a3b --- /dev/null +++ b/apps/backend/src/McpAuthKit.ts @@ -0,0 +1,123 @@ +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/apps/backend/src/PaywallSnapshotHtmlRenderer.test.ts b/apps/backend/src/PaywallSnapshotHtmlRenderer.test.ts new file mode 100644 index 000000000..d8f27839f --- /dev/null +++ b/apps/backend/src/PaywallSnapshotHtmlRenderer.test.ts @@ -0,0 +1,41 @@ +import { SnapshotHtmlRenderer } from "@voidhash/core/services/paywallReleases/SnapshotHtmlRenderer"; +import { + createInitialPaywallDocumentInput, + PaywallDesignerDocument, +} from "@voidhash/mimic-schema"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { BackendSnapshotHtmlRendererLive } from "./PaywallSnapshotHtmlRenderer.ts"; + +describe("BackendSnapshotHtmlRendererLive", () => { + it("renders a hydrated Mimic snapshot with release metadata", async () => { + const snapshot = PaywallDesignerDocument.decode( + PaywallDesignerDocument.encode(createInitialPaywallDocumentInput()), + )?.[0]; + + const html = await Effect.runPromise( + Effect.gen(function* () { + const renderer = yield* SnapshotHtmlRenderer; + return yield* renderer.render({ + componentTrees: {}, + metadata: { + createdAt: "2026-07-11T00:00:00.000Z", + schemaVersion: 1, + status: "draft", + version: 3, + }, + snapshot, + }); + }).pipe( + Effect.catch((error) => Effect.die(error.cause)), + Effect.provide(BackendSnapshotHtmlRendererLive), + ), + ); + + expect(html).toContain(""); + expect(html).toContain("VOIDHASH_PAYWALL_METADATA"); + expect(html).toContain('"version":3'); + expect(html).toContain("__VOIDHASH_PAYWALL__"); + }); +}); diff --git a/apps/backend/src/PaywallSnapshotHtmlRenderer.ts b/apps/backend/src/PaywallSnapshotHtmlRenderer.ts new file mode 100644 index 000000000..4e986baf9 --- /dev/null +++ b/apps/backend/src/PaywallSnapshotHtmlRenderer.ts @@ -0,0 +1,31 @@ +import { + SnapshotHtmlRenderer, + SnapshotHtmlRenderError, +} from "@voidhash/core/services/paywallReleases/SnapshotHtmlRenderer"; +import type { ComponentArtifacts, SnapshotNode } from "@voidhash/paywall-renderer-preact"; +import { Effect, Layer } from "effect"; + +/** Portable Preact adapter for hydrated visual paywall release documents. */ +export const BackendSnapshotHtmlRendererLive = Layer.succeed(SnapshotHtmlRenderer, { + render: (input) => + Effect.tryPromise({ + try: async () => { + const preact = await import("preact"); + const runtimeGlobals = globalThis as unknown as { React?: typeof preact }; + runtimeGlobals.React ??= preact; + const { renderPaywallToHtml } = await import("@voidhash/paywall-renderer-preact"); + return renderPaywallToHtml(input.snapshot as SnapshotNode, { + componentArtifacts: { + trees: input.componentTrees, + } as ComponentArtifacts, + hydrate: true, + metadata: input.metadata, + }).html; + }, + catch: (cause) => + new SnapshotHtmlRenderError({ + cause: cause instanceof Error ? cause.message : String(cause), + message: "Failed to render the paywall release document", + }), + }), +}); diff --git a/apps/backend/src/RpcMiddlewares.ts b/apps/backend/src/RpcMiddlewares.ts new file mode 100644 index 000000000..9bee24478 --- /dev/null +++ b/apps/backend/src/RpcMiddlewares.ts @@ -0,0 +1,61 @@ +import { Db } from "@voidhash/db"; +import type { AuthTokenVerifier } from "@voidhash/core/services/auth/AuthTokenVerifier"; +import { LocalUserSessionService } from "@voidhash/core/services/auth/LocalUserSessionService"; +import { Workos } from "@voidhash/core/services/auth/Workos"; +import { + AuthMiddleware, + AuthSession, +} from "@voidhash/rpc"; +import * as HttpHeaders from "effect/unstable/http/Headers"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import { Effect, Layer, Option, pipe } from "effect"; + +import { resolveWorkosSession } from "./AuthSessionResolver.ts"; +import { withIdentity } from "./Telemetry.ts"; + +const withHttpRequestHeaders = (headers: HttpHeaders.Headers): Effect.Effect => + Effect.serviceOption(HttpServerRequest.HttpServerRequest).pipe( + Effect.map( + Option.match({ + onNone: () => headers, + onSome: (request) => HttpHeaders.merge(headers, HttpHeaders.fromInput(request.headers)), + }), + ), + ); + +/** Builds the shared WorkOS-backed session resolver used by RPC auth adapters. */ +export const makeRpcSessionResolver = (authTokenVerifier: AuthTokenVerifier["Service"]) => + Effect.gen(function* () { + const localUserSessions = yield* LocalUserSessionService; + const workosAuth = yield* Workos; + const db = yield* Db; + + return (headers: HttpHeaders.Headers) => + withHttpRequestHeaders(headers).pipe( + Effect.flatMap((requestHeaders) => + resolveWorkosSession(requestHeaders, authTokenVerifier).pipe( + Effect.provideService(Db)(db), + Effect.provideService(Workos)(workosAuth), + Effect.provideService(LocalUserSessionService)(localUserSessions), + ), + ), + ); + }); + +/** Builds RPC authentication against an injected token-verification port. */ +export const RpcAuthLive = (authTokenVerifier: AuthTokenVerifier["Service"]) => + Layer.effect( + AuthMiddleware, + Effect.gen(function* () { + const resolveSession = yield* makeRpcSessionResolver(authTokenVerifier); + + return AuthMiddleware.of((effect, { headers }) => + pipe( + resolveSession(headers), + Effect.flatMap((session) => + withIdentity(session, Effect.provideService(effect, AuthSession, session)), + ), + ), + ); + }), + ); diff --git a/apps/backend/src/Telemetry.test.ts b/apps/backend/src/Telemetry.test.ts new file mode 100644 index 000000000..135911758 --- /dev/null +++ b/apps/backend/src/Telemetry.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vite-plus/test"; +import { Context, Effect } from "effect"; +import * as Tracer from "effect/Tracer"; + +import { identityAttributes, withIdentity, type IdentitySource } from "./Telemetry.ts"; + +const userSession: IdentitySource = { + method: "user", + user: { id: "user_1", workosUserId: "wos_user_1", role: "admin" }, + person: null, + organizations: [ + { id: "org_1", slug: "acme", workosOrganizationId: "wos_org_1" }, + { id: "org_2", slug: "beta", workosOrganizationId: null }, + ], + projects: [{ id: "proj_1", slug: "web", organizationId: "org_1" }], +}; + +const secretKeySession: IdentitySource = { + method: "secret-key", + user: null, + person: null, + organizations: [], + projects: [{ id: "proj_9", slug: "sdk", organizationId: "org_9" }], +}; + +const publishableKeySession: IdentitySource = { + method: "publishable-key", + user: null, + person: { distinctId: "distinct_42" }, + organizations: [], + projects: [{ id: "proj_9", slug: "sdk", organizationId: "org_9" }], +}; + +const attrMap = (session: IdentitySource): Map => + new Map(identityAttributes(session).map(([k, v]) => [k, v])); + +describe("identityAttributes", () => { + it("stamps the acting user, first org/project, and counts for a user session", () => { + const attrs = attrMap(userSession); + expect(attrs.get("voidhash.auth.method")).toBe("user"); + expect(attrs.get("voidhash.user.id")).toBe("user_1"); + expect(attrs.get("voidhash.user.workos_id")).toBe("wos_user_1"); + expect(attrs.get("voidhash.user.role")).toBe("admin"); + // First org is stamped; count signals there are more. + expect(attrs.get("voidhash.organization.id")).toBe("org_1"); + expect(attrs.get("voidhash.organization.slug")).toBe("acme"); + expect(attrs.get("voidhash.organization.workos_id")).toBe("wos_org_1"); + expect(attrs.get("voidhash.organization.count")).toBe("2"); + expect(attrs.get("voidhash.project.id")).toBe("proj_1"); + expect(attrs.get("voidhash.project.organization_id")).toBe("org_1"); + expect(attrs.get("voidhash.project.count")).toBe("1"); + // No person on a user session. + expect(attrs.has("voidhash.person.distinct_id")).toBe(false); + }); + + it("stamps the scoped project (and its org) for a secret-key session, no user", () => { + const attrs = attrMap(secretKeySession); + expect(attrs.get("voidhash.auth.method")).toBe("secret-key"); + expect(attrs.has("voidhash.user.id")).toBe(false); + expect(attrs.has("voidhash.person.distinct_id")).toBe(false); + expect(attrs.get("voidhash.project.id")).toBe("proj_9"); + expect(attrs.get("voidhash.project.organization_id")).toBe("org_9"); + expect(attrs.get("voidhash.organization.count")).toBe("0"); + }); + + it("stamps the person distinct id for a publishable-key session", () => { + const attrs = attrMap(publishableKeySession); + expect(attrs.get("voidhash.auth.method")).toBe("publishable-key"); + expect(attrs.get("voidhash.person.distinct_id")).toBe("distinct_42"); + expect(attrs.get("voidhash.project.id")).toBe("proj_9"); + }); + + it("omits nullable fields rather than emitting the string null", () => { + const attrs = attrMap({ + method: "user", + user: { id: "user_2", workosUserId: null, role: null }, + person: null, + organizations: [{ id: "org_3", slug: "solo", workosOrganizationId: null }], + projects: [], + }); + expect(attrs.has("voidhash.user.workos_id")).toBe(false); + expect(attrs.has("voidhash.user.role")).toBe(false); + expect(attrs.has("voidhash.organization.workos_id")).toBe(false); + expect(attrs.has("voidhash.project.id")).toBe(false); + expect(attrs.get("voidhash.project.count")).toBe("0"); + }); +}); + +/** Minimal in-memory tracer that records every attribute stamped on each span. */ +const makeRecordingTracer = () => { + const spans = new Map>(); + let counter = 0; + const tracer = Tracer.make({ + span(options) { + const attributes = new Map(); + const id = `span-${counter++}`; + spans.set(options.name, attributes); + const span: Tracer.Span = { + _tag: "Span", + name: options.name, + spanId: id, + traceId: "trace", + parent: options.parent, + annotations: Context.empty(), + status: { _tag: "Started", startTime: options.startTime }, + attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end() {}, + attribute(key, value) { + attributes.set(key, value); + }, + event() {}, + addLinks() {}, + }; + return span; + }, + }); + return { tracer, spans }; +}; + +describe("withIdentity", () => { + it("annotates identity attributes onto the current span", async () => { + const { tracer, spans } = makeRecordingTracer(); + + await Effect.runPromise( + withIdentity(userSession, Effect.void).pipe( + Effect.withSpan("rpc.test"), + Effect.withTracer(tracer), + ), + ); + + const attrs = spans.get("rpc.test"); + expect(attrs).toBeDefined(); + expect(attrs?.get("voidhash.user.id")).toBe("user_1"); + expect(attrs?.get("voidhash.organization.id")).toBe("org_1"); + expect(attrs?.get("voidhash.project.id")).toBe("proj_1"); + expect(attrs?.get("voidhash.auth.method")).toBe("user"); + }); +}); diff --git a/apps/backend/src/Telemetry.ts b/apps/backend/src/Telemetry.ts new file mode 100644 index 000000000..005dfe2b1 --- /dev/null +++ b/apps/backend/src/Telemetry.ts @@ -0,0 +1,147 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Headers from "effect/unstable/http/Headers"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** + * Request-context OpenTelemetry helpers for the backend HTTP/RPC handler. + * + * The tracer Layer itself is provided at the Worker fetch-graph root + * (`stacks/backend/workers/BackendWorker.ts`); these helpers run inside the + * request span created by `HttpMiddleware.tracer` and stamp the cross-cutting + * attributes that make a request traceable end-to-end. With Effect's default + * no-op tracer (dev / tests) every call here is a cheap no-op. + * + * See `docs/attribute-registry.md` for the canonical attribute set. + */ + +const REQUEST_ID_HEADER = "x-request-id"; + +/** Stable, greppable prefix so request ids are obvious in logs and Axiom. */ +const generateRequestId = (): string => `req_${crypto.randomUUID()}`; + +/** + * HTTP middleware that establishes the per-request correlation id. Reads an + * inbound `x-request-id` (propagated by callers such as `apps/www`) or mints a + * fresh one, stamps it on the current request span as `voidhash.request.id`, + * annotates every log emitted while handling the request, and echoes it back on + * the response so clients can quote it in bug reports. + * + * Apply it *inside* `HttpMiddleware.tracer` so the request span already exists. + */ +export const withRequestId = ( + httpApp: Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + HttpServerRequest.HttpServerRequest | R + >, +): Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + HttpServerRequest.HttpServerRequest | R +> => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const requestId = Option.getOrElse( + Headers.get(request.headers, REQUEST_ID_HEADER), + generateRequestId, + ); + yield* Effect.annotateCurrentSpan("voidhash.request.id", requestId); + const response = yield* Effect.annotateLogs(httpApp, "voidhash.request.id", requestId); + return HttpServerResponse.setHeader(response, REQUEST_ID_HEADER, requestId); + }); + +/** + * Structural view of an authenticated session, satisfied by both the RPC + * `UserSession` (`@voidhash/rpc`) and the HTTP API sessions + * (`@voidhash/api-contracts`). Declared locally so this module couples to + * neither package's exact schema. + */ +export interface IdentitySource { + readonly method: string; + readonly user: { + readonly id: string; + readonly workosUserId?: string | null; + readonly role?: string | null; + } | null; + readonly person: { readonly distinctId: string } | null; + readonly organizations: ReadonlyArray<{ + readonly id: string; + readonly slug: string; + readonly workosOrganizationId?: string | null; + }>; + readonly projects: ReadonlyArray<{ + readonly id: string; + readonly slug: string; + readonly organizationId: string; + }>; +} + +/** + * The canonical identity attributes for a session — the per-request set defined + * in `docs/attribute-registry.md` §2b. Stamps the authenticated principal + * (user / person + auth method), the active/first organization and project, and + * `*.count` cardinality breadcrumbs so a multi-org/multi-project session is + * flagged without dumping every id. Nullable fields are omitted (never emitted + * as the string `"null"`). A request's *target* entity — when it differs from + * the principal's first org/project — is stamped on the domain service span + * instead. + */ +export const identityAttributes = ( + session: IdentitySource, +): ReadonlyArray => { + const attrs: Array = [ + ["voidhash.auth.method", session.method], + ["voidhash.organization.count", String(session.organizations.length)], + ["voidhash.project.count", String(session.projects.length)], + ]; + + if (session.user) { + attrs.push(["voidhash.user.id", session.user.id]); + if (session.user.workosUserId) + attrs.push(["voidhash.user.workos_id", session.user.workosUserId]); + if (session.user.role) attrs.push(["voidhash.user.role", session.user.role]); + } + if (session.person) attrs.push(["voidhash.person.distinct_id", session.person.distinctId]); + + const org = session.organizations[0]; + if (org) { + attrs.push(["voidhash.organization.id", org.id], ["voidhash.organization.slug", org.slug]); + if (org.workosOrganizationId) { + attrs.push(["voidhash.organization.workos_id", org.workosOrganizationId]); + } + } + + const project = session.projects[0]; + if (project) { + attrs.push( + ["voidhash.project.id", project.id], + ["voidhash.project.slug", project.slug], + ["voidhash.project.organization_id", project.organizationId], + ); + } + + return attrs; +}; + +/** + * Stamp the {@link identityAttributes} for `session` onto the current span and + * onto every log emitted while running `effect`. Call this in the auth + * middleware around the handler effect (right where `AuthSession` is provided) + * so every span and log under an authenticated request carries the principal. + */ +export const withIdentity = ( + session: IdentitySource, + effect: Effect.Effect, +): Effect.Effect => { + const attrs = identityAttributes(session); + const annotateSpan = Effect.forEach( + attrs, + ([key, value]) => Effect.annotateCurrentSpan(key, value), + { + discard: true, + }, + ); + return annotateSpan.pipe(Effect.andThen(Effect.annotateLogs(effect, Object.fromEntries(attrs)))); +}; diff --git a/apps/backend/src/ai/AgentSessionIndexAdapter.test.ts b/apps/backend/src/ai/AgentSessionIndexAdapter.test.ts new file mode 100644 index 000000000..ddce09614 --- /dev/null +++ b/apps/backend/src/ai/AgentSessionIndexAdapter.test.ts @@ -0,0 +1,54 @@ +import { makeLayerEffectRunner } from "@voidhash/agent"; +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { AgentSessionIndexService } from "@voidhash/core/services"; +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { makeAgentSessionIndex } from "./AgentSessionIndexAdapter.ts"; + +describe("makeAgentSessionIndex", () => { + it("uses a fresh scoped index service after the previous operation is finalized", async () => { + let nextId = 0; + const used: number[] = []; + const released: number[] = []; + const IndexLive = Layer.effect( + AgentSessionIndexService, + Effect.acquireRelease( + Effect.sync(() => { + const id = ++nextId; + return { + id, + service: { + touch: () => + Effect.sync(() => { + if (released.includes(id)) throw new Error(`index lease ${id} was finalized`); + used.push(id); + return undefined as never; + }), + } as unknown as AgentSessionIndexService["Service"], + }; + }), + ({ id }) => Effect.sync(() => released.push(id)), + ).pipe(Effect.map(({ service }) => service)), + ); + const runEffect = makeLayerEffectRunner(() => + Layer.merge(IndexLive, Layer.succeed(AuthSession, {} as AuthSession["Service"])), + ); + const index = makeAgentSessionIndex(runEffect); + const input = { + sessionId: "session-1", + owner: { + organizationId: "organization-1", + projectId: "project-1", + userId: "user-1", + }, + connectionData: undefined, + }; + + await index.touch(input); + await index.touch(input); + + expect(used).toEqual([1, 2]); + expect(released).toEqual([1, 2]); + }); +}); diff --git a/apps/backend/src/ai/AgentSessionIndexAdapter.ts b/apps/backend/src/ai/AgentSessionIndexAdapter.ts new file mode 100644 index 000000000..5a5b58492 --- /dev/null +++ b/apps/backend/src/ai/AgentSessionIndexAdapter.ts @@ -0,0 +1,26 @@ +import type { AgentSessionIndex, EffectRunner } from "@voidhash/agent"; +import { AgentSessionIndexService } from "@voidhash/core/services"; +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { Effect } from "effect"; + +/** Adapts the database index service to the portable session-core hook. */ +export const makeAgentSessionIndex = ( + runEffect: EffectRunner, +): AgentSessionIndex => ({ + touch: (input) => + runEffect( + input.connectionData, + Effect.gen(function* () { + const index = yield* AgentSessionIndexService; + yield* index.touch({ + id: input.sessionId, + organizationId: input.owner.organizationId, + projectId: input.owner.projectId, + userId: input.owner.userId, + surface: input.metadata?.surface, + paywallId: input.metadata?.paywallId, + title: input.title, + }); + }), + ), +}); diff --git a/apps/backend/src/ai/DesignerContext.ts b/apps/backend/src/ai/DesignerContext.ts new file mode 100644 index 000000000..71a8a5b1e --- /dev/null +++ b/apps/backend/src/ai/DesignerContext.ts @@ -0,0 +1,124 @@ +import { PaywallService, PaywallWorkspaceService } from "@voidhash/core/services"; +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { fileNameFromDocRelative, readComponentDefinitions } from "@voidhash/paywall-workspace"; +import { Effect } from "effect"; + +/** Server-resolved facts about the user's current designer workspace. */ +export interface DesignerContext { + readonly paywalls: ReadonlyArray<{ + readonly paywallId: string; + readonly slug: string; + readonly name: string; + readonly componentFileNames: ReadonlyArray; + }>; + readonly openPaywall?: { + readonly paywallId: string; + readonly slug: string; + readonly name: string; + }; + readonly selectedNodeIds: ReadonlyArray; +} + +/** Identifiers used to resolve a fresh designer context before each model turn. */ +export interface DesignerContextInput { + readonly projectId: string; + readonly paywallId?: string; + readonly selectedNodeIds: ReadonlyArray; +} + +const componentFileNamesFromDocument = (root: unknown): ReadonlyArray => { + const snapshot = (root != null ? [root] : []) as unknown as Parameters< + typeof readComponentDefinitions + >[0]; + return readComponentDefinitions(snapshot).map((definition) => + fileNameFromDocRelative(definition.path), + ); +}; + +/** + * Resolves the live project/paywall/selection context. Resolution is + * best-effort so an unreadable sibling document never prevents an agent turn. + */ +export const buildDesignerContext = ( + input: DesignerContextInput, +): Effect.Effect< + DesignerContext | undefined, + never, + PaywallService | PaywallWorkspaceService | AuthSession +> => + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const workspace = yield* PaywallWorkspaceService; + const rows = yield* paywallService.getPaywalls(input.projectId); + const paywalls = yield* Effect.forEach( + rows, + (row) => + workspace.readDocument(input.projectId, row.slug).pipe( + Effect.map((resolved) => ({ + paywallId: row.id, + slug: row.slug, + name: row.name, + componentFileNames: componentFileNamesFromDocument(resolved.root), + })), + Effect.catchCause(() => + Effect.succeed({ + paywallId: row.id, + slug: row.slug, + name: row.name, + componentFileNames: [], + }), + ), + ), + { concurrency: 8 }, + ); + const openRow = rows.find((row) => row.id === input.paywallId); + return { + paywalls, + ...(openRow === undefined + ? {} + : { + openPaywall: { + paywallId: openRow.id, + slug: openRow.slug, + name: openRow.name, + }, + }), + selectedNodeIds: input.selectedNodeIds, + }; + }).pipe(Effect.catchCause(() => Effect.succeed(undefined))); + +const formatPaywallEntry = (paywall: DesignerContext["paywalls"][number]): string => { + const components = + paywall.componentFileNames.length > 0 + ? `components: ${paywall.componentFileNames.map((fileName) => `components/${fileName}`).join(", ")}` + : "no code components"; + return `- ${paywall.paywallId} ("${paywall.name}", slug "${paywall.slug}"): ${components}`; +}; + +/** Renders the dynamic designer facts appended to the agent system prompt. */ +export const renderDesignerContext = (context: DesignerContext): string => { + const sections: string[] = []; + if (context.paywalls.length > 0) { + sections.push( + `Paywalls in this project (stable id, display name, slug, code components):\n${context.paywalls.map(formatPaywallEntry).join("\n")}`, + ); + } + if (context.openPaywall !== undefined) { + const { paywallId, slug, name } = context.openPaywall; + const lines = [ + `The user currently has the "${name}" paywall (id "${paywallId}", slug "${slug}") open in the designer. Unqualified references like "this paywall", "the current screen", or "here" mean this one — open it with \`begin_paywall_edit({ paywallId: "${paywallId}" })\`, then pass the returned \`editSessionId\` to every scoped tool.`, + ]; + if (context.selectedNodeIds.length > 0) { + const plural = context.selectedNodeIds.length === 1 ? "node" : "nodes"; + lines.push( + `The user currently has ${plural} with id ${context.selectedNodeIds.join(", ")} selected in the open paywall — these are document node ids you can target directly in \`edit_paywall\` ops (no need to re-locate them).`, + ); + } + sections.push(lines.join("\n")); + } else { + sections.push( + "The user does not have a specific paywall open in the designer right now, so treat requests as project-wide unless they name a paywall.", + ); + } + return sections.length === 0 ? "" : `\n\nCurrent context:\n${sections.join("\n\n")}`; +}; diff --git a/apps/backend/src/ai/WorkspaceAgentModels.ts b/apps/backend/src/ai/WorkspaceAgentModels.ts new file mode 100644 index 000000000..774342572 --- /dev/null +++ b/apps/backend/src/ai/WorkspaceAgentModels.ts @@ -0,0 +1,19 @@ +import { getCatalogModel, type Model } from "@voidhash/agent"; + +const requiredModel = (provider: string, modelId: string): Model => { + const model = getCatalogModel(provider, modelId); + if (model === undefined) throw new Error(`Missing workspace model: ${provider}/${modelId}`); + return model; +}; + +/** Default production model for text-only workspace turns. */ +export const workspaceTextModel = requiredModel( + "cloudflare-workers-ai", + "@cf/moonshotai/kimi-k2.7-code", +); + +/** Production vision model selected only when the current user turn has images. */ +export const workspaceVisionModel = requiredModel( + "cloudflare-workers-ai", + "@cf/meta/llama-4-scout-17b-16e-instruct", +); diff --git a/apps/backend/src/ai/WorkspaceAgentServices.ts b/apps/backend/src/ai/WorkspaceAgentServices.ts new file mode 100644 index 000000000..a35ea5110 --- /dev/null +++ b/apps/backend/src/ai/WorkspaceAgentServices.ts @@ -0,0 +1,23 @@ +import { + ComponentManifestCacheService, + PaywallDeployService, + PaywallEditSessionService, + PaywallService, + PaywallWorkspaceService, +} from "@voidhash/core/services"; +import { Layer } from "effect"; + +const PaywallServiceLive = PaywallService.layer; +const ComponentManifestCacheServiceLive = ComponentManifestCacheService.layer; +const PaywallWorkspaceServiceLive = PaywallWorkspaceService.layer.pipe( + Layer.provide(PaywallServiceLive), +); + +/** Builds only the domain services required by durable workspace agents. */ +export const WorkspaceAgentServicesLive = Layer.mergeAll( + PaywallServiceLive, + ComponentManifestCacheServiceLive, + PaywallDeployService.layer, + PaywallWorkspaceServiceLive, + PaywallEditSessionService.layer.pipe(Layer.provide(PaywallWorkspaceServiceLive)), +); diff --git a/apps/backend/src/ai/WorkspaceAgentSessionFactory.test.ts b/apps/backend/src/ai/WorkspaceAgentSessionFactory.test.ts new file mode 100644 index 000000000..3bfae06ad --- /dev/null +++ b/apps/backend/src/ai/WorkspaceAgentSessionFactory.test.ts @@ -0,0 +1,98 @@ +import { makeLayerEffectRunner } from "@voidhash/agent"; +import { Context, Layer } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import type { WorkspaceAgentDeps } from "./WorkspaceAgentSessionFactory.ts"; +import { makeWorkspaceAgentSessionFactory } from "./WorkspaceAgentSessionFactory.ts"; +import { workspaceTextModel, workspaceVisionModel } from "./WorkspaceAgentModels.ts"; + +describe("makeWorkspaceAgentSessionFactory", () => { + it("uses progressive skill disclosure and switches to vision only for image turns", async () => { + const factory = makeWorkspaceAgentSessionFactory({ + defaultModel: workspaceTextModel, + visionModel: workspaceVisionModel, + runEffect: makeLayerEffectRunner(() => + Layer.succeedContext(Context.empty() as Context.Context), + ), + }); + const dynamicContext = { current: { selectedNodeIds: [] } }; + const owner = { + organizationId: "organization-1", + projectId: "project-1", + userId: "user-1", + }; + const agent = await factory.create({ + sessionId: "session-1", + owner, + connectionData: undefined, + messages: [], + dynamicContext, + }); + + expect(agent.state.systemPrompt).toContain("paywall-authoring"); + expect(agent.state.systemPrompt).toContain("code-component-authoring"); + expect(agent.state.systemPrompt).not.toContain("Document model and authorable tree"); + expect(agent.state.tools.map((tool) => tool.name)).toContain("read_skill"); + expect(agent.state.tools.map((tool) => tool.name)).toContain("edit_paywall"); + const editPaywall = agent.state.tools.find((tool) => tool.name === "edit_paywall"); + expect(editPaywall?.parameters.properties).toHaveProperty("editSessionId"); + + await factory.preparePrompt?.({ + agent, + sessionId: "session-1", + owner, + connectionData: undefined, + dynamicContext, + message: { role: "user", content: "text", timestamp: 1 }, + }); + expect(agent.state.model.id).toBe(workspaceTextModel.id); + + await factory.preparePrompt?.({ + agent, + sessionId: "session-1", + owner, + connectionData: undefined, + dynamicContext, + message: { + role: "user", + content: [{ type: "image", data: "image", mimeType: "image/png" }], + timestamp: 2, + }, + }); + expect(agent.state.model.id).toBe(workspaceVisionModel.id); + + const selectedModel = { ...workspaceTextModel, id: "selected-model", name: "Selected" }; + agent.state.model = selectedModel; + await factory.preparePrompt?.({ + agent, + sessionId: "session-1", + owner, + connectionData: undefined, + dynamicContext, + message: { role: "user", content: "text", timestamp: 3 }, + }); + expect(agent.state.model.id).toBe(selectedModel.id); + + await factory.preparePrompt?.({ + agent, + sessionId: "session-1", + owner, + connectionData: undefined, + dynamicContext, + message: { + role: "user", + content: [{ type: "image", data: "image", mimeType: "image/png" }], + timestamp: 4, + }, + }); + await factory.preparePrompt?.({ + agent, + sessionId: "session-1", + owner, + connectionData: undefined, + dynamicContext, + message: { role: "user", content: "text", timestamp: 5 }, + }); + expect(agent.state.model.id).toBe(selectedModel.id); + }); +}); diff --git a/apps/backend/src/ai/WorkspaceAgentSessionFactory.ts b/apps/backend/src/ai/WorkspaceAgentSessionFactory.ts new file mode 100644 index 000000000..164a2eb39 --- /dev/null +++ b/apps/backend/src/ai/WorkspaceAgentSessionFactory.ts @@ -0,0 +1,153 @@ +import { + Agent, + bindEffectRunner, + effectAgentToolErrorOverride, + makeReadSkillTool, + renderSkillDisclosure, + type AgentMessage, + type AgentSessionFactory, + type AgentSessionFactoryInput, + type EffectRunner, + type Model, + type StreamFn, +} from "@voidhash/agent"; +import { PaywallService } from "@voidhash/core/services"; + +import { buildDesignerContext } from "./DesignerContext.ts"; +import { registeredSkillSource } from "./skills/registry.ts"; +import { designerAgentSystemPrompt } from "./surfaces.ts"; +import { AgentEditSessionTracker, makeWorkspaceAgentTools } from "./WorkspaceAgentTools.ts"; +import type { WorkspaceToolDeps } from "./workspace-tools.ts"; + +/** Services resolved by the Pi agent's in-process tools. */ +export type WorkspaceAgentDeps = WorkspaceToolDeps | PaywallService; + +/** Model and runtime configuration for the shared workspace session factory. */ +export interface WorkspaceAgentSessionFactoryOptions { + readonly defaultModel: Model; + readonly visionModel: Model; + readonly streamFn?: StreamFn; + readonly getApiKey?: (provider: string) => Promise | string | undefined; + readonly runEffect: EffectRunner; + readonly resolveModel?: ( + provider: string, + modelId: string, + connectionData: ConnectionData, + ) => Model | undefined | Promise | undefined>; +} + +const messageHasImage = (message: AgentMessage): boolean => + message.role === "user" && + Array.isArray(message.content) && + message.content.some((content) => content.type === "image"); + +const latestUserHasImage = (messages: ReadonlyArray): boolean => { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role === "user") return messageHasImage(message); + } + return false; +}; + +const skillPrompt = (): string => { + const disclosure = renderSkillDisclosure(registeredSkillSource()); + return disclosure.length === 0 + ? "" + : `\n\nCall \`read_skill\` before work covered by a listed skill.\n${disclosure}`; +}; + +const contextSystemPrompt = async ( + input: Pick< + AgentSessionFactoryInput, + "owner" | "connectionData" | "dynamicContext" + >, + runEffect: WorkspaceAgentSessionFactoryOptions["runEffect"], +): Promise => { + const resolved = await runEffect( + input.connectionData, + buildDesignerContext({ + projectId: input.owner.projectId, + paywallId: input.dynamicContext.current.paywallId, + selectedNodeIds: input.dynamicContext.current.selectedNodeIds, + }), + ); + return `${designerAgentSystemPrompt(resolved)}${skillPrompt()}`; +}; + +/** + * Creates Pi agents that run canonical workspace tools through the host Effect + * runner and refresh designer facts before every provider turn. + */ +export const makeWorkspaceAgentSessionFactory = ( + options: WorkspaceAgentSessionFactoryOptions, +): AgentSessionFactory => { + const preferredTextModels = new WeakMap>(); + const isVisionModel = (model: Model): boolean => + model.provider === options.visionModel.provider && model.id === options.visionModel.id; + const preferredTextModel = (agent: Agent): Model => { + if (!isVisionModel(agent.state.model)) { + preferredTextModels.set(agent, agent.state.model); + } + return preferredTextModels.get(agent) ?? options.defaultModel; + }; + + return { + create: async (input) => { + const editSessions = new AgentEditSessionTracker(); + editSessions.rehydrate(input.messages); + const runEffect = bindEffectRunner(options.runEffect, input.connectionData); + const tools = [ + ...makeWorkspaceAgentTools( + { projectId: input.owner.projectId, agentSessionId: input.sessionId }, + runEffect, + editSessions, + ), + makeReadSkillTool(registeredSkillSource()), + ]; + let agent: Agent; + agent = new Agent({ + initialState: { + model: options.defaultModel, + messages: [...input.messages], + systemPrompt: await contextSystemPrompt(input, options.runEffect), + tools, + }, + ...(options.streamFn === undefined ? {} : { streamFn: options.streamFn }), + ...(options.getApiKey === undefined ? {} : { getApiKey: options.getApiKey }), + sessionId: input.sessionId, + steeringMode: "all", + followUpMode: "all", + toolExecution: "sequential", + afterToolCall: async ({ result }) => effectAgentToolErrorOverride(result), + prepareNextTurnWithContext: async ({ context: piContext }) => { + const systemPrompt = await contextSystemPrompt(input, options.runEffect); + return { + context: { ...piContext, systemPrompt }, + model: latestUserHasImage(piContext.messages) + ? options.visionModel + : preferredTextModel(agent), + }; + }, + }); + preferredTextModels.set(agent, options.defaultModel); + return agent; + }, + preparePrompt: async ({ agent, message, owner, connectionData, dynamicContext }) => { + agent.state.systemPrompt = await contextSystemPrompt( + { owner, connectionData, dynamicContext }, + options.runEffect, + ); + const textModel = preferredTextModel(agent); + agent.state.model = messageHasImage(message) ? options.visionModel : textModel; + }, + resolveModel: (provider, modelId, connectionData) => { + if (provider === options.defaultModel.provider && modelId === options.defaultModel.id) { + return options.defaultModel; + } + if (provider === options.visionModel.provider && modelId === options.visionModel.id) { + return options.visionModel; + } + return options.resolveModel?.(provider, modelId, connectionData); + }, + }; +}; diff --git a/apps/backend/src/ai/WorkspaceAgentTools.test.ts b/apps/backend/src/ai/WorkspaceAgentTools.test.ts new file mode 100644 index 000000000..9828fa518 --- /dev/null +++ b/apps/backend/src/ai/WorkspaceAgentTools.test.ts @@ -0,0 +1,111 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { AgentEditSessionTracker } from "./WorkspaceAgentTools.ts"; + +describe("AgentEditSessionTracker", () => { + it("accepts scoped calls only after begin_paywall_edit opens the session", async () => { + const tracker = new AgentEditSessionTracker(); + tracker.observe( + "begin_paywall_edit", + { paywallId: "pw_1" }, + { + output: JSON.stringify({ editSessionId: "edit-1", paywallId: "pw_1" }), + isError: false, + }, + ); + + await expect( + Effect.runPromise(tracker.prepare("edit_paywall", { editSessionId: "edit-1", edits: [] })), + ).resolves.toMatchObject({ editSessionId: "edit-1" }); + }); + + it("rejects model-supplied edit sessions not owned by the durable session", async () => { + const tracker = new AgentEditSessionTracker(); + await expect( + Effect.runPromise( + tracker.prepare("edit_paywall", { editSessionId: "edit-attacker", edits: [] }), + ), + ).rejects.toThrow("not owned"); + }); + + it("clears the capability only after a successful finish or revert", async () => { + const tracker = new AgentEditSessionTracker(); + tracker.observe( + "begin_paywall_edit", + { paywallId: "pw_1" }, + { + output: JSON.stringify({ editSessionId: "edit-1", paywallId: "pw_1" }), + isError: false, + }, + ); + + tracker.observe( + "finish_paywall_edit", + { editSessionId: "edit-1" }, + { output: "no", isError: true }, + ); + expect(tracker.get("pw_1")).toBe("edit-1"); + tracker.observe( + "revert_paywall_edit", + { editSessionId: "edit-1" }, + { output: "ok", isError: false }, + ); + expect(tracker.get("pw_1")).toBeUndefined(); + }); + + it("rehydrates an unfinished capability from persisted Pi tool results", async () => { + const tracker = new AgentEditSessionTracker(); + tracker.rehydrate([ + { + role: "toolResult", + toolCallId: "call-1", + toolName: "edit_paywall", + content: [{ type: "text", text: "Updated" }], + details: { + toolName: "edit_paywall", + output: "Updated", + editSessionId: "edit-1", + paywallId: "pw_1", + }, + isError: true, + timestamp: 1, + }, + ]); + await expect( + Effect.runPromise(tracker.prepare("edit_paywall", { editSessionId: "edit-1", edits: [] })), + ).resolves.toMatchObject({ editSessionId: "edit-1" }); + + tracker.rehydrate([ + { + role: "toolResult", + toolCallId: "call-1", + toolName: "edit_paywall", + content: [{ type: "text", text: "Updated" }], + details: { + toolName: "edit_paywall", + output: "Updated", + editSessionId: "edit-1", + paywallId: "pw_1", + }, + isError: false, + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "call-2", + toolName: "finish_paywall_edit", + content: [{ type: "text", text: "Finished" }], + details: { + toolName: "finish_paywall_edit", + output: "Finished", + editSessionId: "edit-1", + paywallId: "pw_1", + }, + isError: false, + timestamp: 2, + }, + ]); + expect(tracker.get("pw_1")).toBeUndefined(); + }); +}); diff --git a/apps/backend/src/ai/WorkspaceAgentTools.ts b/apps/backend/src/ai/WorkspaceAgentTools.ts new file mode 100644 index 000000000..87935cd3f --- /dev/null +++ b/apps/backend/src/ai/WorkspaceAgentTools.ts @@ -0,0 +1,202 @@ +import { + makeEffectAgentToolWithRunner, + type BoundEffectRunner, + type AgentTool, + type AgentMessage, + type TSchema, +} from "@voidhash/agent"; +import { Effect } from "effect"; + +import { MCP_TOOLS } from "../mcp/tool-manifest.ts"; +import type { + WorkspaceToolDeps, + WorkspaceToolResult, + WorkspaceToolScope, +} from "./workspace-tools.ts"; + +const EDIT_SESSION_TOOLS = new Set([ + "get_paywall", + "get_components", + "read_component", + "edit_paywall", + "duplicate_subtree", + "write_component", + "rename_component", + "delete_component", + "get_paywall_preview", + "finish_paywall_edit", + "revert_paywall_edit", +]); + +const inputRecord = (input: unknown): Record | undefined => + input !== null && typeof input === "object" && !Array.isArray(input) + ? (input as Record) + : undefined; + +const decodedEditSession = ( + result: WorkspaceToolResult, +): { readonly editSessionId: string; readonly paywallId: string } | undefined => { + if (result.isError) return undefined; + try { + const value = JSON.parse(result.output) as { editSessionId?: unknown; paywallId?: unknown }; + return typeof value.editSessionId === "string" && typeof value.paywallId === "string" + ? { editSessionId: value.editSessionId, paywallId: value.paywallId } + : undefined; + } catch { + return undefined; + } +}; + +/** Tracks the server-managed edit capability used by an internal agent session. */ +export class AgentEditSessionTracker { + readonly #activeByPaywallId = new Map(); + + /** Returns the active edit-session id for a paywall, when one has been opened. */ + readonly get = (paywallId: string): string | undefined => this.#activeByPaywallId.get(paywallId); + + /** Returns the paywall owned by an active edit-session handle. */ + readonly paywallIdFor = (editSessionId: string): string | undefined => + [...this.#activeByPaywallId].find(([, activeId]) => activeId === editSessionId)?.[0]; + + /** Rebuilds active capabilities from persisted Pi tool-result messages. */ + readonly rehydrate = (messages: ReadonlyArray): void => { + this.#activeByPaywallId.clear(); + for (const message of messages) { + if (message.role !== "toolResult") continue; + const details = inputRecord(message.details); + const toolName = typeof details?.toolName === "string" ? details.toolName : message.toolName; + const decoded = + typeof details?.output === "string" + ? decodedEditSession({ output: details.output, isError: false }) + : undefined; + const editSessionId = + typeof details?.editSessionId === "string" ? details.editSessionId : decoded?.editSessionId; + const paywallId = + typeof details?.paywallId === "string" ? details.paywallId : decoded?.paywallId; + if (editSessionId !== undefined && paywallId !== undefined) { + this.#activeByPaywallId.set(paywallId, editSessionId); + } + if (message.isError) continue; + if (toolName === "finish_paywall_edit" && editSessionId !== undefined) { + for (const [activePaywallId, activeId] of this.#activeByPaywallId) { + if (activeId === editSessionId) this.#activeByPaywallId.delete(activePaywallId); + } + } else if (toolName === "revert_paywall_edit" && editSessionId !== undefined) { + for (const [activePaywallId, activeId] of this.#activeByPaywallId) { + if (activeId === editSessionId) this.#activeByPaywallId.delete(activePaywallId); + } + } + } + }; + + /** + * Accepts only edit-session handles opened by this durable agent session. + */ + readonly prepare = (toolName: string, input: unknown): Effect.Effect => { + const record = inputRecord(input); + if (!EDIT_SESSION_TOOLS.has(toolName) || record === undefined) { + return Effect.succeed(input); + } + const editSessionId = record.editSessionId; + if (typeof editSessionId !== "string" || editSessionId.length === 0) { + return Effect.fail(new Error(`Call begin_paywall_edit before ${toolName}.`)); + } + if (![...this.#activeByPaywallId.values()].includes(editSessionId)) { + return Effect.fail( + new Error(`Edit session "${editSessionId}" is not owned by this agent session.`), + ); + } + return Effect.succeed(input); + }; + + /** Updates tracked lifecycle state after a workspace tool completes. */ + readonly observe = (toolName: string, input: unknown, result: WorkspaceToolResult): void => { + if (result.isError) return; + if (toolName === "begin_paywall_edit") { + const opened = decodedEditSession(result); + if (opened !== undefined) { + this.#activeByPaywallId.set(opened.paywallId, opened.editSessionId); + } + return; + } + const record = inputRecord(input); + if (toolName === "finish_paywall_edit" && typeof record?.editSessionId === "string") { + for (const [paywallId, editSessionId] of this.#activeByPaywallId) { + if (editSessionId === record.editSessionId) this.#activeByPaywallId.delete(paywallId); + } + return; + } + if (toolName === "revert_paywall_edit" && typeof record?.editSessionId === "string") { + for (const [paywallId, editSessionId] of this.#activeByPaywallId) { + if (editSessionId === record.editSessionId) this.#activeByPaywallId.delete(paywallId); + } + } + }; +} + +/** Structured metadata attached to each Pi workspace-tool result. */ +export interface WorkspaceAgentToolDetails { + readonly toolName: string; + readonly output: string; + readonly editSessionId?: string; + readonly paywallId?: string; +} + +const internalParameters = (schema: Record): TSchema => schema as TSchema; + +const contentOf = (result: WorkspaceToolResult) => + result.content === undefined + ? [{ type: "text" as const, text: result.output }] + : result.content.map((content) => ({ ...content })); + +/** + * Adapts every shared MCP workspace tool into a Pi tool that executes through + * the host's Effect runner. Tool names and schemas remain aligned with MCP while + * edit-session handles remain explicit and are ownership-checked before use. + */ +export const makeWorkspaceAgentTools = ( + scope: WorkspaceToolScope, + runEffect: BoundEffectRunner, + tracker = new AgentEditSessionTracker(), +): ReadonlyArray => { + return MCP_TOOLS.map((tool) => + makeEffectAgentToolWithRunner( + { + name: tool.descriptor.name, + label: tool.descriptor.name, + description: tool.descriptor.description, + parameters: internalParameters(tool.descriptor.inputSchema), + effectHandler: (input) => + tracker.prepare(tool.descriptor.name, input).pipe( + Effect.flatMap((prepared) => + tool.dispatch(scope, prepared).pipe(Effect.map((result) => ({ prepared, result }))), + ), + Effect.map(({ prepared, result }) => { + const record = inputRecord(prepared); + const suppliedEditSessionId = record?.editSessionId; + const editSessionId = + typeof suppliedEditSessionId === "string" + ? suppliedEditSessionId + : decodedEditSession(result)?.editSessionId; + const trackedPaywallId = + editSessionId === undefined ? undefined : tracker.paywallIdFor(editSessionId); + const paywallId = + typeof record?.paywallId === "string" ? record.paywallId : trackedPaywallId; + tracker.observe(tool.descriptor.name, prepared, result); + return { + content: contentOf(result), + details: { + toolName: tool.descriptor.name, + output: result.output, + ...(editSessionId === undefined ? {} : { editSessionId }), + ...(paywallId === undefined ? {} : { paywallId }), + }, + isError: result.isError, + }; + }), + ), + }, + runEffect, + ), + ); +}; diff --git a/apps/backend/src/ai/skills/component-authoring.test.ts b/apps/backend/src/ai/skills/component-authoring.test.ts new file mode 100644 index 000000000..294fa5fe8 --- /dev/null +++ b/apps/backend/src/ai/skills/component-authoring.test.ts @@ -0,0 +1,176 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vite-plus/test"; + +import { componentAuthoringSkill } from "./component-authoring.ts"; + +const skill = componentAuthoringSkill(); + +const filesystemSkill = readFileSync( + new URL( + "../../../../../plugins/voidhash/skills/code-component-authoring/SKILL.md", + import.meta.url, + ), + "utf8", +); +const claudeSkill = readFileSync( + new URL( + "../../../../../integrations/claude-code/voidhash/skills/code-component-authoring/SKILL.md", + import.meta.url, + ), + "utf8", +); + +const filesystemBody = filesystemSkill.slice(filesystemSkill.indexOf("\n---\n") + 5).trim(); + +describe("componentAuthoringSkill — delivery channels", () => { + it("keeps the MCP and Codex plugin bodies identical", () => { + expect(skill.trim()).toBe(filesystemBody); + expect(claudeSkill).toBe(filesystemSkill); + expect(skill).not.toContain("TODO"); + }); +}); + +describe("componentAuthoringSkill — component contract", () => { + it("documents the complete authoring and MCP lifecycle", () => { + for (const marker of [ + "begin_paywall_edit", + "get_components", + "read_component", + "write_component", + "rename_component", + "delete_component", + "get_paywall_preview", + "defineComponent", + "Prop builders", + "Actions", + "Previews and slots", + "Runtime hooks", + "cancelled", + ]) { + expect(skill).toContain(marker); + } + }); + + it("lists every prop builder and the author-visible primitives", () => { + for (const builder of [ + "p.string()", + "p.number()", + "p.boolean()", + "p.select(", + "p.image()", + 'p.ref("product")', + "p.component()", + "p.array(item)", + ]) { + expect(skill).toContain(builder); + } + for (const primitive of ["View", "Text", "Pressable", "ScrollView", "Image", "Slot"]) { + expect(skill).toContain(`\`${primitive}\``); + } + }); +}); + +describe("componentAuthoringSkill — custom panels", () => { + it("lists every Panel primitive", () => { + for (const primitive of [ + "Section", + "SectionActions", + "Subsection", + "Row", + "Column", + "Field", + "Text", + "Callout", + "Popover", + "PopoverTrigger", + "PopoverContent", + "Menu", + "TextField", + "SelectField", + "ToggleGroup", + "SwitchField", + "Button", + "SliderField", + "ResetAffordance", + "ColorField", + "ColorPicker", + "GradientStops", + "Swatch", + "ImageField", + "AlignmentGrid", + "DimensionField", + "FillField", + "VariableField", + "ActionEditorField", + "ProductField", + "PropField", + "DefaultProps", + ]) { + expect(skill).toContain(`Panel.${primitive}`); + } + }); + + it("documents mixed/bound/ref handles, limits, and gesture writes", () => { + expect(skill).toContain("ctx.selection.count"); + expect(skill).toContain("bound: boolean"); + expect(skill).toContain("value: PaywallProduct | undefined"); + expect(skill).toContain('gesture?: "live" | "commit"'); + expect(skill).toContain("2,000 nodes"); + expect(skill).toContain("eight event names per node"); + expect(skill).toContain("coalesces live writes per prop per frame"); + expect(skill).toContain("Current Studio custom sessions send `products: []`"); + expect(skill).toContain("a 6-second init deadline"); + expect(skill).toContain("caps intents at 240/second"); + expect(skill).toContain("10 seconds of inactivity"); + expect(skill).toContain("onStopColorChange?"); + expect(skill).toContain("onOpenChange?"); + }); +}); + +describe("componentAuthoringSkill — motion and gestures", () => { + it("lists the complete motion output vocabulary and hooks", () => { + for (const key of [ + "x", + "y", + "scale", + "scaleX", + "scaleY", + "rotate", + "opacity", + "backgroundColor", + "transformOrigin", + ]) { + expect(skill).toContain(`\`${key}\``); + } + for (const hook of [ + "useMotionValue", + "useMotionValueEvent", + "useTransform", + "useSpring", + "useVelocity", + "useMotionRef", + "useScroll", + "useInView", + "useDragControls", + "useMotionConfig", + "useReducedMotion", + ]) { + expect(skill).toContain(hook); + } + }); + + it("documents variants, reduced motion, static previews, and drag arbitration", () => { + expect(skill).toContain("Active interaction targets overlay in this order"); + expect(skill).toContain('reducedMotion: "user" | "always" | "never"'); + expect(skill).toContain("Static previews never run live animation"); + expect(skill).toContain("matching-axis ancestor ScrollView win"); + expect(skill).toContain("roughly three logical pixels"); + expect(skill).toContain("Transforms compile in this fixed order"); + expect(skill).toContain("Target values win"); + expect(skill).toContain("counts zero overlap as in view"); + expect(skill).toContain("current DOM adapter ignores both"); + expect(skill).toContain("post-claim displacement strictly on that axis"); + expect(skill).toContain("There is no `whileHover`"); + }); +}); diff --git a/apps/backend/src/ai/skills/component-authoring.ts b/apps/backend/src/ai/skills/component-authoring.ts new file mode 100644 index 000000000..fff172d96 --- /dev/null +++ b/apps/backend/src/ai/skills/component-authoring.ts @@ -0,0 +1,618 @@ +/** + * Complete author-facing reference for code components, custom editor panels, + * runtime motion, and drag gestures. The body is also shipped as a filesystem + * skill in the Voidhash Codex plugin; keep the contract test in sync when + * changing either delivery channel. + */ +const COMPONENT_AUTHORING_SKILL = `# Code Component Authoring + +Use this as the complete author-facing contract for local Voidhash code components. +Build ordinary composition with document nodes; use a code component only for behavior +the document cannot express, such as runtime-data text, loops, structural branching, +custom formatting, pointer states, motion, and drag gestures. + +## MCP workflow + +1. Call \`begin_paywall_edit({ paywallId })\` and retain its \`editSessionId\`. +2. Read the document with \`get_paywall\` and all placeable contracts with + \`get_components\`. If editing a local component, read it with \`read_component\`. +3. Write one complete \`components/.tsx\` source with \`write_component\`. Fix every + compile or runtime diagnostic and retry; a rejected write commits nothing. +4. Place a successful local definition with a document \`component\` node: + + \`\`\`json + { + "op": "insert", + "parentId": "", + "node": { + "type": "component", + "name": "Animated offer", + "componentSource": "local", + "componentPath": "components/animated-offer.tsx" + } + } + \`\`\` + +5. Configure instance \`props\`, \`localizedValues\`, \`actionBindings\`, \`previewState\`, and + slot children through \`edit_paywall\`, using the manifest returned by \`get_components\`. +6. Render \`get_paywall_preview\`, inspect the actual PNG, iterate, and finish or revert the + edit session using the normal paywall-authoring workflow. + +Treat the component path as identity. Valid paths are exactly +\`components/.tsx\`; the basename may use letters, digits, \`.\`, \`_\`, and \`-\`, +must not contain a separator or \`..\`, and is compared case-insensitively for collisions. +\`rename_component\` changes the identity and re-points local instances. +\`delete_component\` removes only the definition; existing instances become placeholders. + +Author one self-contained file. Import runtime/component APIs from +\`@voidhash/paywalls\`, panel APIs from \`@voidhash/paywalls/panel\`, and nothing else. +Do not import \`react\` directly or use relative imports from an MCP-written component. +React hooks needed by authored code are re-exported by \`@voidhash/paywalls\`. + +## Complete component pattern + +\`\`\`tsx +import { + defineComponent, + MotionConfig, + Pressable, + Slot, + Text, + View, + usePaywallActions, + useSelectedProduct, +} from "@voidhash/paywalls"; +import { Panel } from "@voidhash/paywalls/panel"; + +export default defineComponent({ + title: "Animated Offer", + description: "A selectable offer with editable appearance.", + props: (p) => ({ + title: p.string().label("Title").localizable().default("Annual Pro"), + accent: p.string().label("Accent").editor("color").default("rgba(99, 102, 241, 1)"), + radius: p.number().label("Radius").default(16), + product: p.ref("product"), + footer: p.component().optional(), + }), + actions: (a) => ({ + onSelect: a.action({ productId: a.string() }), + }), + previews: { + default: { + props: { title: "Annual Pro", accent: "rgba(99, 102, 241, 1)", radius: 16 }, + data: { + products: [{ + id: "annual", + slug: "annual", + displayName: "Annual Pro", + priceString: "$59.99", + period: "year", + }], + }, + }, + }, + panel: (ctx) => ( + + + + + + + + ctx.props.accent.set(value, { gesture: "live" })} + onCommit={(value) => ctx.props.accent.set(value, { gesture: "commit" })} + onDiscard={() => ctx.props.accent.cancel()} + value={ctx.props.accent.value} + /> + + + ctx.props.radius.set(value, { gesture: "live" })} + onCommit={(value) => ctx.props.radius.set(value, { gesture: "commit" })} + value={ctx.props.radius.value} + /> + + + + + ), + render: ({ props, actions }) => { + const { selectedProductId } = useSelectedProduct(); + const runtime = usePaywallActions(); + const selected = selectedProductId === props.product.id; + return ( + + { + actions.onSelect({ productId: props.product.id }); + runtime.selectProduct(props.product.id); + }} + style={{ + backgroundColor: selected ? props.accent : "rgba(255, 255, 255, 1)", + borderBottomLeftRadius: props.radius, + borderBottomRightRadius: props.radius, + borderTopLeftRadius: props.radius, + borderTopRightRadius: props.radius, + paddingBottom: 16, + paddingLeft: 16, + paddingRight: 16, + paddingTop: 16, + }} + transition={{ type: "spring", stiffness: 240, damping: 22 }} + whilePress={{ scale: 0.97 }} + > + + {props.title} + {props.product.priceString} + + + + + ); + }, +}); +\`\`\` + +## \`defineComponent\` contract + +Use \`export default defineComponent({ ... })\`. A named constant that holds the call and is +default-exported is also accepted, but never export \`.component\`. There is no component +\`id\`; the file path is the identity. + +- \`title?\` and \`description?\` supply editor/catalog metadata. +- \`props?: (p) => ({ ... })\` declares the editable input contract. +- \`actions?: (a) => ({ ... })\` declares named events an instance may bind. +- \`previews?: { [state]: { props?, data? } }\` supplies deterministic fixtures. +- \`panel?: (ctx) => ReactNode\` supplies a custom properties panel. +- \`render: ({ props, actions }) => ReactNode\` is required. + +Module evaluation must be deterministic and fast. Put hooks inside \`render\`, \`panel\`, or +local React components, never at module scope. \`write_component\` evaluates the module, +extracts the manifest, and renders every component preview. It detects that a custom panel +exists but does not exercise every panel event; a panel can still fail when opened, in +which case Studio falls back to default prop controls. + +Preview rendering flushes passive effects and waits one macrotask before serialization. +Keep effects finite and deterministic; do not start unbounded timers, event loops, or +network-dependent work in a preview. + +### Prop builders + +Every builder is immutable and chainable with \`.label(string)\`, \`.default(value)\`, and +\`.optional()\`. + +- \`p.string()\` yields \`string\`; add \`.editor("color")\` for a color editor. +- \`p.number()\` yields \`number\`. +- \`p.boolean()\` yields \`boolean\`. +- \`p.select(["a", "b"] as const)\` yields the option union. Options must be non-empty. +- \`p.image()\` yields an image URL/asset reference string. +- \`p.ref("product")\` yields a resolved \`PaywallProduct\` in \`render\`. +- \`p.component()\` yields a nested \`ReactNode\` controlled by the editor. +- \`p.array(item)\` yields a homogeneous array; \`item\` cannot itself be an array. Arrays of + ref/component items compile, but Studio treats them as code-configured/read-only. +- \`.localizable()\` is legal only on string and image props. + +A default makes a prop optional to the instance but always populated in \`render\`. +An explicitly optional prop is \`T | undefined\` in \`render\`. Use only JSON-safe scalar or +scalar-array defaults; ref/component defaults do not survive the manifest. Never name a +prop or action \`id\`; it is reserved for document node identity. Use the exact \`Slot\` +identifier and supported product-hook names because manifest slot/host-data detection is +source-based; renamed imports and product hooks hidden in helpers outside \`render\` may not +be detected. + +### Actions + +- \`a.action()\` declares \`() => void\`. +- \`a.action({ field: a.string(), count: a.number(), enabled: a.boolean() })\` declares a + typed payload callback. +- Payloads are flat scalar records only. + +Pass a payload-free action directly to \`Pressable\` when possible. Wrap a payload action so +the component supplies its payload. Declared action callbacks are stable and become no-ops +when an instance has no matching binding. An instance's \`actionBindings\` may bind emitted +fields to literals, variables, or action-payload fields and may perform \`set-variable\`, +\`purchase-product\`, \`close-paywall\`, or \`none\` according to the document schema. + +### Previews and slots + +Preview names must match \`[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}\`. With no declarations, an +implicit \`default\` preview is rendered. \`props\` override declared defaults. \`data\` accepts +\`products\`, \`variables\`, \`platform\`, \`safeAreaInsets\`, and \`dimensions\`; product refs +fall back to the first fixture product when unset. Insets and dimensions use logical pixels. +Exercise meaningful visual branches with named previews and keep fixture data complete. + +Use at most one \`\`. It renders document children supplied to the component +instance; \`\` renders the fallback only when no children were passed. +An empty Slot becomes a marker in the preview tree. More than one Slot collapses the +preview to an error placeholder. A component-node's children are its slot content. + +## Render primitives, runtime, and styles + +Import \`View\`, \`Text\`, \`Pressable\`, \`ScrollView\`, \`Image\`, and \`Slot\` from +\`@voidhash/paywalls\`. + +- \`View\`: flex container; supports visual motion and drag. +- \`Text\`: text content; supports visual motion, \`numberOfLines\`, test id, and accessibility + label; never draggable. +- \`Pressable\`: tap/keyboard surface; supports visual motion, press/focus states, drag, + \`disabled\`, and either static children or \`(state: { pressed }) => ReactNode\`. +- \`ScrollView\`: vertical by default, horizontal with \`horizontal\`; supports visual motion, + \`contentContainerStyle\`, and a motion ref; never draggable. +- \`Image\`: requires \`source\` as a string or \`{ uri }\`; \`resizeMode\` is \`cover\`, \`contain\`, + \`stretch\`, or \`center\`; supports visual motion and drag. + +\`testID\` is available on \`View\`, \`Text\`, \`Pressable\`, \`ScrollView\`, and \`Image\`. +All except \`ScrollView\` also accept \`accessibilityLabel\`. \`id\`/\`name\` on \`View\` and +\`Text\` are inert annotations, and \`View.onPress\` is inert; use \`Pressable\` for runtime +interaction. A ScrollView's \`contentContainerStyle\` accepts static style only. + +Styles accept a single object or nested arrays with falsy entries; later entries win. +Use React Native-style longhands, not CSS or shorthand spacing/borders. Numbers are logical +pixels; dimensions may also be strings such as \`"50%"\`. + +Supported static keys are: + +\`flex\`, \`flexDirection\`, \`alignItems\`, \`alignSelf\`, \`justifyContent\`, \`flexWrap\`, \`gap\`, +\`flexGrow\`, \`flexShrink\`, \`flexBasis\`, \`width\`, \`height\`, \`minWidth\`, \`minHeight\`, +\`maxWidth\`, \`maxHeight\`, \`paddingTop\`, \`paddingBottom\`, \`paddingLeft\`, \`paddingRight\`, +\`marginTop\`, \`marginBottom\`, \`marginLeft\`, \`marginRight\`, \`aspectRatio\`, +\`borderTopWidth\`, \`borderRightWidth\`, \`borderBottomWidth\`, \`borderLeftWidth\`, +\`borderColor\`, \`borderTopLeftRadius\`, \`borderTopRightRadius\`, +\`borderBottomLeftRadius\`, \`borderBottomRightRadius\`, \`borderStyle\`, \`backgroundColor\`, +\`backgroundType\`, \`backgroundGradient\`, \`backgroundImage\`, \`opacity\`, \`overflow\`, +\`position\`, \`top\`, \`right\`, \`bottom\`, \`left\`, \`zIndex\`, \`color\`, \`fontSize\`, +\`fontWeight\`, \`fontStyle\`, \`lineHeight\`, \`letterSpacing\`, \`textAlign\`, \`textTransform\`, +\`textDecorationLine\`, and \`fontFamily\`. + +Value unions: \`flexDirection\` is \`row | row-reverse | column | column-reverse\`; +\`alignItems\` is \`flex-start | flex-end | center | stretch | baseline\`; \`alignSelf\` also +allows \`auto\`; \`justifyContent\` is \`flex-start | flex-end | center | space-between | +space-around | space-evenly\`; \`flexWrap\` is \`wrap | nowrap | wrap-reverse\`; \`borderStyle\` +is \`solid | dotted | dashed\`; \`overflow\` is \`visible | hidden | scroll\`; and \`position\` is +\`absolute | relative\`. \`fontStyle\` is \`normal | italic\`; \`textAlign\` is \`auto | left | +right | center | justify\`; \`textTransform\` is \`none | uppercase | lowercase | capitalize\`; +and \`textDecorationLine\` is \`none | underline | line-through | underline line-through\`. +\`fontWeight\` accepts a number, \`normal\`, \`bold\`, or the strings \`100\` through \`900\`. + +For structured backgrounds, set \`backgroundType\` to \`solid\`, \`gradient\`, or \`image\`. +A gradient is \`{ kind, startX, startY, endX, endY, stops: [{ color, position }] }\` with +\`kind\` \`linear\` or \`radial\`. An image is \`{ url, resizeMode }\`. Do not use arbitrary CSS, +\`transform\`, shorthand \`padding\`, shorthand \`margin\`, shorthand \`borderWidth\`, or +\`borderRadius\`; motion transforms use the motion keys below. + +Runtime hooks: + +- \`usePaywallProducts()\` returns \`readonly PaywallProduct[]\`. +- \`useSelectedProduct()\` returns \`selectedProduct\`, \`selectedProductId\`, and + \`selectProduct(productId)\`. +- \`usePaywallVariables()\` returns \`Record\`. +- \`usePlatform()\` returns \`ios | android | web\`. +- \`useSafeAreaInsets()\` returns \`{ top, right, bottom, left }\` in logical pixels. +- \`useDimensions("screen" | "window")\` returns \`{ width, height, x, y }\` in logical + pixels; \`x\` and \`y\` are screen-space origins. +- \`usePaywallActions()\` returns \`purchase(productId?)\`, \`restore()\`, \`close(reason?)\`, + \`openUrl(url)\`, \`track(name, properties?)\`, and \`selectProduct(productId)\`. +- \`usePaywallStatus()\` returns status \`idle\`, \`purchasing\`, \`purchased\`, \`restoring\`, + \`restored\`, \`cancelled\`, or \`failed\`, plus optional \`productId\` and an optional error + containing \`code\` and \`message\`. +- \`usePaywallConfig()\` returns products, variables, locale, platform, optional safe-area + insets and dimensions, and the optional default selected product id. + +Explicit host or preview environment values take precedence. Browser measurements update on +resize, orientation, and visual-viewport events. Missing platform defaults to \`web\`; missing +or unavailable safe-area and SSR measurements fall back to zero. + +The selected product defaults to \`defaultSelectedProductId\`, then the first product. +\`purchase()\` uses that selection when no id is passed and warns/no-ops if no product exists. + +\`PaywallProduct\` contains \`id\`, \`slug\`, \`displayName\`, optional \`description\`, optional +numeric \`price\`, \`priceString\`, optional \`currencyCode\`, optional \`period\` (\`month\`, +\`year\`, \`week\`, \`lifetime\`), and optional \`trialPeriod\`. Also import \`useState\`, +\`useEffect\`, \`useMemo\`, \`useCallback\`, and \`useRef\` from \`@voidhash/paywalls\`. + +## Custom designer panels + +Import \`Panel\` from \`@voidhash/paywalls/panel\`. Return exactly one \`\` root from the +\`panel(ctx)\` function. The definition runs as a long-lived React session, so hooks, state, +effects, and timers work. Its output is serialized to a closed JSON-safe tree; functions +never cross the sandbox boundary. Events are routed back to the current handler by node id +and event name. A recompile or component-identity change remounts the panel and resets its +local state. + +### Panel context and safe editing + +\`ctx.selection.count\` reports the number of homogeneous component instances being edited. +\`ctx.data.products\` and \`ctx.data.variables\` contain host data when available; do not assume +either is populated. + +Current Studio custom sessions send \`products: []\` and \`variables: {}\`. Consequently a +custom \`ctx.props..set(productId)\` is rejected until a synchronous product source is +wired. Use \`Panel.PropField\` for product refs and variable-binding chrome; it expands through +the host and does not depend on this sandbox data. + +For scalar, select, image, and writable array props, \`ctx.props.name\` contains: + +- \`value: T | undefined\`, \`mixed: boolean\`, \`bound: boolean\`, and \`kind\`. +- \`set(value, { gesture?: "live" | "commit" })\`; omitted gesture means \`commit\`. +- \`cancel()\` to discard an in-flight live gesture. +- \`reset()\` to remove the override and return to the declared default. + +Never call \`set\` or \`reset\` when \`bound\` is true; the host drops the write because variable +binding owns the value. Render \`Panel.PropField\` or \`Panel.DefaultProps\` to retain the +host's binding/localization/reset chrome. For multi-selection, pass \`mixed\` into controls +and avoid presenting one target's value as unanimous. + +A ref handle contains \`value: PaywallProduct | undefined\`, \`productId\`, \`mixed\`, \`kind: +"ref"\`, and \`set(productId)\`. A component prop handle is read-only (\`value\`, \`mixed\`, +\`kind\`). Prefer \`Panel.PropField\` for ref, component, localized, and variable-bound props; +the host owns their complete editor behavior and may not inject products into a custom +session synchronously. + +### Panel primitives + +Shared tokens: + +- gap: \`none | xs | sm | md | lg\`; width: \`auto | full | half\`. +- align: \`start | center | end | stretch\`; justify: \`start | center | end | between\`. +- text variant: \`label | body | caption | heading\`. +- tone: \`default | muted | info | warning | error\`. +- button variant: \`default | outline | ghost | destructive\`; size: \`sm | icon-sm | default\`. +- option: \`{ value, label?, icon?, disabled? }\`. + +Layout and chrome: + +- \`Panel\` root; \`Panel.Section({ title?, collapsible?, defaultCollapsed?, onToggle? })\`; + \`Panel.SectionActions\`; \`Panel.Subsection({ title? })\`. +- \`Panel.Row({ gap?, width?, align?, justify? })\`; \`Panel.Column({ gap?, width?, align? })\`; + \`Panel.Field({ label?, icon? })\`. +- \`Panel.Text({ content?, variant?, tone? })\` and + \`Panel.Callout({ message?, tone? })\` use props, not JSX text children. +- \`Panel.Popover({ open?, onOpenChange? })\`, \`Panel.PopoverTrigger\`, and + \`Panel.PopoverContent({ align?, side? })\` compose a popover. +- \`Panel.Menu({ items?, value?, align?, onSelect? })\` renders a host dropdown. + +Basic controls: + +- \`Panel.TextField({ kind?, value?, mixed?, placeholder?, min?, max?, step?, icon?, + disabled?, trailingMenu?, onChange?, onCommit?, onTrailingSelect? })\`. Event values are + strings; parse numeric text when necessary. +- \`Panel.SelectField({ value?, options?, placeholder?, mixed?, disabled?, onChange? })\`. +- \`Panel.ToggleGroup({ value?, options?, mixed?, disabled?, onChange? })\`. +- \`Panel.SwitchField({ checked?, mixed?, label?, disabled?, onChange? })\`. +- \`Panel.Button({ label?, icon?, variant?, size?, disabled?, onClick? })\`. +- \`Panel.SliderField({ value?, min?, max?, step?, mixed?, disabled?, onChange?, + onCommit? })\`. +- \`Panel.ResetAffordance({ show?, label?, onReset?, children? })\`. + +Host-integrated composites: + +- \`Panel.ColorField({ value?, mixed?, mixedLabel?, disabled?, onChange?, onDragStart?, + onCommit?, onDiscard? })\`. +- \`Panel.ColorPicker({ color?, opacity?, onColorChange?, onOpacityChange?, onDragStart?, + onDragEnd?, onDiscard? })\`. +- \`Panel.GradientStops({ stops?, selectedStopId?, onSelect?, onAddStop?, onMoveStop?, + onRemoveStop?, onDragStart?, onDragEnd?, onDiscard? })\`. +- \`Panel.Swatch({ color?, imageUrl? })\`; \`Panel.ImageField({ url?, resizeMode?, onPick?, + onResizeModeChange?, onClear? })\`. +- \`Panel.AlignmentGrid({ flexDirection?, alignItems?, justifyContent?, mixed?, onChange? })\`. +- \`Panel.DimensionField({ axis?, mode?, value?, label?, mixed?, disabled?, computed?, + onChange?, onCommit?, onModeChange? })\`. +- \`Panel.FillField({ label?, backgroundType?, isTypeMixed?, backgroundColor?, gradient?, + selectedStopIndex?, image?, open?, onTypeChange?, onColorChange?, onGradientChange?, + onStopColorChange?, onStopPositionChange?, onAddStop?, onAddStopAt?, onRemoveStop?, + onSelectStop?, onImageUrlChange?, onImageResizeModeChange?, onGestureStart?, onCommit?, + onDiscard?, onOpenChange? })\`. Keep at most eight handlers on one node; normally use the + write-bearing set \`onTypeChange\`, \`onColorChange\`, \`onGradientChange\`, + \`onImageUrlChange\`, \`onImageResizeModeChange\`, \`onGestureStart\`, \`onCommit\`, and + \`onDiscard\`. +- \`Panel.VariableField({ variableId?, variableName?, variableType?, allowedKinds?, label?, + onBind?, onUnbind?, onCreate? })\`. +- \`Panel.ActionEditorField({ value?, variables?, productVariables?, payloadFields?, + onChange? })\`. +- \`Panel.ProductField({ productId?, placeholder?, disabled?, label?, onChange? })\`. +- \`Panel.PropField({ name })\` expands one manifest prop through the full host editor. +- \`Panel.DefaultProps({ exclude? })\` expands all manifest props except the exclusions. + +Composite callbacks do not grant extra host authority. In particular, \`VariableField\` and +\`ActionEditorField\` only emit their declared callbacks; use host-expanded prop rows for +actual component prop binding, localization, reset, and product selection. + +Use only these icon tokens: +\`w\`, \`h\`, \`a\`, \`plus\`, \`minus\`, \`x\`, \`trash\`, \`pencil\`, \`search\`, \`settings\`, \`info\`, +\`alert\`, \`image\`, \`imagePlus\`, \`pipette\`, \`percent\`, \`diamond\`, \`square\`, \`squareDashed\`, +\`squareRoundCorner\`, \`squareRoundCornerTopLeft\`, \`squareRoundCornerBottomLeft\`, +\`squareRoundCornerBottomRight\`, \`squareDashedTopSolid\`, \`squareDashedTopSolidLeft\`, +\`squareDashedTopSolidRight\`, \`squareDashedTopSolidBottom\`, \`type\`, \`component\`, \`code\`, +\`eye\`, \`paintbrush\`, \`chevronDown\`, \`chevronRight\`, \`chevronUp\`, \`arrowDown\`, +\`arrowRight\`, \`arrowUpCircle\`, \`rotateCw\`, \`rotateCcw\`, \`flipHorizontal\`, \`undo\`, \`redo\`, +\`save\`, \`fullscreen\`, \`scan\`, \`vault\`, \`panelLeftDashed\`, \`panelRightDashed\`, +\`panelTopDashed\`, \`panelBottomDashed\`, \`panelLeftRightDashed\`, \`panelTopBottomDashed\`, +\`betweenHorizontalStart\`, \`user\`, \`users\`, \`externalLink\`, and \`mousePointer\`. + +Panel limits are 256 KiB per tree, 2,000 nodes, depth 32, 4,096 characters per string, +256 options per select/toggle/menu, 64 gradient stops, eight event names per node, and +32 KiB per emitted prop value. The host also validates values against the component +manifest, rejects unknown/read-only/bound props, limits arrays to 200 items, rate-limits +the intent stream, and coalesces live writes per prop per frame. + +Panel trees are coalesced to the latest revision once per animation frame. The sandbox has +a 6-second init deadline, a 2.5-second heartbeat, allows two missed pongs, kills tree +streams above 120/second, caps intents at 240/second, and permits two automatic restarts +with 400 ms backoff. A terminal failure switches to the default prop panel with a retry +control. + +### Panel gesture pattern + +Use committed writes for text/select/switch/button edits. For continuous controls, send +live values during movement, a final committed value on release, and cancel on abort: + +\`\`\`tsx + ctx.props.radius.set(value, { gesture: "live" })} + onCommit={(value) => ctx.props.radius.set(value, { gesture: "commit" })} +/> +\`\`\` + +The first live write opens a host draft; live writes are transient/coalesced, and the end +commits one undoable edit. \`cancel()\` discards the in-flight draft. A selection change, +session failure, or 10 seconds of inactivity also discards an unfinished gesture. Keep the +control's display value derived from the latest \`ctx\` snapshot; the host protects an active +control from stale sandbox echoes during a drag. + +## Runtime animation and gestures + +### Motion targets, variants, and transitions + +Every motion-capable primitive (\`View\`, \`Text\`, \`Pressable\`, \`ScrollView\`, and \`Image\`) +accepts \`initial\`, \`animate\`, \`variants\`, \`transition\`, \`whileInView\`, \`viewport\`, +\`onAnimationStart\`, and \`onAnimationComplete\`. \`Pressable\` also accepts +\`whilePress\`/\`whileFocus\`; draggable primitives accept \`whileDrag\`. + +Motion targets support only \`x\`, \`y\`, \`scale\`, \`scaleX\`, \`scaleY\`, \`rotate\` (degrees), +\`opacity\`, \`backgroundColor\`, and \`transformOrigin\` (\`{ x, y }\` fractions of the box). +Put these keys directly in \`style\` for motion values or in a target. Do not use a CSS +\`transform\` string. + +Use inline targets or named variants: + +\`\`\`tsx + +\`\`\` + +A variant label array merges labels left-to-right. Literal motion style is the base; +\`animate\` overrides it at rest. Active interaction targets overlay in this order: +in-view, press, focus, drag, so later states win conflicting keys. \`initial={false}\` mounts +at rest. Define a numeric value in both start and target states if it must interpolate; +non-numeric motion values such as colors and transform origins currently snap. + +Transforms compile in this fixed order: translate (\`x\`/\`y\`), rotate, \`scale\`, \`scaleX\`, +then \`scaleY\`. + +Transitions use seconds. \`type\` is \`tween\` or \`spring\`; shared fields are \`delay\`, +\`duration\`, \`ease\` (\`linear\`, \`easeIn\`, \`easeOut\`, \`easeInOut\`), \`stiffness\`, \`damping\`, +\`mass\`, \`velocity\`, \`restDelta\`, and \`restSpeed\`. Defaults are a 0.3-second linear tween, +or a spring with stiffness 170, damping 26, mass 1, rest delta/speed 0.01. A transition may +contain per-key overrides plus \`default\`. \`MotionConfig\` supplies inherited transition and +\`reducedMotion: "user" | "always" | "never"\`; reduced motion jumps to targets and still +fires completion. \`useMotionConfig()\` reads the inherited policy/default transition, and +\`useReducedMotion()\` resolves it to a boolean using the platform preference. An interrupted +animation is canceled and does not fire its old completion callback. + +Static previews never run live animation. They serialize the deterministic rest state +(\`animate\`, else \`initial\`, over literal motion style), omit live motion values, render +Pressable children unpressed, and flatten scroll content. Design the rest state to be +complete and readable. + +The \`AnimationControls\` type is reserved for imperative control, but there is currently no +author-facing controls factory/binding. Use declarative \`animate\`/variants or motion values. + +### Motion values + +- \`useMotionValue(initial)\` creates a stable mutable value whose updates bypass React. +- \`motionValue(initial)\` creates one outside React; \`get\`, \`getPrevious\`, \`set\`, and + \`on("change" | "renderRequest", listener)\` form its protocol. +- \`useMotionValueEvent(value, event, listener)\` subscribes without React frame renders. +- \`useTransform(source, fn)\` derives any mapped value. The numeric range overload maps + equal-length input/output arrays and clamps to the covered range. +- \`useSpring(source, transition)\` follows a numeric source with an interruptible spring. +- \`useVelocity(source)\` returns logical units per second. + +Pass a motion value through a supported motion style key: + +\`\`\`tsx +const { scrollYProgress } = useScroll(); +const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0.4, 1, 0.4]); +return ; +\`\`\` + +A live motion value drives a key only when the resolved animation/interaction target does +not also define that key. Target values win; reduced-motion mode also suppresses live style +updates. Static previews omit motion values entirely. + +### Scroll and in-view + +Create renderer-neutral refs with \`useMotionRef()\` and attach them to motion-capable primitives. +\`useScroll({ container?, target?, axis?, offset?, trackLayout? })\` returns \`scrollX\`, +\`scrollY\`, \`scrollXProgress\`, and \`scrollYProgress\` motion values. Without a container it +tracks the root window. Without a target, progress is total scroll progress. With a target, +the default offsets are \`["start end", "end start"]\`; anchors accept \`start\`, \`center\`, +\`end\`, percentages, or numeric strings. Use \`trackLayout\` when changing layout affects the +measurement. \`axis\` defaults to \`y\`; with a target, only the selected axis's progress value +is updated. + +\`useInView(ref, { root?, once?, amount?, margin? })\` returns a boolean. \`amount\` is a +number, \`some\` (1% threshold), or \`all\`; \`once\` stays true after first entry. \`whileInView\` +uses the same viewport contract. Omitted \`amount\` currently uses a zero threshold, which +counts zero overlap as in view; pass \`"some"\` or a positive number for actual intersection. +The current DOM adapter accepts \`margin\` in the public shape but does not apply it; do not +rely on margin-sensitive behavior. + +\`ScrollView\` refs additionally expose \`getScrollMetrics()\`, \`scrollTo({ x?, y? })\`, and +\`subscribeScroll\`. Measurements use the untransformed layout box. + +### Drag gestures + +Drag is supported only by \`View\`, \`Image\`, and \`Pressable\`. + +- \`drag={true}\` allows both axes. \`"x"\` or \`"y"\` limits which dominant direction may + claim the gesture; in the current DOM adapter, also enable \`dragDirectionLock\` to keep + post-claim displacement strictly on that axis. +- \`dragConstraints\` accepts \`{ left?, right?, top?, bottom? }\` relative to the starting + motion position, or a \`MotionRef\` whose measured box bounds the draggable node. +- \`dragElastic\` is \`0.35\` by default, \`false\` for no overshoot, or a numeric factor. +- \`dragMomentum\` defaults true. Release projects velocity by 0.2 seconds, clamps it to + constraints, and animates to the result with \`{ type: "spring", ...dragTransition }\`; + supplied transition fields, including \`type\`, override the default. Set false to stop at + the release position. +- \`dragDirectionLock\` locks to the dominant axis after movement begins. +- \`gesturePriority="auto"\` lets a matching-axis ancestor ScrollView win; a cross-axis drag + wins. Use \`"drag"\` only when the draggable must steal a matching-axis scroll gesture. +- \`dragListener={false}\` disables direct pointer start. It also enables registration of a + supplied \`dragControls={useDragControls()}\`. \`controls.start(event, options)\` requires a + \`MotionGestureEvent\`; options are \`{ snapToCursor?, distanceThreshold? }\`. Authored + primitives expose no raw pointer-down event and the current DOM adapter ignores both + options, so prefer the built-in listener. +- \`onDragStart\`, \`onDrag\`, and \`onDragEnd\` receive a platform-neutral event and \`DragInfo\`: + current \`point\`, last-event \`delta\`, start-relative \`offset\`, and logical-units-per-second + \`velocity\`. + +A drag claims the gesture after roughly three logical pixels. On a Pressable, claiming a +drag clears the pressed state and suppresses the following click. \`whilePress\` begins on +pointer down and clears on leave/up/cancel; \`whileFocus\` follows keyboard focus. Enter and +Space activate a focused Pressable. There is no \`whileHover\`, keyframe/timeline API, layout +animation, arbitrary transform string, or draggable Text/ScrollView in the current surface. + +## Final verification checklist + +- Keep the component as small as the code-only behavior permits. +- Use a valid, unique path and default-export the \`defineComponent\` result. +- Declare every editable input and emitted event; avoid reserved \`id\`. +- Provide preview fixtures for product/runtime branches and use no more than one Slot. +- Use only supported primitives, style keys, motion keys, imports, and panel nodes. +- Preserve mixed/bound semantics and use gesture-aware panel writes correctly. +- Respect reduced motion and make the static rest state complete. +- Re-read compiler diagnostics instead of guessing, inspect the rendered paywall PNG, and + finish or revert the edit session explicitly. +`; + +/** Returns the complete code-component authoring skill body. */ +export const componentAuthoringSkill = (): string => COMPONENT_AUTHORING_SKILL; diff --git a/apps/backend/src/ai/skills/paywall-authoring.test.ts b/apps/backend/src/ai/skills/paywall-authoring.test.ts new file mode 100644 index 000000000..f16a8a315 --- /dev/null +++ b/apps/backend/src/ai/skills/paywall-authoring.test.ts @@ -0,0 +1,121 @@ +import { ALLOWED_CHILDREN_BY_NODE_TYPE, type NodeType } from "@voidhash/mimic-schema"; +import { nodeStyleFields } from "@voidhash/ai-shared"; +import { describe, expect, it } from "vite-plus/test"; + +import { paywallAuthoringSkill } from "./paywall-authoring.ts"; + +/** + * Contract test for the GENERATED sections of the paywall-authoring skill. It + * introspects the mimic schema the SAME way the generator does, so it fails the + * instant the generator silently drops a style field (e.g. a schema kind it does + * not handle) or the containment listing drifts from `ALLOWED_CHILDREN_BY_NODE_TYPE`. + * This is the whole point of generating the reference from the schema — the test + * pins the generator to the source of truth, not to a hand-written expectation. + */ + +/** The node types whose full style reference the skill ships. */ +const STYLED_NODE_TYPES: readonly NodeType[] = ["screen", "view", "text", "shape", "path"]; + +const skill = paywallAuthoringSkill(); + +describe("paywallAuthoringSkill — generated style reference", () => { + for (const type of STYLED_NODE_TYPES) { + it(`lists every style field of \`${type}\``, () => { + const fields = nodeStyleFields(type); + // A styled node type must contribute at least one field — a schema kind the + // generator can't introspect would collapse this to empty and this catches it. + expect(fields.length).toBeGreaterThan(0); + for (const field of fields) { + expect( + skill.includes(`\`${field}\``), + `style field "${field}" of node type "${type}" is missing from the generated skill`, + ).toBe(true); + } + }); + } + + it("surfaces designer-internal flag fields (no longer hidden from the model)", () => { + // The drift these fields expose is the whole point: they are real, settable + // style fields the schema declares, so generating from the schema means the + // model finally sees them. + expect(skill).toContain("`backgroundEnabled`"); + expect(skill).toContain("`safeAreaTop`"); + expect(skill).toContain("`borderEnabled`"); + }); + + it("enumerates enum literal values (e.g. flexDirection)", () => { + // flexDirection is `Either(Literal("row"), Literal("column"))` — the generator + // renders the literal set so the model knows the allowed values. + expect(skill).toContain('"row" | "column"'); + }); + + it("annotates the auto-managed `*Enabled` flag fields", () => { + // The AI edit path derives these flags, so the reference tells the model it + // only ever writes them to hide a group (explicit `false`). + expect(skill).toContain("AUTO-MANAGED"); + expect(skill).toContain("set to true automatically when you set any background field"); + expect(skill).toContain("set to true automatically when you set any fill field"); + }); +}); + +describe("paywallAuthoringSkill — generated containment reference", () => { + it("matches ALLOWED_CHILDREN_BY_NODE_TYPE for every authorable parent", () => { + // root/library/codeComponent are engine-managed and intentionally omitted. + const authorable: readonly NodeType[] = ( + Object.keys(ALLOWED_CHILDREN_BY_NODE_TYPE) as NodeType[] + ).filter((type) => type !== "root" && type !== "library" && type !== "codeComponent"); + + for (const type of authorable) { + const children = ALLOWED_CHILDREN_BY_NODE_TYPE[type]; + if (children.length > 0) { + const expected = `\`${type}\` may contain: ${children + .map((child) => `\`${child}\``) + .join(", ")}`; + expect( + skill.includes(expected), + `containment line for "${type}" drifted from ALLOWED_CHILDREN_BY_NODE_TYPE`, + ).toBe(true); + } else { + expect(skill).toContain(`\`${type}\` is a leaf (no child nodes).`); + } + } + }); + + it("does not list engine-managed node types as authorable parents", () => { + expect(skill).not.toContain("`root` may contain"); + expect(skill).not.toContain("`library` may contain"); + expect(skill).not.toContain("`codeComponent` may contain"); + }); +}); + +describe("paywallAuthoringSkill — visual review discipline", () => { + it("documents rendered inspection, screenshot checkpoints, and gated completion", () => { + expect(skill).toContain("`get_paywall_preview` is the visual checkpoint"); + expect(skill).toContain("`finish_paywall_edit` is the completion gate"); + expect(skill).toContain("Hierarchy and story"); + expect(skill).toContain("Offer clarity"); + expect(skill).toContain("Viewport fit"); + expect(skill).toContain("Purchase affordances"); + }); +}); + +describe("paywallAuthoringSkill — dynamic no-code behavior", () => { + it("explains the variables → actions → states cycle and component action bindings", () => { + expect(skill).toContain("Variables, states, and actions"); + expect(skill).toContain("declare variable → click action updates variable"); + expect(skill).toContain("`localVariables`"); + expect(skill).toContain("`set-variable`"); + expect(skill).toContain("`purchase-product`"); + expect(skill).toContain("`actionBindings`"); + expect(skill).toContain("Later matching states win conflicting fields"); + expect(skill).toContain("State style overrides do not auto-enable"); + expect(skill).toContain('"backgroundEnabled": true'); + expect(skill).toContain('"selected_product"'); + }); + + it("keeps variable-driven states in the document-first path", () => { + expect(skill).toContain("document variables + states + actions"); + expect(skill).toContain("simple conditional visibility via `display`"); + expect(skill).toContain("document states are driven"); + }); +}); diff --git a/apps/backend/src/ai/skills/paywall-authoring.ts b/apps/backend/src/ai/skills/paywall-authoring.ts new file mode 100644 index 000000000..173ed2c9b --- /dev/null +++ b/apps/backend/src/ai/skills/paywall-authoring.ts @@ -0,0 +1,674 @@ +import { ALLOWED_CHILDREN_BY_NODE_TYPE, NODE_TYPES, type NodeType } from "@voidhash/mimic-schema"; +import { + acceptanceOf, + nodeDefaultData, + nodeStyleFields, + nodeStyleSchema, + STYLE_GROUP_FLAG_BY_FIELD, + type SerializedSchema, +} from "@voidhash/ai-shared"; + +/** + * Authoring guide appended to the designer-surface system prompt. The document + * model, containment rules, and the entire style reference are GENERATED from the + * live mimic schemas at module load (never hand-listed) — the drift-killer at the + * heart of the document-first authoring redesign. The code-component half is + * hand-written prose (that grammar lives outside the mimic node schemas). + * + * Rendering pipeline (see the generators below): read the mimic node primitives + * via ai-shared's schema introspection → for each covered node type, enumerate + * its style fields with their value family (enum literals / number / color / + * string / object) and schema default → format as a compact per-field list. The + * containment lines come straight from `ALLOWED_CHILDREN_BY_NODE_TYPE`. + */ + +/** + * A short value-type label for a style field, derived from its serialized schema. + * Enums enumerate their literal set; a field whose name marks it a color renders + * `color` (colors are plain strings in the schema, indistinguishable by shape); + * structured fields render `object`; scalars render their family. + */ +function styleValueLabel(field: string, schema: SerializedSchema): string { + const acc = acceptanceOf(schema); + if (acc.literals.length > 0 && !acc.acceptsNumber && !acc.acceptsString && !acc.acceptsBoolean) { + return acc.literals.map((literal) => JSON.stringify(literal)).join(" | "); + } + if (acc.isStructured) return "object"; + // Colors are `rgba(r, g, b, a)` strings — the schema can't distinguish them + // from a free string, so key off the field name (the only reliable signal). + if (/color$/i.test(field)) return "color (rgba)"; + const families: string[] = []; + if (acc.acceptsNumber) families.push("number"); + if (acc.acceptsString) families.push("string"); + if (acc.acceptsBoolean) families.push("boolean"); + return families.join(" | ") || "unknown"; +} + +/** + * The `Enabled` flag fields the AI edit path AUTO-MANAGES (derived from the + * group-flag map, not hand-listed). Setting any field of a gated style group + * (background / border / shadow / fill / stroke) turns its flag on automatically. + */ +const AUTO_MANAGED_FLAG_FIELDS: ReadonlySet = new Set( + Object.values(STYLE_GROUP_FLAG_BY_FIELD), +); + +/** + * The AUTO-MANAGED annotation for a `Enabled` flag field, telling the model + * it never needs to set the flag on and that its only use is an explicit `false` + * to hide the group without deleting the group's fields. + */ +function autoManagedAnnotation(flagField: string): string { + const group = flagField.replace(/Enabled$/, ""); + return ` — AUTO-MANAGED: set to true automatically when you set any ${group} field; write \`false\` explicitly to hide the ${group} without deleting its fields.`; +} + +/** The schema default for one style field of a node type, or `undefined` if it has none. */ +function styleFieldDefault(type: NodeType, field: string): unknown { + const style = nodeDefaultData(type)["style"]; + return style && typeof style === "object" ? (style as Record)[field] : undefined; +} + +/** + * Render one node type's full style reference as a compact block: one line per + * style field with its value family and default. Every field the schema declares + * is listed (that is what the contract test guards) — so the model sees the + * designer-internal fields (`backgroundEnabled`, `safeAreaTop`, …) too, which is + * the whole point of generating from the schema. + */ +function renderStyleReference(type: NodeType): string { + const styleSchema = nodeStyleSchema(type); + const fields = nodeStyleFields(type); + if (styleSchema === undefined || fields.length === 0) { + return `\`${type}\` has no style fields.`; + } + const lines = fields.map((field) => { + const label = styleValueLabel(field, styleSchema.fields[field]!); + const defaultValue = styleFieldDefault(type, field); + const defaultText = + defaultValue === undefined ? "no default" : `default ${JSON.stringify(defaultValue)}`; + const annotation = AUTO_MANAGED_FLAG_FIELDS.has(field) ? autoManagedAnnotation(field) : ""; + return `- \`${field}\`: ${label} (${defaultText})${annotation}`; + }); + return lines.join("\n"); +} + +/** The node types whose style reference ships in the prompt (every styled editable node). */ +const STYLE_REFERENCE_NODE_TYPES: readonly NodeType[] = ["screen", "view", "text", "shape", "path"]; + +/** The full generated style reference: one section per styled node type. */ +const styleReference = (): string => + STYLE_REFERENCE_NODE_TYPES.map( + (type) => `### \`${type}\` style fields\n\n${renderStyleReference(type)}`, + ).join("\n\n"); + +/** + * The generated containment listing: for each node type, the node types it may + * legally contain (from `ALLOWED_CHILDREN_BY_NODE_TYPE`). A leaf (no legal + * children) is stated as such. Only the types the model authors are listed + * (`library`/`codeComponent`/`root` are engine-managed). + */ +const AUTHORABLE_PARENT_TYPES: readonly NodeType[] = NODE_TYPES.filter( + (type) => type !== "root" && type !== "library" && type !== "codeComponent", +); + +const CONTAINMENT_REFERENCE = AUTHORABLE_PARENT_TYPES.map((type) => { + const children = ALLOWED_CHILDREN_BY_NODE_TYPE[type]; + return children.length > 0 + ? `- \`${type}\` may contain: ${children.map((child) => `\`${child}\``).join(", ")}` + : `- \`${type}\` is a leaf (no child nodes).`; +}).join("\n"); + +let cachedSkill: string | undefined; + +/** + * Assembled skill body — static prose interleaved with the generated references. + * Lazy + memoized, never a module-level string: rendering the style reference + * computes schema defaults, which encodes entry-wrapped arrays whose + * fractional-index keys are jitter-randomized — and workerd forbids generating + * random values in global scope, so the skill must be built on first use inside + * a request handler. + */ +export const paywallAuthoringSkill = (): string => + (cachedSkill ??= `# Paywall Authoring Reference + +This is the domain reference for building a paywall as a mimic document +with \`edit_paywall\`, plus code components for anything that needs real code. +Read the document model and containment rules first — the style reference below is +generated from the live schema, so it is always exact. + +## Document model + +A paywall is a MIMIC DOCUMENT: a tree of nodes. You author it with \`edit_paywall\` +ops (\`insert\`, \`update\`, \`move\`, \`remove\`, \`replaceChildren\`), addressing nodes +by their engine-minted id. The node kinds you build with: + +- \`screen\` — the paywall's root visual frame (the top-level node under the + document). Holds the whole layout; has a background, safe-area flags, and a + fixed default size (375×812). +- \`view\` — a flex container / row / column. The workhorse for layout, grouping, + cards, buttons (a \`view\` with a click interaction is a tappable surface — + there is no separate button/pressable node). +- \`scrollView\` — a \`view\` that SCROLLS. Same style/children as a \`view\`; use it + for content taller than the screen. \`horizontal: true\` scrolls sideways and lays + its children out in a row (forces row flow, like RN); \`showsScrollIndicator: false\` + hides the scrollbar. +- \`text\` — a text leaf. Its \`text\` data field is the literal string it renders. +- \`shape\` — a vector container that holds \`path\` nodes (for icons / custom + vector art). +- \`path\` — a single vector path inside a \`shape\`. +- \`component\` — an INSTANCE of a catalog component, a local code component, or a + first-party BUILTIN that ships with the renderer. Insert one to place a reusable + widget; bind its props/actions (discover them with \`get_components\`). Insert a + builtin with \`componentSource: "builtin"\` and its \`componentSlug\` from + \`get_components\` — builtins are UNPINNED (no version/hash) and take props like + any component. + +Node ids are ADDRESSES: every node has a stable id. \`get_paywall\` returns the +tree with ids; the user's selection is a set of ids; inserts RETURN their minted +ids. You never write an id yourself. + +### Containment (which node may hold which) + +An \`insert\`/\`move\` into a parent that cannot contain the node type is rejected. +The legal parent → child rules (generated from the schema): + +${CONTAINMENT_REFERENCE} + +## Style reference (generated from the schema) + +Style is a per-node \`style\` object of RN-named fields (NO shorthands: write +\`paddingTop\`/\`paddingRight\`/… never \`padding\`; \`borderTopLeftRadius\`/… never +\`borderRadius\`). Colors are \`rgba(r, g, b, a)\` strings. Each node type accepts a +different subset; a field not listed for a node type is rejected on it. + +**Group flags are AUTO-MANAGED in a node's base \`style\`.** Setting ANY background / +border / shadow (or, on \`path\`, fill / stroke) field there automatically turns +that group's \`Enabled\` flag on — just set \`backgroundColor\` and the +background renders. Inside \`states[].overrides.style\`, explicitly include the +matching \`*Enabled: true\` flag when the base style has not already enabled that +group. The flags also support non-destructive hiding: explicitly set one to +\`false\` (e.g. \`{ backgroundEnabled: false }\`) to hide a group while keeping its +fields for later. + +Set a style field via \`update\` with merge semantics: \`set: { style: { paddingTop: 8 } }\` +changes ONLY \`paddingTop\` and leaves every other style field untouched. To insert +a node with styles, put the \`style\` object on the inserted node. + +${styleReference()} + +### Flex layout (defaults match CSS) + +Layout is flexbox, exactly like CSS / React Native: + +- Views **hug their content** by default: \`width\` and \`height\` default to + \`"auto"\`. Do NOT set a numeric \`width\`/\`height\` just to "make it fit" — omit + them and let flex size the node. Set a number only for a genuinely fixed size. +- A container **stretches its children on the CROSS axis** by default + (\`alignItems\` defaults to \`"stretch"\`). So a child of a \`column\` fills the + container's width, and a child of a \`row\` fills its height, automatically — + you do not need \`alignSelf\` or a width/height for that. +- To make a child **fill the MAIN axis** (grow to share leftover space), give it + \`flex: 1\` (e.g. a spacer, or two side-by-side cards that split a row). +- Cross-axis fill is automatic UNLESS the child opts out — either a numeric size + on that axis (a fixed size defeats stretch) or a non-\`stretch\` \`alignSelf\` + (\`"flex-start"\`/\`"center"\`/\`"flex-end"\`). Set \`alignSelf\` only to deviate from + the container's alignment. +- Prefer omitting \`width\`/\`height\` and reaching for \`flexDirection\`, \`gap\`, + \`justifyContent\` (main axis), \`alignItems\` (cross axis), and \`flex\` to compose + layout — the same tools you would use in CSS. + +### Structured style values (gradient / image backgrounds) + +\`backgroundGradient\` and \`backgroundImage\` are objects. Set +\`backgroundType: "gradient"\` (or \`"image"\`) for the chosen fill to render +(\`backgroundEnabled\` is turned on automatically for you). + +\`\`\`json +// backgroundGradient +{ "kind": "linear", "startX": 0.5, "startY": 0, "endX": 0.5, "endY": 1, + "stops": [ { "color": "rgba(255,255,255,1)", "position": 0 }, + { "color": "rgba(255,255,255,0)", "position": 1 } ] } +// backgroundImage +{ "url": "https://…", "resizeMode": "cover" } // cover | contain | stretch | center +\`\`\` + +## Tool usage + +- **Open your edit session.** Discover the target with \`list_paywalls\`, then call + \`begin_paywall_edit({ paywallId })\`. Pass only the returned \`editSessionId\` to every + scoped call. Every participant gets an independent Mimic connection, so multiple users + and agents may edit the same paywall concurrently. Finish yours after visual QA, or + revert it; never abandon an active session. +- **\`get_paywall\` first.** Learn the current tree and ids before editing. Pass + \`nodeId\` to root at a subtree and \`depth\` to cap the tree (deeper nodes return + as \`{ id, type, name?, childCount }\` stubs you expand with a follow-up + \`get_paywall({ editSessionId, nodeId })\`). Cheaper than pulling the whole document every + turn. +- **\`edit_paywall\` applies an ATOMIC batch.** Every op lands or none does. On + success you get the minted ids for \`insert\`/\`replaceChildren\` ops (keyed by op + index, parents-before-children) so you can address new nodes next. On failure + you get per-op errors naming the node, the allowed fields (with a did-you-mean), + and the allowed values — trust them and correct the batch. On a write conflict + the batch is re-applied against the latest LIVE tree, so concurrent designer + edits MERGE rather than clobber — a follow-up \`get_paywall\` may show a tree + shifted by another editor's changes. +- **Merge semantics on \`update\`.** \`set.style\` merges per style field; other + objects merge per-field; arrays and scalars REPLACE wholesale. Send only the + fields you want to change. +- **Ids are minted.** Never supply an id on an inserted node. Target existing + nodes with ids from \`get_paywall\`, the selection, or a prior insert's returned + ids. +- **Selection ids are directly addressable.** When the context block lists + selected node ids, use them straight in ops (no need to re-locate them). +- **\`duplicate_subtree\` preserves visual systems.** Prefer it over rebuilding a + repeated benefit, product option, card, or control from scratch. Fresh ids are + returned, so immediately tailor the clone's content/state with focused updates. +- **\`get_paywall_preview\` is the visual checkpoint.** Capture the screen after + each meaningful visual section and after every correction pass. Inspect the + pixels; do not infer visual quality from the document tree alone. +- **\`finish_paywall_edit\` is the completion gate.** It accepts only the exact signature + of the latest screenshot while the document is unchanged and + \`unresolvedIssues\` is empty. Any post-review edit requires a new screenshot. + +## Visual design and review workflow + +For broad design work, decide on one coherent direction before editing: audience +and offer, visual mood, a restrained palette, typography scale, spacing rhythm, +content hierarchy, and CTA treatment. Build one meaningful visual group at a +time, then review it in the live screenshot. Prefer purposeful hierarchy and +consistent systems over adding decoration. + +At every screenshot checkpoint, critique the rendered paywall against this +rubric and correct visible issues with focused edits: + +- **Hierarchy and story:** the value proposition reads first, supporting proof + and benefits scan naturally, and the primary CTA is unmistakably dominant. +- **Offer clarity:** product options, price/period, trial or billing terms, savings, + and the selected state are understandable without guesswork. +- **Composition:** margins, gaps, alignment, radii, and repeated structures follow + a consistent rhythm; no section feels accidentally crowded or empty. +- **Typography and color:** the type scale has clear roles, copy wraps cleanly, + contrast is legible, and accents are reserved for meaningful emphasis. +- **Viewport fit:** no unintended clipping or horizontal overflow; safe areas are + respected; the CTA and essential offer information are reachable and sensibly + placed on the 375×812 screen. +- **Purchase affordances:** selectable options look selectable, the CTA looks + tappable, and close, restore, legal, and subscription-term affordances are + present when appropriate. +- **Polish:** imagery, icons, borders, shadows, and states render cleanly with no + placeholders, awkward truncation, inconsistent styling, or accidental defaults. + +Do not merely describe a problem found in the screenshot. Fix it, capture a new +screenshot, and repeat until the review has no unresolved issues. Then call +\`finish_paywall_edit\` with the final screenshot's document signature. + +### Op cheatsheet + +- \`insert\` — add a subtree under \`parentId\` at \`index\` (append if omitted). +- \`update\` — partial data change on \`nodeId\` (\`set\`, merge semantics above). +- \`move\` — reparent \`nodeId\` under \`parentId\` at \`index\` (cycles/illegal + containment rejected). +- \`remove\` — delete \`nodeId\` and its subtree. +- \`replaceChildren\` — swap \`nodeId\`'s whole child list for a new ordered one. + +## Worked examples + +### (a) Insert a screen's content in one batch + +Add a header row and a title inside the screen \`scr_1\` (ids returned are used +implicitly by nesting the \`children\` inline): + +\`\`\`json +{ "edits": [ + { "op": "insert", "parentId": "scr_1", "node": { + "type": "view", "name": "Header", + "style": { "flexDirection": "row", "justifyContent": "space-between", "alignItems": "center", "paddingTop": 16, "paddingBottom": 16 }, + "children": [ + { "type": "text", "name": "Title", "text": "Unlock Pro", + "style": { "fontSize": 28, "fontWeight": "700", "color": "rgba(255,255,255,1)" } } + ] + } } +] } +\`\`\` + +### (b) Restyle the selected node + +The user has \`view_9\` selected and asks for a rounded indigo card. Change only +the fields you mean to (style merges per field): + +\`\`\`json +{ "edits": [ + { "op": "update", "nodeId": "view_9", "set": { "style": { + "backgroundColor": "rgba(99,102,241,1)", + "borderTopLeftRadius": 12, "borderTopRightRadius": 12, + "borderBottomRightRadius": 12, "borderBottomLeftRadius": 12, + "paddingTop": 16, "paddingRight": 16, "paddingBottom": 16, "paddingLeft": 16 } } } +] } +\`\`\` + +### (c) Insert a component instance and bind it + +First \`get_components\` to read the component's path/slug, props, and actions. +Then insert a \`component\` node. A \`component\` node's identity fields select which +component to place; \`props\` binds its inputs and interactions/actions wire its +outputs. Read the exact prop/action names from \`get_components\` (they differ per +component) and follow the shapes the tool reports — do not assume field names. +Bind a purchase to the component's declared action via the same interaction model +as (d): a \`purchase-product\` action on the component's action slot. + +### (d) Variables, states, and actions (dynamic behavior without code) + +These three first-class document fields form a small no-code state machine: + +1. **Variables hold runtime state.** Put \`localVariables\` on a common ancestor so + the node and its descendants can read them. Supported values are + \`{ key: "string", value }\`, \`{ key: "number", value }\`, + \`{ key: "boolean", value }\`, and + \`{ key: "product", value: { productId? } }\`. Variables may live on + \`screen\`, \`view\`, \`scrollView\`, \`text\`, \`shape\`, or \`path\`; lookup is + lexical (own node, then nearest ancestor). Declare shared state on the nearest + common ancestor and reference its stable \`id\`, never its display \`name\`. +2. **Actions change state or call the host.** A \`view\` or \`scrollView\` has an + \`interactions\` array of \`{ id, trigger: { type: "click" }, action }\`. The + action is \`none\`, \`close-paywall\`, \`set-variable\`, or \`purchase-product\`. + Values/products can be literals or variable references. A \`component\` exposes + named outputs through \`actionBindings\`; bind those names (from + \`get_components\`) to the same actions, optionally reading a field from the + component's emitted payload with \`{ type: "action-payload", ... }\`. +3. **States react to variables.** Stateful nodes have a \`states\` array. Each state + has \`{ id, name, condition, overrides }\`. A condition is DNF: + \`{ type: "or", value: [ { type: "and", value: [predicates...] } ] }\` — any + OR branch may match, while every predicate in an AND branch must match. + Predicates are \`equals\`, \`not-equals\`, \`greater-than\`, + \`greater-than-or-equal\`, \`less-than\`, or \`less-than-or-equal\`; each operand + is a typed literal or \`{ type: "variable-reference", value: { id } }\`. + Matching states merge \`overrides.style\` over the base style immediately after + a variable changes. Later matching states win conflicting fields. On \`view\` + and \`scrollView\`, a state may also replace an interaction's action through + \`overrides.actions\`; its \`interactionId\` must be the interaction array-entry + id returned by \`get_paywall\`. State style overrides do not auto-enable gated + groups, so include \`backgroundEnabled\`, \`borderEnabled\`, \`shadowEnabled\`, + \`fillEnabled\`, or \`strokeEnabled\` when an override first turns on that group. + +This is enough for product selection, toggles, conditional emphasis/visibility, +multi-step sections, close buttons, and a CTA that purchases the selected product +without TSX. Build the cycle as **declare variable → click action updates variable +→ matching state changes appearance/behavior → CTA consumes variable**. + +Example: declare the selected product on the screen, make a yearly option select +it and show its selected style, then have the CTA purchase the current selection. +Repeat the option action/state for each other product id: + +\`\`\`json +{ "edits": [ + { "op": "update", "nodeId": "scr_1", "set": { "localVariables": [ + { "id": "selected_product", "name": "Selected product", + "value": { "key": "product", "value": { "productId": "yearly" } } } + ] } }, + { "op": "update", "nodeId": "view_yearly", "set": { + "interactions": [ + { "id": "select_yearly", "trigger": { "type": "click" }, + "action": { "type": "set-variable", "payload": { + "variableId": "selected_product", + "newValue": { "type": "literal", "value": { + "key": "product", "value": { "productId": "yearly" } + } } + } } + } + ], + "states": [ + { "id": "yearly_selected", "name": "Selected", "condition": { + "type": "or", "value": [ + { "type": "and", "value": [ + { "type": "equals", "value": { + "left": { "type": "variable-reference", "value": { "id": "selected_product" } }, + "right": { "type": "literal", "value": { + "key": "product", "value": { "productId": "yearly" } + } } + } } + ] } + ] + }, "overrides": { "style": { + "backgroundEnabled": true, + "backgroundColor": "rgba(99,102,241,0.16)", + "borderEnabled": true, + "borderColor": "rgba(99,102,241,1)", + "borderTopWidth": 2, "borderRightWidth": 2, + "borderBottomWidth": 2, "borderLeftWidth": 2 + } } + } + ] + } }, + { "op": "update", "nodeId": "view_cta", "set": { "interactions": [ + { "id": "purchase_selected", "trigger": { "type": "click" }, + "action": { "type": "purchase-product", "payload": { + "type": "variable-reference", "variableId": "selected_product" + } } + } + ] } } +] } +\`\`\` + +Arrays replace wholesale on \`update\`, so preserve every existing variable, +interaction, state, prop binding, or action binding you still need. The \`id\` +fields inside these arrays are stable logical ids for sub-records, not node ids. + +## Doctrine: build with the document, reach for code only when you must + +**The document (\`edit_paywall\`) is ALWAYS the primary way to build a paywall.** +Static layout, styling, literal text, local variables, click/component actions, +and variable-driven style/visibility states all belong in the document. Write a +code component ONLY when the document genuinely cannot express what you need: + +- **Mapping/looping** — one row per product, or any list built by iterating over + runtime data (\`usePaywallProducts()\`). +- **Structural branching** — different children/content that cannot be expressed + by a variable-driven style state (simple conditional visibility via \`display\` + belongs in document states). +- **Transient pressed / hover / focus pseudo-states** — document states are driven + by variables, not pointer/gesture lifecycle. +- **Animation.** +- **Custom formatting logic** — deriving "$5/mo billed yearly", percent savings, + trial copy. +- **Runtime-data-bound text** — a \`text\` node renders a LITERAL string; it cannot + interpolate a product price or a variable. Data-driven text needs a component. + +### Decision checklist + +1. Static layout + literal text? → **document** (\`screen\`/\`view\`/\`text\` nodes). +2. Selection, toggle, conditional styling/visibility, close, or purchase flow? → + **document variables + states + actions.** +3. Text needs runtime data (price, count, formatting)? → code component for that + text only. +4. A list produced by iterating products/data? → code component (map). +5. Structural branching not expressible with state style overrides? → code component. +6. Pressed/hover/animated visuals? → code component. +7. Otherwise → **document.** Prefer building with nodes. + +When a code component IS needed, keep it **as small as possible** — one focused +interactive widget — then place it as a \`component\` node in the document. + +## Code-component authoring (\`components/.tsx\`) + +A code component is a normal React component defined with \`defineComponent\` from +\`@voidhash/paywalls\`. Its identity is its path; a \`component\` node references it by +that path. Primitives, hooks, and full \`StyleProp\` arrays are all available here +(this is the escape hatch — real JS is allowed). Author it with \`write_component\`; +on success it compiles and becomes placeable, on failure you get compile +diagnostics to fix (the compile loop is internal to \`write_component\`). + +\`\`\`tsx +import { + defineComponent, + View, + Text, + Pressable, + Slot, + useSelectedProduct, +} from "@voidhash/paywalls"; +import type { PaywallProduct } from "@voidhash/paywalls"; + +export default defineComponent({ + title: "Product Option", + description: "A selectable pricing row.", + props: (p) => ({ + product: p.ref("product"), + accentColor: p.string().editor("color").default("rgba(99, 102, 241, 1)"), + }), + actions: (a) => ({ + onSelect: a.action({ product: a.string() }), + }), + previews: { + default: { + data: { + products: [ + { + id: "yearly", + slug: "yearly", + displayName: "Yearly", + priceString: "$59.99", + period: "year", + }, + ], + }, + }, + }, + render: ({ props, actions }) => { + const { selectedProductId } = useSelectedProduct(); + const isSelected = selectedProductId === props.product.id; + return ( + actions.onSelect({ product: props.product.id })}> + + + {props.product.displayName} + + + {props.product.priceString} + + + + + ); + }, +}); +\`\`\` + +### \`defineComponent\` shape + +\`defineComponent({ title?, description?, props?, actions?, previews?, panel?, render })\`. +There is **NO \`id\`** — a component is identified by its FILE PATH +(\`components/.tsx\`), which is the path a \`component\` node references. + +- \`render: ({ props, actions }) => ReactNode\` — the template. \`props\` has + defaults filled and \`.optional()\` props typed \`T | undefined\`; \`actions\` are + typed callbacks. +- Preview-state \`data\` accepts \`products\`, \`variables\`, \`platform\`, + \`safeAreaInsets\`, and \`dimensions\` for deterministic runtime-hook branches. +- The file MUST default-export the \`defineComponent({ … })\` definition itself — + \`export default defineComponent({ … })\`. Do NOT export a \`.component\` property or + any other shape — only the \`defineComponent(...)\` result is valid. + +### Prop builders (\`p\`) + +Chainable, immutable: \`.label(str)\`, \`.default(value)\`, \`.optional()\`, and +\`.editor("color")\` (string props only). + +- \`p.string()\` — string. \`p.number()\`, \`p.boolean()\`. +- \`p.select([...] as const)\` — a string constrained to the options. +- \`p.image()\` — an image URL/asset reference (string). +- \`p.ref("product")\` — a product reference; the template receives a + \`PaywallProduct\`. +- \`p.component()\` — a nested element the editor fills; the template receives a + \`ReactNode\`. +- \`p.array(item)\` — a homogeneous list; \`item\` must be a non-array builder. + +### Action builders (\`a\`) + +- \`a.action()\` — a payload-free action; template callback is \`() => void\`. +- \`a.action({ field: a.string() })\` — a typed payload; template callback is + \`(payload) => void\`. Payload fields are scalars: \`a.string()\`, \`a.number()\`, + \`a.boolean()\`. + +### Primitives (\`@voidhash/paywalls\`) + +\`View\`, \`Text\`, \`Pressable\`, \`ScrollView\`, \`Image\`, \`Slot\`. \`style\` accepts a +\`StyleProp\` — a single object OR an array with falsy entries ignored +(\`style={[base, cond && override]}\`) — using the SAME RN style vocabulary as the +document (plus text-only \`textTransform\`/\`textDecorationLine\`/\`fontStyle\`/\`fontFamily\`). + +- \`\` — the tap surface. Pass a declared action directly + (\`onPress={actions.onSelect}\`) or wrap it (\`onPress={() => actions.onSelect({…})}\`). +- \`\`. +- \`\` renders children the document passed into the component instance; + \`\` renders fallback when none were passed. + +### Runtime hooks (\`@voidhash/paywalls\`) + +- \`usePaywallProducts(): readonly PaywallProduct[]\` — the available products + (map over these to render a list). +- \`useSelectedProduct(): { selectedProduct, selectedProductId, selectProduct }\`. +- \`usePaywallVariables(): Record\`. +- \`usePlatform(): "ios" | "android" | "web"\`. +- \`useSafeAreaInsets(): { top, right, bottom, left }\` in logical pixels. +- \`useDimensions("screen" | "window"): { width, height, x, y }\` in logical pixels; + \`x\` and \`y\` are screen-space origins. +- \`usePaywallActions(): { purchase, restore, close, openUrl, track, selectProduct }\`. +- \`usePaywallStatus(): { status, productId?, error? }\` — + \`idle\`/\`purchasing\`/\`purchased\`/\`restoring\`/\`restored\`/\`failed\`. +- \`usePaywallConfig(): PaywallRuntimeConfig\` (products, variables, locale, + platform, safe-area insets, and dimensions). + +Explicit runtime or preview environment values take precedence and late configuration updates +consumers. Browser measurements react to resize, orientation, and visual-viewport events. +Missing platform defaults to \`web\`; unavailable safe-area and SSR measurements use zero. + +\`PaywallProduct\`: +\`{ id, slug, displayName, description?, price?, priceString, currencyCode?, period?, trialPeriod? }\` +where \`period\` is \`"month" | "year" | "week" | "lifetime"\`. + +### Renaming and deleting components + +- \`rename_component(fromPath, toPath)\` — moves the component. Instances + referencing the old path are RE-POINTED automatically (rename cascade), so the + \`component\` nodes keep working. +- \`delete_component(path)\` — removes the component. Existing instances degrade to + placeholders (they are NOT cascade-deleted), so replace or remove those + \`component\` nodes afterward. + +## Tool vocabulary + +- \`list_paywalls()\` — discover stable paywall ids (plus display slugs) in the project. +- \`begin_paywall_edit({ paywallId })\` — capture the revert baseline and return a + \`editSessionId\`. +- \`get_paywall({ editSessionId, nodeId?, depth? })\` — read cleaned document JSON (subtree + + depth for economy). +- \`get_components({ editSessionId })\` — list every placeable catalog, local, and builtin + component with its authoring contract. +- \`read_component({ editSessionId, path })\` — read a local code component's TSX source. +- \`edit_paywall({ editSessionId, edits })\` — apply an atomic batch of document + ops (returns minted ids or per-op errors). +- \`duplicate_subtree({ editSessionId, nodeId, parentId, index? })\` — clone a + visual subtree with fresh ids. +- \`write_component({ editSessionId, path, source })\` — create or replace a + compiled code component. +- \`rename_component({ editSessionId, fromPath, toPath })\` — move a component + and re-point its instances. +- \`delete_component({ editSessionId, path })\` — delete a component; existing + instances degrade to placeholders. +- \`get_paywall_preview({ editSessionId })\` — render the version-bound PNG + used for visual QA. +- \`finish_paywall_edit({ editSessionId, reviewedDocumentSignature, verdict, + unresolvedIssues: [] })\` — accept a visually verified edit. +- \`revert_paywall_edit({ editSessionId })\` — restore the captured baseline when that can + be done without overwriting interleaved participant work. A mutation-free session closes + without changing the document. + +Read (\`get_paywall\`/\`get_components\`) before you write, use the minted ids the +edits return, and trust the field/value errors the validator gives you. +`); diff --git a/apps/backend/src/ai/skills/registry.ts b/apps/backend/src/ai/skills/registry.ts new file mode 100644 index 000000000..513476bbc --- /dev/null +++ b/apps/backend/src/ai/skills/registry.ts @@ -0,0 +1,48 @@ +import type { SkillSource } from "@voidhash/agent/SkillSource"; + +import { componentAuthoringSkill } from "./component-authoring.ts"; +import { paywallAuthoringSkill } from "./paywall-authoring.ts"; + +/** A lazily materialized skill delivered through every agent channel. */ +export interface SkillDefinition { + readonly name: string; + readonly description: string; + readonly body: () => string; +} + +const definitions: ReadonlyArray = [ + { + name: "paywall-authoring", + description: + "Design, edit, preview, visually review, finish, or revert a Voidhash paywall with the shared workspace tools.", + body: paywallAuthoringSkill, + }, + { + name: "code-component-authoring", + description: + "Create, edit, debug, and place Voidhash code components, including typed props and actions, preview fixtures, custom designer panels, animation, motion values, scroll-linked effects, drag gestures, and gesture-aware panel controls. Use when a paywall needs runtime data, loops, structural logic, custom formatting, pressed/focus states, animation, dragging, or a tailored component-properties panel, or when working with read_component, write_component, rename_component, or delete_component.", + body: componentAuthoringSkill, + }, +]; + +/** Lists every registered skill without evaluating its body. */ +export const listSkills = (): ReadonlyArray => definitions; + +/** Looks up one registered skill by its stable name. */ +export const findSkill = (name: string): SkillDefinition | undefined => + definitions.find((definition) => definition.name === name); + +/** Creates the non-filesystem skill source consumed by the Pi agent. */ +export const registeredSkillSource = (): SkillSource => ({ + list: () => definitions.map(({ name, description }) => ({ name, description })), + read: (name) => findSkill(name)?.body(), +}); + +/** Stable MCP resource URI for one registered skill. */ +export const skillResourceUri = (name: string): string => `voidhash://skills/${name}`; + +/** Resolves a registered skill from its MCP resource URI. */ +export const skillFromResourceUri = (uri: string): SkillDefinition | undefined => { + const prefix = "voidhash://skills/"; + return uri.startsWith(prefix) ? findSkill(uri.slice(prefix.length)) : undefined; +}; diff --git a/apps/backend/src/ai/surfaces.test.ts b/apps/backend/src/ai/surfaces.test.ts new file mode 100644 index 000000000..c4a9b72e6 --- /dev/null +++ b/apps/backend/src/ai/surfaces.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { designerAgentSystemPrompt } from "./surfaces.ts"; + +/** + * Cover the dynamic designer context block appended to the system prompt: the + * base prompt without context, the paywall listing (slug + name + code + * components), the open-paywall block + selection (document node ids), and the + * no-paywall-open degradation. The vocabulary is document-first — edits are live + * `edit_paywall` ops, not a `paywall.tsx` fork/apply. + */ +describe("designerAgentSystemPrompt", () => { + const base = designerAgentSystemPrompt(); + + it("returns the base prompt unchanged when no context is supplied", () => { + expect(base).toContain("You are Voidhash AI"); + expect(base).not.toContain("Current context:"); + expect(base).not.toContain("Document model and authorable tree"); + }); + + it("frames editing as live, undoable document transactions (no fork/apply)", () => { + expect(base).toContain("edit_paywall"); + expect(base).toContain("live paywall"); + expect(base).toContain("UNDOABLE"); + // The old composition-as-a-file model is gone (no `paywall.tsx` grammar). + expect(base).not.toContain("paywall.tsx"); + // The prompt explicitly negates the old apply step rather than describing one; + // the only place `apply_changes` appears is that negation ("no apply_changes"). + expect(base).not.toContain("call `apply_changes`"); + expect(base).not.toContain("validate_changes"); + }); + + it("carries the agentic How-to-work discipline in the base prompt", () => { + expect(base).toContain("How to work:"); + expect(base).toContain("Keep going until the user's request is fully handled"); + expect(base).toContain("compact visual direction"); + expect(base).toContain("get_paywall_preview"); + expect(base).toContain("finish_paywall_edit"); + expect(base).toContain("Never claim completion without a successful"); + }); + + it("lists every paywall with its display name and code components", () => { + const prompt = designerAgentSystemPrompt({ + paywalls: [ + { + paywallId: "pw_1", + slug: "trial", + name: "Trial", + componentFileNames: ["hero.tsx", "cta.tsx"], + }, + { paywallId: "pw_2", slug: "onboarding", name: "Onboarding", componentFileNames: [] }, + ], + openPaywall: { paywallId: "pw_1", slug: "trial", name: "Trial" }, + selectedNodeIds: [], + }); + expect(prompt).toContain( + '- pw_1 ("Trial", slug "trial"): components: components/hero.tsx, components/cta.tsx', + ); + expect(prompt).toContain('- pw_2 ("Onboarding", slug "onboarding"): no code components'); + }); + + it("names the open paywall and points at edit_paywall for unqualified refs", () => { + const prompt = designerAgentSystemPrompt({ + paywalls: [{ paywallId: "pw_1", slug: "trial", name: "Trial", componentFileNames: [] }], + openPaywall: { paywallId: "pw_1", slug: "trial", name: "Trial" }, + selectedNodeIds: [], + }); + expect(prompt).toContain('has the "Trial" paywall (id "pw_1", slug "trial") open'); + expect(prompt).toContain('paywallId: "pw_1"'); + expect(prompt).toContain("edit_paywall"); + expect(prompt).toContain('"this paywall"'); + // No workspace path for the open paywall in the document-first model. + expect(prompt).not.toContain("/paywalls/trial/paywall.tsx"); + }); + + it("renders the selection as directly-addressable document node ids (plural)", () => { + const prompt = designerAgentSystemPrompt({ + paywalls: [{ paywallId: "pw_1", slug: "trial", name: "Trial", componentFileNames: [] }], + openPaywall: { paywallId: "pw_1", slug: "trial", name: "Trial" }, + selectedNodeIds: ["node_a", "node_b"], + }); + expect(prompt).toContain("nodes with id node_a, node_b selected"); + expect(prompt).toContain("edit_paywall"); + }); + + it("uses the singular for a single selected node", () => { + const prompt = designerAgentSystemPrompt({ + paywalls: [{ paywallId: "pw_1", slug: "trial", name: "Trial", componentFileNames: [] }], + openPaywall: { paywallId: "pw_1", slug: "trial", name: "Trial" }, + selectedNodeIds: ["node_a"], + }); + expect(prompt).toContain("node with id node_a selected"); + }); + + it("states no paywall is open when none matches", () => { + const prompt = designerAgentSystemPrompt({ + paywalls: [{ paywallId: "pw_1", slug: "trial", name: "Trial", componentFileNames: [] }], + openPaywall: undefined, + selectedNodeIds: [], + }); + expect(prompt).toContain("does not have a specific paywall open"); + }); +}); diff --git a/apps/backend/src/ai/surfaces.ts b/apps/backend/src/ai/surfaces.ts new file mode 100644 index 000000000..1f523e6ee --- /dev/null +++ b/apps/backend/src/ai/surfaces.ts @@ -0,0 +1,44 @@ +import { renderDesignerContext, type DesignerContext } from "./DesignerContext.ts"; + +const DESIGNER_SYSTEM_PROMPT = `You are Voidhash AI, an autonomous design agent embedded in the Voidhash paywall designer. You help the user build and edit the paywall they currently have open. A paywall is a live MIMIC DOCUMENT — a tree of nodes (screen, view, text, shape, path, component) — that you author with document-edit operations, plus optional code components for anything that needs real code. + +Scope: +- You edit ONLY the currently open paywall's document, with \`edit_paywall\`. You address nodes by their document node id (from \`get_paywall\` or the user's current selection). +- Code components are the escape hatch for genuine code (loops, conditionals, pressed states, runtime-data text). A component's identity is its path \`components/.tsx\`: \`write_component\` creates-or-replaces it, \`rename_component\` moves it, \`delete_component\` removes it. +- You can READ any other paywall in the project for reference with \`get_paywall\` (e.g. "make this paywall match the onboarding one" — read onboarding, then edit the open paywall). You CANNOT edit another paywall. + +Editing model — edits are LIVE: +- Every \`edit_paywall\` batch and every component tool applies IMMEDIATELY to the live paywall as a mimic transaction. The user sees the change on the canvas at once, and it is UNDOABLE from their history. There is NO build/apply/publish step for composition — no fork, no \`apply_changes\`. You edit the document directly. +- \`edit_paywall\` is ATOMIC per batch: either every op in the batch applies or none does. On success it returns the engine-minted ids of any inserted nodes (keyed by op index) so you can address the new nodes in a follow-up. On failure it returns per-op errors naming the offending node, the allowed fields (with a did-you-mean), and the allowed values — read them and correct the batch. Nothing partial lands. +- Node ids are engine-minted. NEVER supply an id when inserting; use the returned minted ids (or ids from \`get_paywall\`/the selection) to target follow-up edits. +- \`write_component\` DOES have a compile step: on success the component compiles, commits, and becomes placeable as a \`component\` node; on failure you get compile diagnostics to fix. That loop is internal to \`write_component\` — it is the ONLY place a build gate exists. + +Attachments: +- The user may attach images (e.g. a screenshot or mockup of the paywall they want) or paste content. When an image is attached, use it as the visual reference to reproduce. Match layout, colours, copy, and spacing as closely as the node/style system allows. + +Rules: +- Read before you write. Call \`get_paywall\` (root, or a subtree with \`nodeId\`/\`depth\` for economy) to learn the current structure and ids before editing — explore, don't guess. Call \`get_components\` before inserting a \`component\` node so you bind its props/actions correctly. +- Trust the validator. Field/value errors from \`edit_paywall\` name the allowed fields and values for that node type — iterate on them rather than guessing style keys. +- For broad creation or redesign requests, establish a compact visual direction before editing: audience and offer, mood, palette, typography scale, spacing rhythm, content hierarchy, and primary CTA treatment. Make decisive, coherent design choices when the user has not specified them. +- Prefer the smallest change. Update only the fields you mean to change (\`update\` merges \`style\` per field). Reuse existing components and use \`duplicate_subtree\` for repeated visual groups. Reach for a code component ONLY when the document grammar genuinely cannot express what you need (see the authoring reference). +- Keep your prose responses concise. The user watches the designer live-update as you edit, so narrate only what matters and avoid restating full node trees back to them. + +How to work: +- Understand the request first, then \`get_paywall\` to see the current tree and ids. Read a sibling paywall with \`get_paywall\` when it is a useful reference. +- Plan multi-step work. When a request needs several changes (new components, structural edits), lay out the steps and make them in order — batch related ops into one \`edit_paywall\` call where it is coherent (they apply atomically). Work one meaningful visual group at a time so each checkpoint is easy to assess and correct. +- Edit step by step; after each \`edit_paywall\`, read the returned minted ids / errors and correct before continuing. Author components (\`write_component\`) and fix their compile diagnostics BEFORE inserting the \`component\` nodes that reference them. +- Verify structure with \`get_paywall\`. After every meaningful visual section, call \`get_paywall_preview\`, inspect the actual render, state a short internal verdict, and make targeted corrections for visible issues before moving on. +- Completion is gated by visual QA. After the final edit, capture a fresh full-screen \`get_paywall_preview\`, review it against the paywall rubric in the authoring reference, make and re-review any fixes, then call \`finish_paywall_edit\` with that screenshot's exact document signature and no unresolved issues. Never claim completion without a successful \`finish_paywall_edit\`. +- Keep going until the user's request is fully handled. Do not stop halfway or hand a partial result back because the task is long — finish it. Only end your turn when the work is done and verified, or when you genuinely need a decision from the user.`; + +/** + * Compact Pi system prompt. Domain instructions are disclosed separately via + * the skill registry and `read_skill`, avoiding a large static prompt on every + * provider request. + */ +export const designerAgentSystemPrompt = (context?: DesignerContext): string => + context === undefined + ? DESIGNER_SYSTEM_PROMPT + : `${DESIGNER_SYSTEM_PROMPT}${renderDesignerContext(context)}`; + +export type { DesignerContext } from "./DesignerContext.ts"; diff --git a/apps/backend/src/ai/tools.test.ts b/apps/backend/src/ai/tools.test.ts new file mode 100644 index 000000000..d86dddf66 --- /dev/null +++ b/apps/backend/src/ai/tools.test.ts @@ -0,0 +1,712 @@ +import { + ComponentCompiler, + ComponentManifestCacheService, + componentServingPreviewKey, + PaywallArtifactStore, + PaywallDeployService, + PaywallEditSessionService, + PaywallWorkspaceService, + SnapshotImageRenderer, +} from "@voidhash/core/services"; +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { PaywallNotFoundError } from "@voidhash/core/domain/paywall/Paywall"; +import { Context, Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import * as WorkspaceTools from "./workspace-tools.ts"; + +/** + * Drive the stateful, document-first MCP workspace-tool core directly against a + * mocked workspace/deploy/compiler context. These tools read and edit the live + * document through an explicit edit-session connection; every one folds an + * expected service failure into a readable message rather than throwing. + */ + +const SCOPE: WorkspaceTools.WorkspaceToolScope = { projectId: "proj_1" }; +const EDIT_SESSION_ID = "pw_edit_1"; + +/** A decoded document root: root → screen → view(with a text), and a library with one component. */ +const documentRoot = { + id: "root1", + type: "root", + parentId: null, + pos: "a0", + data: { name: "Paywall" }, + children: [ + { + id: "screen1", + type: "screen", + parentId: "root1", + pos: "a0", + data: {}, + children: [ + { + id: "view1", + type: "view", + parentId: "screen1", + pos: "a0", + data: {}, + children: [ + { + id: "text1", + type: "text", + parentId: "view1", + pos: "a0", + data: { text: "Hi" }, + children: [], + }, + ], + }, + ], + }, + { + id: "lib1", + type: "library", + parentId: "root1", + pos: "a1", + data: {}, + children: [ + { + id: "cc1", + type: "codeComponent", + parentId: "lib1", + pos: "a0", + data: { path: "components/hero.tsx", source: "export const Hero = () => null;" }, + children: [], + }, + ], + }, + ], +}; + +const fakeWorkspace = (over: Partial = {}) => + ({ + listPaywalls: () => Effect.succeed([{ slug: "trial", paywallId: "pw_1" }]), + readDocument: (_p: string, slug: string) => + Effect.succeed({ + slug, + name: "Trial", + paywallId: "pw_1", + tree: { encoded: true }, + root: documentRoot, + version: 8, + }), + readDocumentTree: (paywallId: string) => + paywallId === "pw_1" + ? Effect.succeed({ tree: { encoded: true }, root: documentRoot, version: 8 }) + : Effect.fail( + new PaywallNotFoundError({ message: `No paywall with id "${paywallId}"` }), + ), + readConnectedDocumentTree: () => + Effect.succeed({ tree: { encoded: true }, root: documentRoot, version: 8 }), + editDocument: () => + Effect.succeed({ version: 9, commandCount: 2, mintedIds: { "0": ["abc1234567"] } }), + editConnectedDocument: () => + Effect.succeed({ version: 9, commandCount: 2, mintedIds: { "0": ["abc1234567"] } }), + writeComponentSource: () => Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + writeConnectedComponentSource: () => + Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + moveComponentFile: () => Effect.succeed({ version: 9, commandCount: 2, diagnostics: [] }), + moveConnectedComponentFile: () => + Effect.succeed({ version: 9, commandCount: 2, diagnostics: [] }), + deleteComponentFile: () => Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + deleteConnectedComponentFile: () => + Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + ...over, + }) as unknown as PaywallWorkspaceService["Service"]; + +const fakeDeploy = (over: Partial = {}) => + ({ + listComponents: () => Effect.succeed([]), + ...over, + }) as unknown as PaywallDeployService["Service"]; + +const fakeManifestCache = (over: Partial = {}) => + ({ + getMany: () => Effect.succeed(new Map()), + record: () => Effect.void, + ...over, + }) as unknown as ComponentManifestCacheService["Service"]; + +const fakeCompiler = (over: Partial = {}) => + ({ + compileCheck: () => Effect.succeed({ status: "unavailable" as const }), + compileAndExtract: () => Effect.succeed({ status: "unavailable" as const }), + ...over, + }) as unknown as ComponentCompiler["Service"]; + +const fakeEditSessions = (over: Partial = {}) => + ({ + begin: () => + Effect.succeed({ + editSessionId: EDIT_SESSION_ID, + paywallId: "pw_1", + baselineVersion: 8, + }), + connectActive: () => + Effect.succeed({ + editSessionId: EDIT_SESSION_ID, + projectId: "proj_1", + paywallId: "pw_1", + paywallSlug: "trial", + baselineVersion: 1, + }), + recordPreview: () => Effect.void, + recordMutation: () => Effect.void, + finish: () => Effect.succeed({ editSessionId: EDIT_SESSION_ID, status: "finished" as const }), + revert: () => Effect.succeed({ version: 9, commandCount: 2, paywallSlug: "trial" }), + ...over, + }) as unknown as PaywallEditSessionService["Service"]; + +const fakeArtifactStore = () => + ({ getObject: () => Effect.succeed(null) }) as unknown as PaywallArtifactStore["Service"]; + +const fakeRenderer = () => + ({ render: () => Effect.succeed(new Uint8Array([1, 2, 3])) }) as SnapshotImageRenderer["Service"]; + +interface Fakes { + readonly workspace?: PaywallWorkspaceService["Service"]; + readonly deploy?: PaywallDeployService["Service"]; + readonly manifestCache?: ComponentManifestCacheService["Service"]; + readonly compiler?: ComponentCompiler["Service"]; + readonly editSessions?: PaywallEditSessionService["Service"]; + readonly artifactStore?: PaywallArtifactStore["Service"]; + readonly renderer?: SnapshotImageRenderer["Service"]; +} + +const run = ( + effect: Effect.Effect< + WorkspaceTools.WorkspaceToolResult, + never, + WorkspaceTools.WorkspaceToolDeps + >, + fakes: Fakes = {}, +): Promise => + Effect.runPromise( + effect.pipe( + Effect.provide( + Context.empty().pipe( + Context.add(PaywallWorkspaceService, fakes.workspace ?? fakeWorkspace()), + Context.add(PaywallDeployService, fakes.deploy ?? fakeDeploy()), + Context.add(ComponentManifestCacheService, fakes.manifestCache ?? fakeManifestCache()), + Context.add(ComponentCompiler, fakes.compiler ?? fakeCompiler()), + Context.add(PaywallEditSessionService, fakes.editSessions ?? fakeEditSessions()), + Context.add(PaywallArtifactStore, fakes.artifactStore ?? fakeArtifactStore()), + Context.add(SnapshotImageRenderer, fakes.renderer ?? fakeRenderer()), + Context.add(AuthSession, {} as never), + ), + ), + ), + ); + +describe("MCP workspace tools (document-first)", () => { + it("begin_paywall_edit returns the edit-session capability and captured baseline", async () => { + const result = await run(WorkspaceTools.beginPaywallEdit(SCOPE, { paywallId: "pw_1" })); + expect(result.isError, result.output).toBe(false); + expect(result.output).toContain(EDIT_SESSION_ID); + expect(JSON.parse(result.output)).toMatchObject({ baselineVersion: 8, paywallId: "pw_1" }); + }); + + it("list_paywalls formats the project's paywalls", async () => { + const result = await run(WorkspaceTools.listPaywalls(SCOPE)); + expect(result.isError, result.output).toBe(false); + expect(result.output).toContain("trial"); + expect(result.output).toContain("/paywalls/trial"); + }); + + it("bash reads projected documents and component sources over the VFS", async () => { + const result = await run( + WorkspaceTools.runBash(SCOPE, { + command: "ls /paywalls && cat /paywalls/pw_1/components/hero.tsx", + }), + ); + expect(result.isError, result.output).toBe(false); + expect(result.output).toContain("pw_1"); + expect(result.output).toContain("export const Hero"); + }); + + it("bash reports a non-zero exit code as a successful result", async () => { + const result = await run( + WorkspaceTools.runBash(SCOPE, { + command: "grep no-such-token /paywalls/pw_1/document.json", + }), + ); + expect(result.isError, result.output).toBe(false); + expect(result.output).toContain("[exit code 1]"); + }); + + it("bash surfaces a workspace service failure as isError", async () => { + const result = await run( + WorkspaceTools.runBash(SCOPE, { command: "ls /paywalls" }), + { + workspace: fakeWorkspace({ + listPaywalls: () => Effect.fail(new Error("db down")) as never, + }), + }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("db down"); + }); + + it("get_paywall returns cleaned document JSON with node ids", async () => { + const result = await run(WorkspaceTools.getPaywall(SCOPE, { editSessionId: EDIT_SESSION_ID })); + expect(result.isError).toBe(false); + expect(result.output).toContain('"id": "root1"'); + expect(result.output).toContain('"id": "view1"'); + }); + + it("get_paywall(nodeId) roots the tree at a subtree", async () => { + const result = await run( + WorkspaceTools.getPaywall(SCOPE, { editSessionId: EDIT_SESSION_ID, nodeId: "view1" }), + ); + expect(result.isError).toBe(false); + expect(result.output).toContain('"id": "view1"'); + // The sibling library node is not under view1. + expect(result.output).not.toContain('"id": "lib1"'); + }); + + it("get_paywall errors on an unknown nodeId", async () => { + const result = await run( + WorkspaceTools.getPaywall(SCOPE, { editSessionId: EDIT_SESSION_ID, nodeId: "ghost" }), + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("no node"); + }); + + it("get_components lists catalog + local components; a missing manifest is noted", async () => { + const result = await run( + WorkspaceTools.getComponents(SCOPE, { editSessionId: EDIT_SESSION_ID }), + { + deploy: fakeDeploy({ + listComponents: () => + Effect.succeed([ + { + slug: "pricing", + title: "Pricing", + latestVersion: 3, + latest: { manifest: { props: {}, actions: {} }, previewStates: ["default"] }, + previousVersions: [], + componentId: "pc_1", + }, + ]) as never, + }), + // no cached manifest for the local component → "manifest unavailable" note + manifestCache: fakeManifestCache({ getMany: () => Effect.succeed(new Map()) }), + }, + ); + expect(result.isError).toBe(false); + expect(result.output).toContain("pricing"); + expect(result.output).toContain("components/hero.tsx"); + expect(result.output).toContain("manifest unavailable"); + expect(result.output).toContain("Builtin components"); + }); + + it("read_component returns the local component source", async () => { + const result = await run( + WorkspaceTools.readComponent(SCOPE, { + editSessionId: EDIT_SESSION_ID, + path: "components/hero.tsx", + }), + ); + expect(result.isError).toBe(false); + expect(result.output).toBe("export const Hero = () => null;"); + }); + + it("read_component errors on an unknown path and lists what is available", async () => { + const result = await run( + WorkspaceTools.readComponent(SCOPE, { + editSessionId: EDIT_SESSION_ID, + path: "components/ghost.tsx", + }), + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("components/hero.tsx"); + }); + + it("edit_paywall validates then applies, returning minted ids", async () => { + const result = await run( + WorkspaceTools.editPaywall(SCOPE, { + editSessionId: EDIT_SESSION_ID, + edits: [{ op: "insert", parentId: "screen1", node: { type: "view" } }], + }), + ); + expect(result.isError).toBe(false); + expect(result.output).toContain("version 9"); + expect(result.output).toContain("Minted ids"); + expect(result.output).toContain("abc1234567"); + }); + + it("edit_paywall auto-derives backgroundEnabled from a lone backgroundColor", async () => { + // The applied edits are what the workspace commits — capture them and assert the + // group flag was injected by the shared validator's write-side normalization. + let applied: unknown[] = []; + const result = await run( + WorkspaceTools.editPaywall(SCOPE, { + editSessionId: EDIT_SESSION_ID, + edits: [ + { + op: "update", + nodeId: "view1", + set: { style: { backgroundColor: "rgba(1, 2, 3, 1)" } }, + }, + ], + }), + { + workspace: fakeWorkspace({ + editConnectedDocument: ((_p: string, _slug: string, edits: unknown[]) => { + applied = edits; + return Effect.succeed({ version: 9, commandCount: 1, mintedIds: {} }); + }) as never, + }), + }, + ); + expect(result.isError).toBe(false); + const set = (applied[0] as { set: { style: Record } }).set; + expect(set.style.backgroundEnabled).toBe(true); + expect(set.style.backgroundColor).toBe("rgba(1, 2, 3, 1)"); + }); + + it("edit_paywall returns structured validation errors and never applies", async () => { + const editDocument = () => { + throw new Error("editDocument must not run on a validation failure"); + }; + const result = await run( + WorkspaceTools.editPaywall(SCOPE, { + editSessionId: EDIT_SESSION_ID, + // view cannot be a child of text → illegalChild + edits: [{ op: "insert", parentId: "text1", node: { type: "view" } }], + }), + { workspace: fakeWorkspace({ editConnectedDocument: editDocument as never }) }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("edit_paywall rejected"); + expect(result.output).toContain("text"); + }); + + it("edit_paywall folds a conflict into a clean message (no Cause/_tag leak)", async () => { + const result = await run( + WorkspaceTools.editPaywall(SCOPE, { + editSessionId: EDIT_SESSION_ID, + edits: [{ op: "update", nodeId: "text1", set: { text: "Bye" } }], + }), + { + workspace: fakeWorkspace({ + editConnectedDocument: () => + Effect.fail({ _tag: "WorkspaceWriteConflictError", message: "race lost" }) as never, + }), + }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("edit_paywall rejected: race lost"); + expect(result.output).not.toContain("Cause("); + expect(result.output).not.toContain("_tag"); + }); + + it("duplicate_subtree clones a visual subtree through an atomic edit", async () => { + let applied: unknown[] = []; + const result = await run( + WorkspaceTools.duplicateSubtree(SCOPE, { + editSessionId: EDIT_SESSION_ID, + nodeId: "view1", + parentId: "screen1", + nextName: "Copied offer", + }), + { + workspace: fakeWorkspace({ + editConnectedDocument: ((_projectId: string, _slug: string, edits: unknown[]) => { + applied = edits; + return Effect.succeed({ version: 10, commandCount: 1, mintedIds: {} }); + }) as never, + }), + }, + ); + expect(result.isError).toBe(false); + expect(applied).toHaveLength(1); + expect(applied[0]).toMatchObject({ + op: "insert", + parentId: "screen1", + node: { type: "view", name: "Copied offer", children: [{ type: "text", text: "Hi" }] }, + }); + }); + + it("get_paywall_preview returns image content and records its exact document version", async () => { + const recorded: unknown[] = []; + const rendered: unknown[] = []; + const catalogRoot = structuredClone(documentRoot); + const screen = catalogRoot.children[0]!; + (screen.children as unknown[]).push({ + id: "component1", + type: "component", + parentId: "screen1", + pos: "a1", + data: { + componentSource: "catalog", + contentHash: "hash-1", + previewState: "compact", + }, + children: [], + }); + const artifact = (state: string) => ({ + body: new TextEncoder().encode(JSON.stringify({ state, treeVersion: 2 })), + contentType: "application/json", + }); + const result = await run( + WorkspaceTools.getPaywallPreview(SCOPE, { + editSessionId: EDIT_SESSION_ID, + width: 375, + height: 812, + scale: 1, + }), + { + workspace: fakeWorkspace({ + readConnectedDocumentTree: () => + Effect.succeed({ + tree: { encoded: true }, + root: catalogRoot, + version: 8, + }), + }), + editSessions: fakeEditSessions({ + recordPreview: ((input: unknown) => + Effect.sync(() => void recorded.push(input))) as never, + }), + compiler: fakeCompiler({ + compileAndExtract: () => + Effect.succeed({ + status: "ready" as const, + manifest: {}, + previewTrees: { + default: { + treeVersion: 1, + state: "default", + root: { type: "text", text: "Hero", style: {} }, + }, + }, + }), + }), + artifactStore: { + getObject: (key: string) => + key === componentServingPreviewKey("hash-1", "default") + ? Effect.succeed(artifact("default") as never) + : key === componentServingPreviewKey("hash-1", "compact") + ? Effect.succeed(artifact("compact") as never) + : Effect.succeed(null), + } as unknown as PaywallArtifactStore["Service"], + renderer: { + render: (input) => + Effect.sync(() => { + rendered.push(input); + return new Uint8Array([1, 2, 3]); + }), + }, + }, + ); + expect(result.isError, result.output).toBe(false); + expect(result.content).toEqual([ + { type: "text", text: expect.stringContaining('"documentVersion":8') }, + { type: "image", data: "AQID", mimeType: "image/png" }, + ]); + expect(recorded[0]).toMatchObject({ documentVersion: 8 }); + expect(rendered[0]).toMatchObject({ + componentTrees: { + "hash-1": { + default: { state: "default", treeVersion: 2 }, + compact: { state: "compact", treeVersion: 2 }, + }, + }, + localComponentTrees: { + "components/hero.tsx": { + default: { + treeVersion: 1, + state: "default", + root: { type: "text", text: "Hero", style: {} }, + }, + }, + }, + }); + }); + + it("finish_paywall_edit binds the verdict to the current document version and signature", async () => { + const finished: unknown[] = []; + const result = await run( + WorkspaceTools.finishPaywallEdit(SCOPE, { + editSessionId: EDIT_SESSION_ID, + reviewedDocumentSignature: "doc-reviewed", + verdict: "Clear hierarchy and no clipping.", + unresolvedIssues: [], + }), + { + editSessions: fakeEditSessions({ + finish: ((input: unknown) => + Effect.sync(() => { + finished.push(input); + return { id: EDIT_SESSION_ID, status: "finished" as const }; + })) as never, + }), + }, + ); + expect(result.isError, result.output).toBe(false); + expect(finished[0]).toMatchObject({ + currentDocumentVersion: 8, + reviewedDocumentSignature: "doc-reviewed", + verdict: "Clear hierarchy and no clipping.", + }); + expect((finished[0] as { currentDocumentSignature: string }).currentDocumentSignature).toMatch( + /^doc-/, + ); + }); + + it("finish_paywall_edit rejects unresolved visual issues without closing the edit session", async () => { + const finish = () => { + throw new Error("must not finish with unresolved visual issues"); + }; + const result = await run( + WorkspaceTools.finishPaywallEdit(SCOPE, { + editSessionId: EDIT_SESSION_ID, + reviewedDocumentSignature: "doc-reviewed", + verdict: "Needs another pass.", + unresolvedIssues: ["CTA is clipped"], + }), + { editSessions: fakeEditSessions({ finish: finish as never }) }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("CTA is clipped"); + }); + + it("revert_paywall_edit restores the entire captured baseline", async () => { + const result = await run( + WorkspaceTools.revertPaywallEdit(SCOPE, { editSessionId: EDIT_SESSION_ID }), + ); + expect(result.isError, result.output).toBe(false); + expect(result.output).toContain("Reverted edit session"); + expect(result.output).toContain("2 commands"); + }); + + it("write_component rejects a broken component with diagnostics (commits nothing)", async () => { + const writeComponentSource = () => { + throw new Error("must not commit on a compile error"); + }; + const result = await run( + WorkspaceTools.writeComponent(SCOPE, { + editSessionId: EDIT_SESSION_ID, + path: "components/broken.tsx", + source: "export default (=> {", + }), + { + workspace: fakeWorkspace({ writeConnectedComponentSource: writeComponentSource as never }), + compiler: fakeCompiler({ + compileAndExtract: () => + Effect.succeed({ + status: "error" as const, + phase: "compile" as const, + diagnostics: [{ message: "Unexpected token", line: 1, column: 17 }], + }), + }), + }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("Unexpected token"); + }); + + it("write_component commits a clean component and records its manifest", async () => { + const recorded: unknown[] = []; + const result = await run( + WorkspaceTools.writeComponent(SCOPE, { + editSessionId: EDIT_SESSION_ID, + path: "components/card.tsx", + source: "export default () => null;", + }), + { + manifestCache: fakeManifestCache({ + record: ((input: unknown) => Effect.sync(() => void recorded.push(input))) as never, + }), + compiler: fakeCompiler({ + compileAndExtract: () => + Effect.succeed({ + status: "ready" as const, + manifest: { + manifestVersion: 2, + props: {}, + actions: {}, + slot: false, + previewStates: ["default"], + hostData: [], + }, + previewTrees: {}, + }), + }), + }, + ); + expect(result.isError).toBe(false); + expect(result.output).toContain("manifest recorded"); + expect(recorded).toHaveLength(1); + }); + + it("write_component rejects when headless compilation is unavailable", async () => { + const result = await run( + WorkspaceTools.writeComponent(SCOPE, { + editSessionId: EDIT_SESSION_ID, + path: "components/card.tsx", + source: "export default () => null;", + }), + // default compiler is `unavailable` + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("headless component compilation is unavailable"); + }); + + it("write_component rejects an invalid file name before compiling", async () => { + const compileAndExtract = () => { + throw new Error("must not compile an invalid path"); + }; + const result = await run( + WorkspaceTools.writeComponent(SCOPE, { + editSessionId: EDIT_SESSION_ID, + path: "components/../evil.tsx", + source: "export default () => null;", + }), + { compiler: fakeCompiler({ compileAndExtract: compileAndExtract as never }) }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("write_component rejected"); + }); + + it("rename_component moves a component and reports the version", async () => { + const result = await run( + WorkspaceTools.renameComponent(SCOPE, { + editSessionId: EDIT_SESSION_ID, + fromPath: "components/hero.tsx", + toPath: "components/banner.tsx", + }), + ); + expect(result.isError).toBe(false); + expect(result.output).toContain("components/hero.tsx → components/banner.tsx"); + }); + + it("delete_component removes a component and warns about placeholders", async () => { + const result = await run( + WorkspaceTools.deleteComponent(SCOPE, { + editSessionId: EDIT_SESSION_ID, + path: "components/hero.tsx", + }), + ); + expect(result.isError).toBe(false); + expect(result.output).toContain("placeholders"); + }); + + it("a service failure folds into a readable message (no Cause leak)", async () => { + const result = await run( + WorkspaceTools.getPaywall(SCOPE, { editSessionId: "missing_session" }), + { + workspace: fakeWorkspace({ + readConnectedDocumentTree: () => + Effect.fail({ _tag: "PaywallNotFoundError", message: 'No paywall "missing"' }) as never, + }), + }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("get_paywall failed"); + expect(result.output).toContain('No paywall "missing"'); + expect(result.output).not.toContain("Cause("); + }); +}); diff --git a/apps/backend/src/ai/vfs/bash-tool.test.ts b/apps/backend/src/ai/vfs/bash-tool.test.ts new file mode 100644 index 000000000..fff8e61ec --- /dev/null +++ b/apps/backend/src/ai/vfs/bash-tool.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { runWorkspaceBash, truncateBashOutput } from "./bash-tool.ts"; +import type { PaywallVfsFiles, WorkspaceVfsSources } from "./workspace-vfs.ts"; + +/** + * Fixture sources: two paywalls, `pw_1` with one local component. `readCalls` + * counts document reads per paywall id so tests can assert the per-call + * memoization and the listing scope gate. + */ +const fixtureSources = (): { sources: WorkspaceVfsSources; readCalls: Map } => { + const readCalls = new Map(); + const paywalls: Record = { + pw_1: { + documentJson: `${JSON.stringify({ id: "root1", name: "Trial", token: "trial-token" }, null, 2)}\n`, + components: [{ fileName: "hero.tsx", source: "export const Hero = () => null;\n" }], + }, + pw_2: { + documentJson: `${JSON.stringify({ id: "root2", name: "Onboarding" }, null, 2)}\n`, + components: [], + }, + }; + const sources: WorkspaceVfsSources = { + listPaywalls: async () => [ + { paywallId: "pw_1", slug: "trial" }, + { paywallId: "pw_2", slug: "onboarding" }, + ], + readPaywall: async (paywallId) => { + readCalls.set(paywallId, (readCalls.get(paywallId) ?? 0) + 1); + return paywalls[paywallId] ?? null; + }, + }; + return { sources, readCalls }; +}; + +describe("runWorkspaceBash", () => { + it("lists the root with README, paywalls mount, and scratch dirs", async () => { + const { sources } = fixtureSources(); + const result = await runWorkspaceBash(sources, "ls /"); + expect(result.exitCode).toBe(0); + const names = result.stdout.trim().split("\n"); + expect(names).toContain("README.md"); + expect(names).toContain("paywalls"); + expect(names).toContain("tmp"); + }); + + it("serves the README and the paywall projections", async () => { + const { sources } = fixtureSources(); + const readme = await runWorkspaceBash(sources, "cat /README.md"); + expect(readme.stdout).toContain("/paywalls//document.json"); + + const listing = await runWorkspaceBash(sources, "ls /paywalls"); + expect(listing.stdout.trim().split("\n").sort()).toEqual(["pw_1", "pw_2"]); + + const document = await runWorkspaceBash(sources, "cat /paywalls/pw_1/document.json"); + expect(document.exitCode).toBe(0); + expect(document.stdout).toContain("trial-token"); + + const component = await runWorkspaceBash( + sources, + "cat /paywalls/pw_1/components/hero.tsx", + ); + expect(component.stdout).toContain("export const Hero"); + }); + + it("reads each paywall document exactly once per call under grep -r", async () => { + const { sources, readCalls } = fixtureSources(); + const result = await runWorkspaceBash( + sources, + "grep -rl trial-token /paywalls && grep -c trial-token /paywalls/pw_1/document.json", + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("/paywalls/pw_1/document.json"); + expect(readCalls.get("pw_1")).toBe(1); + expect(readCalls.get("pw_2")).toBe(1); + }); + + it("refuses ids outside the project listing without reading them", async () => { + const { sources, readCalls } = fixtureSources(); + const result = await runWorkspaceBash(sources, "cat /paywalls/pw_403/document.json"); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("No such file or directory"); + expect(readCalls.has("pw_403")).toBe(false); + }); + + it("rejects writes into the projection with EROFS but allows /tmp scratch", async () => { + const { sources } = fixtureSources(); + const write = await runWorkspaceBash(sources, "echo x > /paywalls/pw_1/scratch.txt"); + expect(write.exitCode).not.toBe(0); + expect(write.stderr).toContain("EROFS"); + + const scratch = await runWorkspaceBash( + sources, + "echo scratch-ok > /tmp/x && cat /tmp/x", + ); + expect(scratch.exitCode).toBe(0); + expect(scratch.stdout).toBe("scratch-ok\n"); + }); + + it("answers voidhash and voidhash paywalls", async () => { + const { sources } = fixtureSources(); + const help = await runWorkspaceBash(sources, "voidhash"); + expect(help.stdout).toContain("read-only projection"); + + const paywalls = await runWorkspaceBash(sources, "voidhash paywalls"); + expect(paywalls.stdout).toBe("pw_1\ttrial\npw_2\tonboarding\n"); + + const unknown = await runWorkspaceBash(sources, "voidhash nope"); + expect(unknown.exitCode).toBe(1); + expect(unknown.stderr).toContain("unknown subcommand"); + }); +}); + +describe("truncateBashOutput", () => { + it("passes short output through and truncates long output with a notice", () => { + const short = truncateBashOutput({ stdout: "ok\n", stderr: "" }); + expect(short).toEqual({ stdout: "ok\n", stderr: "" }); + + const long = truncateBashOutput({ stdout: "x".repeat(50_000), stderr: "y".repeat(9_000) }); + expect(long.stdout.length).toBeLessThan(41_000); + expect(long.stdout).toContain("[stdout truncated at 40kB"); + expect(long.stderr).toContain("[stderr truncated at 8kB"); + }); +}); diff --git a/apps/backend/src/ai/vfs/bash-tool.ts b/apps/backend/src/ai/vfs/bash-tool.ts new file mode 100644 index 000000000..fbdc78016 --- /dev/null +++ b/apps/backend/src/ai/vfs/bash-tool.ts @@ -0,0 +1,89 @@ +/** + * The just-bash execution seam behind the `bash` workspace tool: a fresh, + * sandboxed shell per invocation over {@link makeWorkspaceVfs}. No network, no + * script runtimes — the default pure-JS command set (grep/sed/awk/jq/…) plus + * the `voidhash` custom command. Custom commands register in + * {@link workspaceCustomCommands}. + */ +import { Bash, defineCommand, type CustomCommand, type ExecResult } from "just-bash/browser"; + +import { + makeWorkspaceVfs, + WORKSPACE_VFS_README, + type WorkspaceVfsSources, +} from "./workspace-vfs.ts"; + +/** stdout cap for one bash result (~10k tokens). */ +export const MAX_BASH_STDOUT = 40_000; +/** stderr cap for one bash result. */ +export const MAX_BASH_STDERR = 8_000; + +const voidhashCommand = (sources: WorkspaceVfsSources): CustomCommand => + defineCommand("voidhash", async (args) => { + const subcommand = args[0]; + if (subcommand === undefined || subcommand === "help") { + return { stdout: WORKSPACE_VFS_README, stderr: "", exitCode: 0 }; + } + if (subcommand === "paywalls") { + const paywalls = await sources.listPaywalls(); + const lines = paywalls.map((paywall) => `${paywall.paywallId}\t${paywall.slug}`); + return { stdout: lines.length === 0 ? "" : `${lines.join("\n")}\n`, stderr: "", exitCode: 0 }; + } + return { + stdout: "", + stderr: `voidhash: unknown subcommand '${subcommand}' (usage: voidhash [help|paywalls])\n`, + exitCode: 1, + }; + }); + +const workspaceCustomCommands = (sources: WorkspaceVfsSources): CustomCommand[] => [ + voidhashCommand(sources), +]; + +// Filesystem errors from redirect targets (`echo x > /paywalls/...`) escape +// the interpreter as throws instead of becoming command stderr; a Node-shaped +// `E:` message is an answered question, not an infrastructure failure. +const isFsError = (error: unknown): error is Error => + error instanceof Error && /^E[A-Z]+: /.test(error.message); + +/** + * Execute one command line in a fresh workspace shell. Filesystem and shell + * state live only for this call; `signal` cooperatively aborts execution. + */ +export const runWorkspaceBash = async ( + sources: WorkspaceVfsSources, + command: string, + options: { readonly signal?: AbortSignal } = {}, +): Promise => { + const bash = new Bash({ + fs: await makeWorkspaceVfs(sources), + cwd: "/", + env: { HOME: "/home/user" }, + executionLimits: { + maxCommandCount: 512, + maxOutputSize: 2 * 1024 * 1024, + }, + customCommands: workspaceCustomCommands(sources), + }); + try { + return await bash.exec(command, options.signal === undefined ? {} : { signal: options.signal }); + } catch (error) { + if (isFsError(error)) { + return { stdout: "", stderr: `bash: ${error.message}\n`, exitCode: 1 }; + } + throw error; + } +}; + +const truncate = (text: string, max: number, label: string): string => + text.length <= max + ? text + : `${text.slice(0, max)}\n[${label} truncated at ${Math.round(max / 1000)}kB — narrow with grep/head/wc and rerun]\n`; + +/** Cap a bash result's streams to token-friendly sizes, appending a notice. */ +export const truncateBashOutput = ( + result: Pick, +): { stdout: string; stderr: string } => ({ + stdout: truncate(result.stdout, MAX_BASH_STDOUT, "stdout"), + stderr: truncate(result.stderr, MAX_BASH_STDERR, "stderr"), +}); diff --git a/apps/backend/src/ai/vfs/readonly-fs.test.ts b/apps/backend/src/ai/vfs/readonly-fs.test.ts new file mode 100644 index 000000000..85dd5f208 --- /dev/null +++ b/apps/backend/src/ai/vfs/readonly-fs.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { LazyReadOnlyFs, type ReadOnlyDirEntry, type ReadOnlyDirProvider } from "./readonly-fs.ts"; + +/** + * A fixture provider over a static two-level tree: + * /a (dir) + * /a/x.txt "hello" + * /top.txt "root file" + */ +const fixtureProvider = (): ReadOnlyDirProvider => { + const files = new Map([ + ["a/x.txt", "hello"], + ["top.txt", "root file"], + ]); + const dirs = new Set(["", "a"]); + return { + async readdir(relPath): Promise | null> { + if (!dirs.has(relPath)) { + return null; + } + const prefix = relPath === "" ? "" : `${relPath}/`; + const entries: ReadOnlyDirEntry[] = []; + for (const dir of dirs) { + if (dir !== "" && dir.startsWith(prefix) && !dir.slice(prefix.length).includes("/")) { + entries.push({ name: dir.slice(prefix.length), kind: "dir" }); + } + } + for (const file of files.keys()) { + if (file.startsWith(prefix) && !file.slice(prefix.length).includes("/")) { + entries.push({ name: file.slice(prefix.length), kind: "file" }); + } + } + return entries; + }, + async stat(relPath) { + if (dirs.has(relPath)) { + return { kind: "dir" }; + } + const content = files.get(relPath); + return content === undefined ? null : { kind: "file" }; + }, + async readFile(relPath) { + return files.get(relPath) ?? null; + }, + }; +}; + +describe("LazyReadOnlyFs", () => { + it("maps readdir/stat/readFile onto the provider", async () => { + const fs = new LazyReadOnlyFs(fixtureProvider()); + expect((await fs.readdir("/")).sort()).toEqual(["a", "top.txt"]); + expect(await fs.readdir("/a")).toEqual(["x.txt"]); + expect(await fs.readFile("/a/x.txt")).toBe("hello"); + expect(await fs.exists("/a/x.txt")).toBe(true); + expect(await fs.exists("/nope")).toBe(false); + const stat = await fs.stat("/a/x.txt"); + expect(stat.isFile).toBe(true); + expect(stat.size).toBe(5); + expect((await fs.stat("/a")).isDirectory).toBe(true); + expect(await fs.realpath("/a/../a/x.txt")).toBe("/a/x.txt"); + }); + + it("reports typed dirents without extra stat calls", async () => { + const fs = new LazyReadOnlyFs(fixtureProvider()); + const entries = await fs.readdirWithFileTypes("/"); + expect(entries.find((entry) => entry.name === "a")?.isDirectory).toBe(true); + expect(entries.find((entry) => entry.name === "top.txt")?.isFile).toBe(true); + }); + + it("throws Node-shaped errors for missing and mistyped paths", async () => { + const fs = new LazyReadOnlyFs(fixtureProvider()); + await expect(fs.readFile("/nope.txt")).rejects.toThrow( + "ENOENT: no such file or directory, open '/nope.txt'", + ); + await expect(fs.readFile("/a")).rejects.toThrow( + "EISDIR: illegal operation on a directory, read '/a'", + ); + await expect(fs.stat("/nope")).rejects.toThrow( + "ENOENT: no such file or directory, stat '/nope'", + ); + await expect(fs.readdir("/nope")).rejects.toThrow( + "ENOENT: no such file or directory, scandir '/nope'", + ); + await expect(fs.readdir("/top.txt")).rejects.toThrow( + "ENOTDIR: not a directory, scandir '/top.txt'", + ); + await expect(fs.readlink("/a/x.txt")).rejects.toThrow( + "EINVAL: invalid argument, readlink '/a/x.txt'", + ); + }); + + it("throws EROFS for every mutation", async () => { + const fs = new LazyReadOnlyFs(fixtureProvider()); + const mutations: ReadonlyArray<[string, Promise]> = [ + ["writeFile", fs.writeFile("/a/x.txt")], + ["appendFile", fs.appendFile("/a/x.txt")], + ["mkdir", fs.mkdir("/b")], + ["rm", fs.rm("/a/x.txt")], + ["cp", fs.cp("/a/x.txt", "/a/y.txt")], + ["mv", fs.mv("/a/x.txt")], + ["chmod", fs.chmod("/a/x.txt")], + ["symlink", fs.symlink("/a/x.txt", "/a/l")], + ["link", fs.link("/a/x.txt", "/a/l")], + ["utimes", fs.utimes("/a/x.txt")], + ]; + for (const [name, promise] of mutations) { + await expect(promise, name).rejects.toThrow(/^EROFS: read-only file system/); + } + }); +}); diff --git a/apps/backend/src/ai/vfs/readonly-fs.ts b/apps/backend/src/ai/vfs/readonly-fs.ts new file mode 100644 index 000000000..a5589704e --- /dev/null +++ b/apps/backend/src/ai/vfs/readonly-fs.ts @@ -0,0 +1,222 @@ +/** + * A lazy, read-only just-bash filesystem backend over a small provider seam. + * + * Mounted under a {@link https://github.com/vercel-labs/just-bash MountableFs} + * mount point, so every path this filesystem sees is already mount-relative + * (`/` = the mount root). Listings and contents are resolved on demand from the + * provider — nothing is materialized up front — which keeps `ls /paywalls` + * cheap while `grep -r` still reaches every file. All mutations throw `EROFS`. + */ +import type { FsStat, IFileSystem } from "just-bash/browser"; + +type DirentEntry = Awaited>>[number]; + +/** One entry of a provider directory listing. */ +export interface ReadOnlyDirEntry { + readonly name: string; + readonly kind: "file" | "dir"; +} + +/** + * The data source behind one read-only mount. Paths are provider-relative with + * no leading slash (`""` = the mount root, `"a/b.txt"` = a nested file). + * Implementations should memoize expensive reads per instance — one provider + * instance lives for exactly one bash invocation. + */ +export interface ReadOnlyDirProvider { + /** Entries of a directory, or `null` when the path is not a directory. */ + readdir(relPath: string): Promise | null>; + /** Kind (and optional byte size) of a path, or `null` when it does not exist. */ + stat(relPath: string): Promise<{ kind: "file" | "dir"; size?: number } | null>; + /** Content of a file, or `null` when the path is not a file. */ + readFile(relPath: string): Promise; +} + +// Error messages mirror just-bash's own InMemoryFs formats exactly — its +// coreutils branch on the `ENOENT:`/`EISDIR:`/... message prefixes when +// rendering `cat`/`ls` failures. +const enoent = (syscall: string, path: string): Error => + new Error(`ENOENT: no such file or directory, ${syscall} '${path}'`); +const eisdir = (syscall: string, path: string): Error => + new Error(`EISDIR: illegal operation on a directory, ${syscall} '${path}'`); +const enotdir = (syscall: string, path: string): Error => + new Error(`ENOTDIR: not a directory, scandir '${path}'`); +const einval = (syscall: string, path: string): Error => + new Error(`EINVAL: invalid argument, ${syscall} '${path}'`); +const erofs = (syscall: string, path: string): Error => + new Error( + `EROFS: read-only file system, ${syscall} '${path}' — this folder is a read-only projection; use /tmp for scratch files`, + ); + +/** Resolve `.`/`..` segments of an absolute POSIX path (no symlinks to follow). */ +const normalizePath = (path: string): string => { + const segments: string[] = []; + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") { + continue; + } + if (segment === "..") { + segments.pop(); + continue; + } + segments.push(segment); + } + return `/${segments.join("/")}`; +}; + +const relative = (path: string): string => normalizePath(path).slice(1); + +const FILE_MODE = 0o644; +const DIR_MODE = 0o755; + +export class LazyReadOnlyFs implements IFileSystem { + private readonly mtime = new Date(); + private readonly pathPrefix: string; + + constructor( + private readonly provider: ReadOnlyDirProvider, + options: { readonly pathPrefix?: string } = {}, + ) { + this.pathPrefix = options.pathPrefix ?? ""; + } + + // Mounted filesystems receive mount-relative paths; errors that escape the + // interpreter (e.g. a redirect's failed `open`) render the raw path, so + // re-prefix with the mount point to keep messages in the user's vocabulary. + private display(path: string): string { + const normalized = normalizePath(path); + return normalized === "/" ? this.pathPrefix || "/" : `${this.pathPrefix}${normalized}`; + } + + async readFile(path: string): Promise { + const content = await this.provider.readFile(relative(path)); + if (content !== null) { + return content; + } + const stat = await this.provider.stat(relative(path)); + throw stat?.kind === "dir" + ? eisdir("read", this.display(path)) + : enoent("open", this.display(path)); + } + + async readFileBuffer(path: string): Promise { + return new TextEncoder().encode(await this.readFile(path)); + } + + async writeFile(path: string): Promise { + throw erofs("open", this.display(path)); + } + + async appendFile(path: string): Promise { + throw erofs("open", this.display(path)); + } + + async exists(path: string): Promise { + return (await this.provider.stat(relative(path))) !== null; + } + + async stat(path: string): Promise { + const stat = await this.provider.stat(relative(path)); + if (stat === null) { + throw enoent("stat", this.display(path)); + } + const size = + stat.kind === "file" + ? (stat.size ?? + new TextEncoder().encode((await this.provider.readFile(relative(path))) ?? "").length) + : 0; + return { + isFile: stat.kind === "file", + isDirectory: stat.kind === "dir", + isSymbolicLink: false, + mode: stat.kind === "file" ? FILE_MODE : DIR_MODE, + size, + mtime: this.mtime, + }; + } + + async lstat(path: string): Promise { + return this.stat(path); + } + + async mkdir(path: string): Promise { + throw erofs("mkdir", this.display(path)); + } + + async readdir(path: string): Promise { + const entries = await this.provider.readdir(relative(path)); + if (entries === null) { + const stat = await this.provider.stat(relative(path)); + throw stat === null + ? enoent("scandir", this.display(path)) + : enotdir("scandir", this.display(path)); + } + return entries.map((entry) => entry.name); + } + + async readdirWithFileTypes(path: string): Promise { + const entries = await this.provider.readdir(relative(path)); + if (entries === null) { + const stat = await this.provider.stat(relative(path)); + throw stat === null + ? enoent("scandir", this.display(path)) + : enotdir("scandir", this.display(path)); + } + return entries.map((entry) => ({ + name: entry.name, + isFile: entry.kind === "file", + isDirectory: entry.kind === "dir", + isSymbolicLink: false, + })); + } + + async rm(path: string): Promise { + throw erofs("rm", this.display(path)); + } + + async cp(src: string, dest: string): Promise { + throw erofs("cp", this.display(dest)); + } + + async mv(src: string): Promise { + throw erofs("rename", this.display(src)); + } + + resolvePath(base: string, path: string): string { + return path.startsWith("/") ? normalizePath(path) : normalizePath(`${base}/${path}`); + } + + // Sync by contract, so a lazy backend cannot enumerate here. Nothing in + // just-bash consumes it outside FS-composition internals (globs walk + // `readdir`), so reporting just the root is safe. + getAllPaths(): string[] { + return ["/"]; + } + + async chmod(path: string): Promise { + throw erofs("chmod", this.display(path)); + } + + async symlink(_target: string, linkPath: string): Promise { + throw erofs("symlink", this.display(linkPath)); + } + + async link(_existingPath: string, newPath: string): Promise { + throw erofs("link", this.display(newPath)); + } + + async readlink(path: string): Promise { + throw einval("readlink", this.display(path)); + } + + async realpath(path: string): Promise { + if (!(await this.exists(path))) { + throw enoent("realpath", this.display(path)); + } + return normalizePath(path); + } + + async utimes(path: string): Promise { + throw erofs("utimes", this.display(path)); + } +} diff --git a/apps/backend/src/ai/vfs/workspace-vfs.ts b/apps/backend/src/ai/vfs/workspace-vfs.ts new file mode 100644 index 000000000..9a61ce613 --- /dev/null +++ b/apps/backend/src/ai/vfs/workspace-vfs.ts @@ -0,0 +1,208 @@ +/** + * The workspace virtual filesystem the `bash` tool executes over: read-only + * projections of the project's paywall workspace mounted over a writable + * in-memory base (`/README.md`, `/tmp`, `/home/user`). + * + * Layout: + * + * - `/paywalls//document.json` — cleaned document JSON (the same + * shape `get_paywall` returns). + * - `/paywalls//components/.tsx` — local code-component TSX. + * + * Adding a future folder (`/builtins`, `/components` catalog, `/examples`) is + * one new {@link ReadOnlyDirProvider} plus one mount entry in + * {@link makeWorkspaceVfs}. + */ +import { serializeDocument, type SnapshotDocumentNode } from "@voidhash/ai-shared"; +import { fileNameFromDocRelative } from "@voidhash/paywall-workspace"; +import { InMemoryFs, MountableFs, type IFileSystem } from "just-bash/browser"; + +import { + LazyReadOnlyFs, + type ReadOnlyDirEntry, + type ReadOnlyDirProvider, +} from "./readonly-fs.ts"; + +/** One paywall of the scoped project, as listed by the workspace service. */ +export interface WorkspaceVfsPaywall { + readonly slug: string; + readonly paywallId: string; +} + +/** The projected files of one paywall directory. */ +export interface PaywallVfsFiles { + readonly documentJson: string; + readonly components: ReadonlyArray<{ readonly fileName: string; readonly source: string }>; +} + +/** + * The data the VFS reads, as plain promise-returning functions so the VFS + * modules stay Effect-free and unit-testable with fixtures. `readPaywall` + * returns `null` for an unknown id (e.g. a paywall deleted mid-call). + */ +export interface WorkspaceVfsSources { + listPaywalls(): Promise>; + readPaywall(paywallId: string): Promise; +} + +/** + * Shape a decoded document root into its VFS files: pretty-printed cleaned + * document JSON plus the local `codeComponent` sources (walked from the + * singleton `library` node) named by their `.tsx` file basename. + */ +export const paywallVfsFiles = (root: SnapshotDocumentNode | null): PaywallVfsFiles => { + const cleaned = root === null ? null : serializeDocument([root]); + const library = (root?.children ?? []).find((child) => child.type === "library"); + const components = (library?.children ?? []).flatMap((node) => { + if (node.type !== "codeComponent") { + return []; + } + const data = (node.data ?? {}) as { path?: unknown; source?: unknown }; + return typeof data.path === "string" && typeof data.source === "string" + ? [{ fileName: fileNameFromDocRelative(data.path), source: data.source }] + : []; + }); + return { documentJson: `${JSON.stringify(cleaned, null, 2)}\n`, components }; +}; + +const COMPONENTS_DIR = "components"; +const DOCUMENT_FILE = "document.json"; + +/** + * `/paywalls` provider: one directory per paywall id. The listing and each + * paywall's files are memoized per instance (= per bash invocation), so a + * `grep -r /paywalls` reads every document exactly once. Ids not present in + * the project listing resolve to ENOENT without touching `readPaywall` — the + * listing is the project-scope gate. + */ +export class PaywallsProvider implements ReadOnlyDirProvider { + private listing: Promise> | undefined; + private readonly files = new Map>(); + + constructor(private readonly sources: WorkspaceVfsSources) {} + + private list(): Promise> { + this.listing ??= this.sources.listPaywalls(); + return this.listing; + } + + private async filesOf(paywallId: string): Promise { + const listed = (await this.list()).some((paywall) => paywall.paywallId === paywallId); + if (!listed) { + return null; + } + let files = this.files.get(paywallId); + if (files === undefined) { + files = this.sources.readPaywall(paywallId); + this.files.set(paywallId, files); + } + return files; + } + + async readdir(relPath: string): Promise | null> { + const segments = relPath === "" ? [] : relPath.split("/"); + if (segments.length === 0) { + const paywalls = await this.list(); + return paywalls.map((paywall) => ({ name: paywall.paywallId, kind: "dir" })); + } + if (segments.length === 1) { + const files = await this.filesOf(segments[0]!); + return files === null + ? null + : [ + { name: DOCUMENT_FILE, kind: "file" }, + { name: COMPONENTS_DIR, kind: "dir" }, + ]; + } + if (segments.length === 2 && segments[1] === COMPONENTS_DIR) { + const files = await this.filesOf(segments[0]!); + return files === null + ? null + : files.components.map((component) => ({ name: component.fileName, kind: "file" })); + } + return null; + } + + async stat(relPath: string): Promise<{ kind: "file" | "dir"; size?: number } | null> { + const segments = relPath === "" ? [] : relPath.split("/"); + if (segments.length === 0) { + return { kind: "dir" }; + } + if (segments.length === 1) { + return (await this.filesOf(segments[0]!)) === null ? null : { kind: "dir" }; + } + if (segments.length === 2) { + const files = await this.filesOf(segments[0]!); + if (files === null) { + return null; + } + if (segments[1] === DOCUMENT_FILE) { + return { kind: "file", size: new TextEncoder().encode(files.documentJson).length }; + } + return segments[1] === COMPONENTS_DIR ? { kind: "dir" } : null; + } + if (segments.length === 3 && segments[1] === COMPONENTS_DIR) { + const source = await this.componentSource(segments[0]!, segments[2]!); + return source === null + ? null + : { kind: "file", size: new TextEncoder().encode(source).length }; + } + return null; + } + + async readFile(relPath: string): Promise { + const segments = relPath === "" ? [] : relPath.split("/"); + if (segments.length === 2 && segments[1] === DOCUMENT_FILE) { + const files = await this.filesOf(segments[0]!); + return files === null ? null : files.documentJson; + } + if (segments.length === 3 && segments[1] === COMPONENTS_DIR) { + return this.componentSource(segments[0]!, segments[2]!); + } + return null; + } + + private async componentSource(paywallId: string, fileName: string): Promise { + const files = await this.filesOf(paywallId); + const component = files?.components.find((candidate) => candidate.fileName === fileName); + return component?.source ?? null; + } +} + +export const WORKSPACE_VFS_README = `# Voidhash workspace (read-only projection) + +Layout: + /paywalls//document.json cleaned document JSON (same shape as get_paywall) + /paywalls//components/.tsx local code-component TSX sources + /tmp writable scratch — lives only within this single bash call + +Directory names under /paywalls are paywall ids: pass them directly to begin_paywall_edit. +Run \`voidhash paywalls\` for an id → slug listing. + +Everything except /tmp and /home/user is READ-ONLY (writes fail with EROFS). Each bash call is +a fresh shell and filesystem — chain steps with && and pipes instead of relying on state. +To modify a paywall, use begin_paywall_edit + edit_paywall / write_component. +`; + +/** + * Build the per-call workspace filesystem: a writable in-memory base carrying + * `/README.md` and the scratch dirs, with the read-only `/paywalls` projection + * mounted over it. + */ +export const makeWorkspaceVfs = async (sources: WorkspaceVfsSources): Promise => { + const base = new InMemoryFs({ "/README.md": WORKSPACE_VFS_README }); + const fs = new MountableFs({ + base, + mounts: [ + { + mountPoint: "/paywalls", + filesystem: new LazyReadOnlyFs(new PaywallsProvider(sources), { + pathPrefix: "/paywalls", + }), + }, + ], + }); + await fs.mkdir("/tmp"); + await fs.mkdir("/home/user", { recursive: true }); + return fs; +}; diff --git a/apps/backend/src/ai/workspace-tools.ts b/apps/backend/src/ai/workspace-tools.ts new file mode 100644 index 000000000..03cb0ebb3 --- /dev/null +++ b/apps/backend/src/ai/workspace-tools.ts @@ -0,0 +1,1161 @@ +/** + * Stateful document-editing tool core: ONE implementation of each MCP + * `tools/call` handler, + * consumed by the MCP JSON-RPC frontend (`routes/mcp.ts`). + * + * Individual MCP HTTP requests are server-executed, while `editSessionId` keeps + * a leased Mimic participant connection alive across them. There is no fork, no + * `paywall.tsx`, and no whole-target overwrite. The surface is document-first — + * composition is edited as Mimic document ops and code components are managed by + * path: + * + * - `list_paywalls` — the project's paywall ids, slugs, and directories. + * - `begin_paywall_edit` / `finish_paywall_edit` / `revert_paywall_edit` — a + * version- and preview-bound edit lifecycle. + * - `get_paywall({ editSessionId, nodeId?, depth? })` — cleaned document JSON. + * - `get_components({ editSessionId })` — catalog, local, and builtin components. + * - `read_component({ editSessionId, path })` — a local component's TSX source. + * - `edit_paywall({ editSessionId, edits })` — an ATOMIC batch of document ops against + * the LIVE document (schema-validated, then reconciled+submitted with retry). + * - `duplicate_subtree` — copy an existing visual subtree with fresh node ids. + * - `write_component({ editSessionId, path, source })` — server-VALIDATED (headless build) + * then committed; diagnostics commit nothing. + * - `rename_component` / `delete_component` — path move / placeholder-degrade. + * - `get_paywall_preview` — a PNG plus the exact document version/signature it + * represents, which must be supplied when finishing. + * + * Each tool is a function `(scope, input) → Effect`: it + * runs the matching workspace effect and folds the outcome into a client-facing + * string. Expected failures (rejections, conflicts, not-found) become a readable + * `{ isError: true }` message rather than a thrown error. The Effect itself never + * fails (`E = never`); the frontend provides its context. + */ +import { + ComponentCompiler, + ComponentManifestCacheService, + componentServingPreviewKey, + PaywallArtifactStore, + PaywallDeployService, + PaywallEditSessionService, + PaywallWorkspaceService, + SnapshotImageRenderer, + type CompileExtractResult, +} from "@voidhash/core/services"; +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { + serializeDocument, + validateDocumentEdits, + type DocumentEdit, + type EditableDocumentNode, + type NodeInput, + type SnapshotDocumentNode, +} from "@voidhash/ai-shared"; +import { listBuiltinComponents } from "@voidhash/paywall-builtins"; +import { + fileNameFromDocRelative, + hashSource, + validateComponentFileName, +} from "@voidhash/paywall-workspace"; +import { Cause, Effect, Exit, Option } from "effect"; + +import { runWorkspaceBash, truncateBashOutput } from "./vfs/bash-tool.ts"; +import { paywallVfsFiles, type WorkspaceVfsSources } from "./vfs/workspace-vfs.ts"; + +/** The context (services) every workspace tool closes over when it runs. */ +export type WorkspaceToolDeps = + | PaywallWorkspaceService + | PaywallDeployService + | ComponentManifestCacheService + | ComponentCompiler + | PaywallArtifactStore + | PaywallEditSessionService + | SnapshotImageRenderer + | AuthSession; + +export type WorkspaceToolContent = + | { readonly type: "text"; readonly text: string } + | { readonly type: "image"; readonly data: string; readonly mimeType: "image/png" }; + +/** + * The result of running a workspace tool. `output` is the client-facing string + * (a formatted success or a readable failure message). `isError` is `true` when + * the tool did not complete its intended effect (a workspace rejection/conflict/ + * not-found, or a validation failure) — the MCP frontend maps it to an + * `isError: true` tool result. + */ +export interface WorkspaceToolResult { + readonly output: string; + readonly isError: boolean; + readonly content?: ReadonlyArray; +} + +/** + * The scope a workspace tool operates over. `projectId` is authoritative — the + * MCP frontend derives it from the API key. Internal durable sessions also + * supply their identity so newly opened edit sessions remain agent-session-owned. + */ +export interface WorkspaceToolScope { + readonly projectId: string; + readonly agentSessionId?: string; +} + +const okResult = ( + output: string, + content?: ReadonlyArray, +): WorkspaceToolResult => ({ + output, + isError: false, + ...(content === undefined ? {} : { content }), +}); +const errResult = (output: string): WorkspaceToolResult => ({ output, isError: true }); + +/** + * Diagnostics riding on a typed failure (e.g. `WorkspaceWriteRejectedError`), + * rendered as `- ` lines. These carry the ACTUAL rejection reasons — + * without them the client only sees the generic envelope message and cannot + * react. + */ +const failureDiagnosticLines = (error: unknown): string[] => { + const diagnostics = (error as { diagnostics?: unknown }).diagnostics; + if (!Array.isArray(diagnostics)) { + return []; + } + return diagnostics.flatMap((entry) => { + if (entry === null || typeof entry !== "object") { + return []; + } + const message = (entry as { message?: unknown }).message; + return typeof message === "string" && message.length > 0 ? [`- ${message}`] : []; + }); +}; + +/** + * Render a typed failure value as a readable message. Tagged errors that carry + * their payload in `cause` instead of `message` (e.g. `PaywallServiceError`) + * inherit `Error.prototype.message === ""` — naively reading `.message` yields + * an EMPTY string. Prefer a non-empty `message`, then a `cause` payload + * (prefixed with the error's `_tag`), then the tag alone, and never return an + * empty string; any `diagnostics` the error carries are appended as `- ` lines. + */ +const failureMessage = (error: unknown): string => { + const base = (() => { + if (error !== null && typeof error === "object") { + const { message, cause, _tag } = error as { + message?: unknown; + cause?: unknown; + _tag?: unknown; + }; + if (typeof message === "string" && message.length > 0) { + return message; + } + const tag = typeof _tag === "string" ? _tag : undefined; + const causeText = + typeof cause === "string" && cause.length > 0 + ? cause + : cause instanceof Error && cause.message.length > 0 + ? cause.message + : undefined; + if (causeText !== undefined) { + return tag !== undefined ? `${tag}: ${causeText}` : causeText; + } + if (tag !== undefined) { + return tag; + } + } + const text = String(error); + return text.length > 0 ? text : "unknown error (no message)"; + })(); + const diagnosticLines = failureDiagnosticLines(error); + return diagnosticLines.length > 0 ? `${base}\n${diagnosticLines.join("\n")}` : base; +}; + +/** + * Run a workspace effect and fold its exit into `{ ok, value | message }`. The + * context is supplied by the frontend (provided into the returned effect), so + * the shared core needs no direct context access. An expected typed failure + * becomes a readable message (see {@link failureMessage}) — never a defect the + * frontend has to catch. + */ +const runFolded = ( + effect: Effect.Effect, +): Effect.Effect< + { ok: true; value: A } | { ok: false; message: string }, + never, + WorkspaceToolDeps +> => + effect.pipe( + Effect.exit, + Effect.map((exit) => { + if (Exit.isSuccess(exit)) { + return { ok: true as const, value: exit.value }; + } + // Extract the first typed failure (`Fail` reason's `error`) from the + // cause; a die/interrupt has no `Fail` reason, so it pretty-prints the + // whole cause instead. + const failure = Cause.findErrorOption(exit.cause); + const message = Option.isSome(failure) + ? failureMessage(failure.value) + : Cause.pretty(exit.cause); + return { ok: false as const, message }; + }), + ); + +/** Inputs for each workspace tool, matching the MCP tool schemas. */ +export interface ListPaywallsInput {} + +export interface RunBashInput { + readonly command: string; +} + +/** Stable paywall target shared by every paywall-scoped tool. */ +export interface PaywallTargetInput { + readonly paywallId: string; +} + +export interface BeginPaywallEditInput extends PaywallTargetInput {} +export interface EditSessionInput { + readonly editSessionId: string; +} +export interface GetPaywallInput extends EditSessionInput { + readonly nodeId?: string; + readonly depth?: number; +} +export interface GetComponentsInput extends EditSessionInput {} +export interface ReadComponentInput extends EditSessionInput { + readonly path: string; +} +export interface EditPaywallInput extends EditSessionInput { + readonly edits: ReadonlyArray; +} +export interface DuplicateSubtreeInput extends EditSessionInput { + readonly nodeId: string; + readonly parentId: string; + readonly index?: number; + readonly nextName?: string; +} +export interface WriteComponentInput extends EditSessionInput { + readonly path: string; + readonly source: string; +} +export interface RenameComponentInput extends EditSessionInput { + readonly fromPath: string; + readonly toPath: string; +} +export interface DeleteComponentInput extends EditSessionInput { + readonly path: string; +} +export interface GetPaywallPreviewInput extends EditSessionInput { + readonly width?: number; + readonly height?: number; + readonly scale?: 1 | 2; +} +export interface FinishPaywallEditInput extends EditSessionInput { + readonly reviewedDocumentSignature: string; + readonly verdict: string; + readonly unresolvedIssues: ReadonlyArray; +} +export interface RevertPaywallEditInput { + readonly editSessionId: string; +} + +interface ResolvedPaywallTarget { + readonly paywallId: string; + readonly slug: string; +} + +interface ResolvedEditSession extends ResolvedPaywallTarget { + readonly editSessionId: string; +} + +/** Resolve a stable paywall id within the scoped project. */ +const resolvePaywallTarget = ( + scope: WorkspaceToolScope, + input: PaywallTargetInput, +): Effect.Effect => + Effect.gen(function* () { + const ws = yield* PaywallWorkspaceService; + const paywalls = yield* ws.listPaywalls(scope.projectId); + const target = paywalls.find((candidate) => candidate.paywallId === input.paywallId); + if (target === undefined) { + return yield* Effect.fail( + new Error(`No paywall with id "${input.paywallId}" exists in this project.`), + ); + } + return target; + }); + +/** Resolves and authorizes the Mimic connection represented by an edit session. */ +const resolveEditSession = ( + scope: WorkspaceToolScope, + input: EditSessionInput, +): Effect.Effect => + Effect.gen(function* () { + const editSessions = yield* PaywallEditSessionService; + const session = yield* editSessions.connectActive({ + projectId: scope.projectId, + editSessionId: input.editSessionId, + agentSessionId: scope.agentSessionId, + }); + return { + editSessionId: session.editSessionId, + paywallId: session.paywallId, + slug: session.paywallSlug, + }; + }); + +const recordSessionMutation = ( + scope: WorkspaceToolScope, + target: ResolvedEditSession, + result: { readonly version: number; readonly commandCount: number }, +) => + result.commandCount === 0 + ? Effect.void + : Effect.gen(function* () { + const editSessions = yield* PaywallEditSessionService; + yield* editSessions.recordMutation({ + projectId: scope.projectId, + editSessionId: target.editSessionId, + agentSessionId: scope.agentSessionId, + documentVersion: result.version, + }); + }).pipe(Effect.ignore); + +/** `begin_paywall_edit` — capture the revert baseline and mint the write capability. */ +export const beginPaywallEdit = ( + scope: WorkspaceToolScope, + input: BeginPaywallEditInput, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* runFolded( + Effect.gen(function* () { + const target = yield* resolvePaywallTarget(scope, input); + const editSessions = yield* PaywallEditSessionService; + return yield* scope.agentSessionId === undefined + ? editSessions.begin({ + projectId: scope.projectId, + paywallId: target.paywallId, + source: "mcp", + }) + : editSessions.begin({ + projectId: scope.projectId, + paywallId: target.paywallId, + source: "built_in", + agentSessionId: scope.agentSessionId, + }); + }), + ); + return result.ok + ? okResult( + JSON.stringify({ + editSessionId: result.value.editSessionId, + paywallId: result.value.paywallId, + baselineVersion: result.value.baselineVersion, + }), + ) + : errResult(`begin_paywall_edit failed: ${result.message}`); + }); + +/** + * `list_paywalls` — the project's paywalls (stable id + slug + path) as a list. + * MCP has no system prompt to embed the paywall set into, so it is a first-class + * tool here. + */ +export const listPaywalls = ( + scope: WorkspaceToolScope, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* runFolded( + Effect.gen(function* () { + const ws = yield* PaywallWorkspaceService; + return yield* ws.listPaywalls(scope.projectId); + }), + ); + if (!result.ok) { + return errResult(`list_paywalls failed: ${result.message}`); + } + if (result.value.length === 0) { + return okResult("No paywalls in this project."); + } + const lines = result.value.map( + (dir) => `- ${dir.paywallId}: slug ${dir.slug} (/paywalls/${dir.slug})`, + ); + return okResult(`${result.value.length} paywall(s):\n${lines.join("\n")}`); + }); + +/** + * `bash` — one read-only research command over the workspace VFS + * (`/paywalls//…`), executed in a fresh just-bash shell. A completed + * exec is a success even on a non-zero exit code (`grep` exiting 1 on no match + * is a valid answer) — the exit code and stderr are reported in the output; + * only infrastructure failures (service errors, the 30s timeout) fold to + * `isError`. + */ +export const runBash = ( + scope: WorkspaceToolScope, + input: RunBashInput, +): Effect.Effect => + Effect.gen(function* () { + const ws = yield* PaywallWorkspaceService; + const context = yield* Effect.context(); + const runEffect = Effect.runPromiseWith(context); + // A rejected VFS read gets re-rendered by the shell (e.g. `ls` prints a + // generic not-found), so the first service failure is recorded here and + // wins over whatever the shell made of it. + let serviceFailure: string | undefined; + const runSource = (effect: Effect.Effect): Promise => + runEffect(runFolded(effect)).then((folded) => { + if (folded.ok) { + return folded.value; + } + serviceFailure ??= folded.message; + throw new Error(folded.message); + }); + const sources: WorkspaceVfsSources = { + listPaywalls: () => runSource(ws.listPaywalls(scope.projectId)), + readPaywall: (paywallId) => + runSource( + ws.readDocumentTree(paywallId).pipe( + Effect.map((document) => + paywallVfsFiles((document.root as SnapshotDocumentNode | null) ?? null), + ), + Effect.catchTag("PaywallNotFoundError", () => Effect.succeed(null)), + ), + ), + }; + const result = yield* runFolded( + Effect.tryPromise((signal) => runWorkspaceBash(sources, input.command, { signal })).pipe( + Effect.timeout("30 seconds"), + ), + ); + if (serviceFailure !== undefined) { + return errResult(`bash failed: ${serviceFailure}`); + } + if (!result.ok) { + return errResult(`bash failed: ${result.message}`); + } + const { stdout, stderr } = truncateBashOutput(result.value); + const sections: string[] = []; + if (stdout.length > 0) { + sections.push(stdout); + } + if (stderr.length > 0) { + sections.push(`[stderr]\n${stderr}`); + } + if (result.value.exitCode !== 0) { + sections.push(`[exit code ${result.value.exitCode}]`); + } + return okResult(sections.length > 0 ? sections.join("\n") : "(no output)"); + }); + +/** + * Adapt a decoded document snapshot node to the ai-shared + * {@link EditableDocumentNode} the validator reads (`data` verbatim — the + * validator only touches scalar/enum leaves). + */ +const toEditableNode = (node: SnapshotDocumentNode): EditableDocumentNode => ({ + id: node.id, + type: node.type, + data: node.data as Record, + children: (node.children ?? []).map(toEditableNode), +}); + +/** Resolve the target and read its decoded document root. */ +const readDocumentRoot = (scope: WorkspaceToolScope, input: EditSessionInput) => + runFolded( + Effect.gen(function* () { + const target = yield* resolveEditSession(scope, input); + const ws = yield* PaywallWorkspaceService; + const document = yield* ws.readConnectedDocumentTree(scope.projectId, { + paywallId: target.paywallId, + connectionId: target.editSessionId, + }); + return { target, document }; + }), + ); + +/** + * `get_paywall` — the paywall's LIVE document as cleaned JSON (nested + * `{ id, type, name?, ...data, children }`, defaults stripped, CRDT internals + * dropped). `nodeId` roots a subtree; `depth` caps the tree (deeper nodes render + * as stubs). The `id`s are the addressing keys for `edit_paywall`. + */ +export const getPaywall = ( + scope: WorkspaceToolScope, + input: GetPaywallInput, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* readDocumentRoot(scope, input); + if (!result.ok) { + return errResult(`get_paywall failed: ${result.message}`); + } + const roots = + result.value.document.root != null + ? [result.value.document.root as SnapshotDocumentNode] + : []; + const cleaned = serializeDocument(roots, { + ...(input.nodeId !== undefined ? { nodeId: input.nodeId } : {}), + ...(input.depth !== undefined ? { depth: input.depth } : {}), + }); + if (cleaned === null) { + const label = `${result.value.target.paywallId} (${result.value.target.slug})`; + return errResult( + input.nodeId !== undefined + ? `get_paywall: no node "${input.nodeId}" in paywall ${label}.` + : `get_paywall: paywall ${label} has no document.`, + ); + } + return okResult(JSON.stringify(cleaned, null, 2)); + }); + +/** + * Walk a decoded document root for its local `codeComponent` definitions + * (`library` node → `codeComponent` children), returning `{ path, source }`. + */ +const localComponentsOf = ( + root: SnapshotDocumentNode | null, +): ReadonlyArray<{ readonly path: string; readonly source: string }> => { + if (root === null) { + return []; + } + const library = (root.children ?? []).find((child) => child.type === "library"); + if (library === undefined) { + return []; + } + return (library.children ?? []).flatMap((node) => { + if (node.type !== "codeComponent") { + return []; + } + const data = (node.data ?? {}) as { path?: unknown; source?: unknown }; + return typeof data.path === "string" && typeof data.source === "string" + ? [{ path: data.path, source: data.source }] + : []; + }); +}; + +/** + * `get_components` — every component placeable in the paywall: CATALOG components + * (deployed/shared, from {@link PaywallDeployService.listComponents} — slug, + * version, description, props/actions/slot/previewStates from their §2 manifest) + * AND the paywall's LOCAL code components (walked from the document's `library`; + * their manifests resolved from the content-addressed cache by source hash — a + * component whose source has no cached manifest is listed with a + * "manifest unavailable" note, never evaluated inline). + */ +export const getComponents = ( + scope: WorkspaceToolScope, + input: GetComponentsInput, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* runFolded( + Effect.gen(function* () { + const deploy = yield* PaywallDeployService; + const ws = yield* PaywallWorkspaceService; + const manifestCache = yield* ComponentManifestCacheService; + const target = yield* resolveEditSession(scope, input); + + const catalog = yield* deploy.listComponents({ projectId: scope.projectId }); + const document = yield* ws.readConnectedDocumentTree(scope.projectId, { + paywallId: target.paywallId, + connectionId: target.editSessionId, + }); + const locals = localComponentsOf((document.root as SnapshotDocumentNode | null) ?? null); + const cached = yield* manifestCache.getMany( + locals.map((local) => hashSource(local.source)), + ); + + return { + catalog: catalog.map((component) => ({ + slug: component.slug, + title: component.title, + version: component.latestVersion, + manifest: component.latest.manifest, + previewStates: component.latest.previewStates, + })), + locals: locals.map((local) => { + const row = cached.get(hashSource(local.source)); + return { + path: local.path, + manifest: row?.status === "ready" ? row.manifest : undefined, + }; + }), + builtins: listBuiltinComponents() + .filter((builtin) => builtin.manifest.slot !== true) + .map((builtin) => ({ + slug: builtin.slug, + name: builtin.name, + description: builtin.description, + props: builtin.manifest.props, + actions: builtin.manifest.actions, + previewStates: builtin.manifest.previewStates, + slot: builtin.manifest.slot ?? false, + insertAs: { componentSource: "builtin", componentSlug: builtin.slug }, + })), + } as const; + }), + ); + if (!result.ok) { + return errResult(`get_components failed: ${result.message}`); + } + const { catalog, locals, builtins } = result.value; + const sections: string[] = []; + sections.push( + catalog.length === 0 + ? "Catalog components: none." + : `Catalog components (${catalog.length}):\n${JSON.stringify(catalog, null, 2)}`, + ); + sections.push( + locals.length === 0 + ? "Local code components: none." + : `Local code components (${locals.length}):\n${locals + .map((local) => + local.manifest !== undefined + ? `- ${local.path}:\n${JSON.stringify(local.manifest, null, 2)}` + : `- ${local.path}: manifest unavailable (component not yet compiled in a session — read_component to see its source).`, + ) + .join("\n")}`, + ); + sections.push( + builtins.length === 0 + ? "Builtin components: none." + : `Builtin components (${builtins.length}):\n${JSON.stringify(builtins, null, 2)}`, + ); + return okResult(sections.join("\n\n")); + }); + +/** `read_component` — a local code component's TSX source by its `components/.tsx` path. */ +export const readComponent = ( + scope: WorkspaceToolScope, + input: ReadComponentInput, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* readDocumentRoot(scope, input); + if (!result.ok) { + return errResult(`read_component failed: ${result.message}`); + } + const locals = localComponentsOf( + (result.value.document.root as SnapshotDocumentNode | null) ?? null, + ); + const component = locals.find((local) => local.path === input.path); + if (component === undefined) { + const available = locals.map((local) => local.path); + return errResult( + `read_component: no local component at "${input.path}" in paywall ${result.value.target.paywallId} (${result.value.target.slug}).${ + available.length > 0 ? ` Available: [${available.join(", ")}].` : "" + }`, + ); + } + return okResult(component.source); + }); + +/** + * `edit_paywall` — an ATOMIC batch of document ops against the LIVE document. The + * ops are validated with the schema-derived `validateDocumentEdits` against the + * decoded tree; on failure the STRUCTURED errors are returned verbatim (the + * model reads them to converge). On success the ops are applied and reconciled + * into the live document through the version-retry submit loop, and the minted + * ids for created nodes are returned so the model can address them next. + */ +export const editPaywall = ( + scope: WorkspaceToolScope, + input: EditPaywallInput, +): Effect.Effect => + Effect.gen(function* () { + const resolved = yield* runFolded(resolveEditSession(scope, input)); + if (!resolved.ok) { + return errResult(`edit_paywall failed: ${resolved.message}`); + } + const target = resolved.value; + // Validate first (against a fresh read) so an invalid batch never opens a + // transaction and returns the model-facing errors verbatim. + const read = yield* readDocumentRoot(scope, input); + if (!read.ok) { + return errResult(`edit_paywall failed: ${read.message}`); + } + const root = read.value.document.root as SnapshotDocumentNode | null; + if (root === null) { + return errResult( + `edit_paywall: paywall ${target.paywallId} (${target.slug}) has no document to edit.`, + ); + } + const validation = validateDocumentEdits(input.edits, toEditableNode(root)); + if (!validation.ok) { + return errResult( + `edit_paywall rejected — fix these and retry:\n${validation.errors + .map((error) => `- [edit ${error.editIndex}] ${error.message}`) + .join("\n")}`, + ); + } + + const applied = yield* runFolded( + Effect.gen(function* () { + const ws = yield* PaywallWorkspaceService; + const result = yield* ws.editConnectedDocument( + scope.projectId, + { paywallId: target.paywallId, connectionId: target.editSessionId }, + validation.edits, + ); + yield* recordSessionMutation(scope, target, result); + return result; + }), + ); + if (!applied.ok) { + return errResult(`edit_paywall rejected: ${applied.message}`); + } + const { version, commandCount, mintedIds } = applied.value; + const mintedEntries = Object.entries(mintedIds); + const mintedNote = + mintedEntries.length > 0 + ? `\nMinted ids (by op index): ${mintedEntries + .map(([index, ids]) => `${index}=[${ids.join(", ")}]`) + .join("; ")}` + : ""; + return okResult( + `Applied ${validation.edits.length} edit(s) to ${target.paywallId} (${target.slug}) at version ${version} (${commandCount} command${commandCount === 1 ? "" : "s"}).${mintedNote}`, + ); + }); + +const isVisualNode = (node: SnapshotDocumentNode): boolean => + node.type !== "root" && node.type !== "library" && node.type !== "codeComponent"; + +/** Convert a visual snapshot subtree into an id-free document insert payload. */ +const cloneNodeInput = (node: SnapshotDocumentNode): NodeInput => { + const children = (node.children ?? []).filter(isVisualNode).map(cloneNodeInput); + return { + type: node.type, + ...structuredClone((node.data ?? {}) as Record), + ...(children.length === 0 ? {} : { children }), + } as NodeInput; +}; + +const findSnapshotNode = ( + root: SnapshotDocumentNode, + nodeId: string, +): SnapshotDocumentNode | null => { + if (root.id === nodeId) { + return root; + } + for (const child of root.children ?? []) { + const found = findSnapshotNode(child, nodeId); + if (found !== null) { + return found; + } + } + return null; +}; + +/** `duplicate_subtree` — clone a visual subtree and insert it with fresh ids. */ +export const duplicateSubtree = ( + scope: WorkspaceToolScope, + input: DuplicateSubtreeInput, +): Effect.Effect => + Effect.gen(function* () { + const read = yield* readDocumentRoot(scope, input); + if (!read.ok) { + return errResult(`duplicate_subtree failed: ${read.message}`); + } + const { target, document } = read.value; + const root = document.root as SnapshotDocumentNode | null; + if (root === null) { + return errResult( + `duplicate_subtree: paywall ${target.paywallId} (${target.slug}) has no document.`, + ); + } + const source = findSnapshotNode(root, input.nodeId); + if (source === null) { + return errResult(`duplicate_subtree: no node "${input.nodeId}" in the document.`); + } + if (!isVisualNode(source)) { + return errResult( + `duplicate_subtree: cannot duplicate engine-managed ${source.type} node "${input.nodeId}".`, + ); + } + const node = cloneNodeInput(source); + if (input.nextName !== undefined) { + node.name = input.nextName; + } + return yield* editPaywall(scope, { + editSessionId: input.editSessionId, + edits: [ + { + op: "insert", + parentId: input.parentId, + ...(input.index === undefined ? {} : { index: input.index }), + node, + }, + ], + }); + }); + +/** Render a compile/extract failure's diagnostics as `- ` lines. */ +const formatExtractDiagnostics = ( + result: Extract, +): string => + `[${result.phase}] compile failed:\n${result.diagnostics + .map((d) => { + const position = + d.line !== undefined + ? ` (line ${d.line}${d.column !== undefined ? `, col ${d.column}` : ""})` + : ""; + return `- ${d.message}${position}`; + }) + .join("\n")}`; + +/** + * `write_component` — server-VALIDATE the single component's source THEN commit: + * run the headless {@link ComponentCompiler.compileAndExtract} (compile, + * manifest extraction, and preview-state rendering on the container/native + * adapter). An unavailable compiler or compile/runtime diagnostics commit + * NOTHING. On success the source is written + * to the `codeComponent` node + * (created when the path is new) and, when a manifest was extracted, recorded in + * the content-addressed cache so a later projection can resolve it. + */ +export const writeComponent = ( + scope: WorkspaceToolScope, + input: WriteComponentInput, +): Effect.Effect => + Effect.gen(function* () { + const resolved = yield* runFolded(resolveEditSession(scope, input)); + if (!resolved.ok) { + return errResult(`write_component failed: ${resolved.message}`); + } + const target = resolved.value; + const nameError = validateComponentFileName(fileNameFromDocRelative(input.path)); + if (nameError !== undefined) { + return errResult(`write_component rejected: ${nameError}`); + } + + // Build the single component first. A compile/runtime error commits nothing. + const built = yield* runFolded( + Effect.gen(function* () { + const compiler = yield* ComponentCompiler; + return yield* compiler.compileAndExtract(input.source); + }), + ); + if (!built.ok) { + return errResult(`write_component failed: ${built.message}`); + } + const build = built.value; + if (build.status === "error") { + return errResult(`write_component rejected: ${formatExtractDiagnostics(build)}`); + } + if (build.status === "unavailable") { + return errResult( + "write_component rejected: headless component compilation is unavailable; no source was committed.", + ); + } + + // Commit the source (create-or-replace the codeComponent node), then record + // the manifest extracted by the required headless compile. + const committed = yield* runFolded( + Effect.gen(function* () { + const ws = yield* PaywallWorkspaceService; + const result = yield* ws.writeConnectedComponentSource( + scope.projectId, + { paywallId: target.paywallId, connectionId: target.editSessionId }, + input.path, + input.source, + ); + yield* recordSessionMutation(scope, target, result); + const manifestCache = yield* ComponentManifestCacheService; + yield* manifestCache.record({ + sourceHash: hashSource(input.source), + status: "ready", + manifest: build.manifest, + }); + return result; + }), + ); + if (!committed.ok) { + return errResult(`write_component rejected: ${committed.message}`); + } + return okResult( + `Wrote ${input.path} to ${target.paywallId} (${target.slug}) at version ${committed.value.version} (compiled clean; manifest recorded).`, + ); + }); + +/** + * `rename_component` — move a local component from `fromPath` to `toPath` (a path + * rename): repaths the definition AND re-points every local instance referencing + * it (rename cascade). Addressed by `components/.tsx` file names. + */ +export const renameComponent = ( + scope: WorkspaceToolScope, + input: RenameComponentInput, +): Effect.Effect => + Effect.gen(function* () { + const resolved = yield* runFolded(resolveEditSession(scope, input)); + if (!resolved.ok) { + return errResult(`rename_component failed: ${resolved.message}`); + } + const target = resolved.value; + const result = yield* runFolded( + Effect.gen(function* () { + const ws = yield* PaywallWorkspaceService; + const result = yield* ws.moveConnectedComponentFile( + scope.projectId, + { paywallId: target.paywallId, connectionId: target.editSessionId }, + fileNameFromDocRelative(input.fromPath), + fileNameFromDocRelative(input.toPath), + ); + yield* recordSessionMutation(scope, target, result); + return result; + }), + ); + return result.ok + ? okResult( + `Renamed ${input.fromPath} → ${input.toPath} in ${target.paywallId} (${target.slug}) at version ${result.value.version}.`, + ) + : errResult(`rename_component rejected: ${result.message}`); + }); + +/** + * `delete_component` — remove a local component's `codeComponent` definition by + * path. Existing instances of it DEGRADE to placeholders (never cascade-deleted), + * so they should be replaced or removed afterward. + */ +export const deleteComponent = ( + scope: WorkspaceToolScope, + input: DeleteComponentInput, +): Effect.Effect => + Effect.gen(function* () { + const resolved = yield* runFolded(resolveEditSession(scope, input)); + if (!resolved.ok) { + return errResult(`delete_component failed: ${resolved.message}`); + } + const target = resolved.value; + const result = yield* runFolded( + Effect.gen(function* () { + const ws = yield* PaywallWorkspaceService; + const result = yield* ws.deleteConnectedComponentFile( + scope.projectId, + { paywallId: target.paywallId, connectionId: target.editSessionId }, + fileNameFromDocRelative(input.path), + ); + yield* recordSessionMutation(scope, target, result); + return result; + }), + ); + return result.ok + ? okResult( + `Deleted ${input.path} from ${target.paywallId} (${target.slug}) at version ${result.value.version}. Any instances of it now render as placeholders — replace or remove them.`, + ) + : errResult(`delete_component rejected: ${result.message}`); + }); + +const documentSignature = (root: unknown): string => `doc-${hashSource(JSON.stringify(root))}`; + +const pngBase64 = (png: Uint8Array): string => { + const chunks: string[] = []; + for (let offset = 0; offset < png.length; offset += 0x8000) { + chunks.push(String.fromCharCode(...png.subarray(offset, offset + 0x8000))); + } + return btoa(chunks.join("")); +}; + +const deployedComponentPreviewStates = ( + root: SnapshotDocumentNode | null, +): ReadonlyMap> => { + const statesByHash = new Map>(); + const visit = (node: SnapshotDocumentNode): void => { + if (node.type === "component") { + const data = (node.data ?? {}) as Record; + const contentHash = data.contentHash; + if (data.componentSource !== "local" && typeof contentHash === "string" && contentHash) { + const states = statesByHash.get(contentHash) ?? new Set(); + states.add("default"); + if (typeof data.previewState === "string" && data.previewState) { + states.add(data.previewState); + } + statesByHash.set(contentHash, states); + } + } + for (const child of node.children ?? []) { + visit(child); + } + }; + if (root !== null) { + visit(root); + } + return statesByHash; +}; + +const fetchPreviewComponentTrees = (root: SnapshotDocumentNode | null) => + Effect.gen(function* () { + const store = yield* PaywallArtifactStore; + const trees: Record> = {}; + for (const [contentHash, states] of deployedComponentPreviewStates(root)) { + for (const state of states) { + const object = yield* store.getObject(componentServingPreviewKey(contentHash, state)); + if (object === null) { + continue; + } + const tree = yield* Effect.try({ + try: () => JSON.parse(new TextDecoder().decode(object.body)) as unknown, + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); + if (tree !== null) { + (trees[contentHash] ??= {})[state] = tree; + } + } + } + return trees; + }); + +const compileLocalPreviewTrees = (root: SnapshotDocumentNode | null) => + Effect.gen(function* () { + const compiler = yield* ComponentCompiler; + const trees: Record> = {}; + for (const local of localComponentsOf(root)) { + const result = yield* compiler.compileAndExtract(local.source); + if (result.status === "unavailable") { + return yield* Effect.fail( + new Error( + `Headless compilation is unavailable for local component ${local.path}; preview was not rendered.`, + ), + ); + } + if (result.status === "error") { + return yield* Effect.fail( + new Error( + `Local component ${local.path} cannot be previewed. ${formatExtractDiagnostics(result)}`, + ), + ); + } + trees[local.path] = { ...result.previewTrees }; + } + return trees; + }); + +/** `get_paywall_preview` — render and return a version-bound PNG review image. */ +export const getPaywallPreview = ( + scope: WorkspaceToolScope, + input: GetPaywallPreviewInput, +): Effect.Effect => + Effect.gen(function* () { + const width = input.width ?? 375; + const height = input.height ?? 812; + const scale = input.scale ?? 1; + const result = yield* runFolded( + Effect.gen(function* () { + const target = yield* resolveEditSession(scope, input); + const editSessions = yield* PaywallEditSessionService; + const workspace = yield* PaywallWorkspaceService; + const connection = { + paywallId: target.paywallId, + connectionId: target.editSessionId, + }; + const before = yield* workspace.readConnectedDocumentTree(scope.projectId, connection); + const signature = documentSignature(before.root); + const root = (before.root as SnapshotDocumentNode | null) ?? null; + const componentTrees = yield* fetchPreviewComponentTrees(root); + const localComponentTrees = yield* compileLocalPreviewTrees(root); + const renderer = yield* SnapshotImageRenderer; + const png = yield* renderer.render({ + snapshot: before.root, + componentTrees, + localComponentTrees, + width, + height, + deviceScaleFactor: scale, + }); + const after = yield* workspace.readConnectedDocumentTree(scope.projectId, connection); + if (after.version !== before.version || documentSignature(after.root) !== signature) { + return yield* Effect.fail( + new Error( + "The paywall changed while its preview was rendering. Request a fresh preview.", + ), + ); + } + yield* editSessions.recordPreview({ + projectId: scope.projectId, + editSessionId: input.editSessionId, + agentSessionId: scope.agentSessionId, + documentSignature: signature, + documentVersion: before.version, + }); + return { png, signature, version: before.version }; + }), + ); + if (!result.ok) { + return errResult(`get_paywall_preview failed: ${result.message}`); + } + const data = pngBase64(result.value.png); + const metadata = JSON.stringify({ + kind: "paywall-preview", + mediaType: "image/png", + width, + height, + scale, + documentVersion: result.value.version, + documentSignature: result.value.signature, + message: "Review this image visually before finishing the edit session.", + }); + return okResult(metadata, [ + { type: "text", text: metadata }, + { type: "image", data, mimeType: "image/png" }, + ]); + }); + +/** `finish_paywall_edit` — close an edit session after reviewing its exact latest preview. */ +export const finishPaywallEdit = ( + scope: WorkspaceToolScope, + input: FinishPaywallEditInput, +): Effect.Effect => + Effect.gen(function* () { + if (input.unresolvedIssues.length > 0) { + return errResult( + `finish_paywall_edit rejected: unresolved issues remain: ${input.unresolvedIssues.join("; ")}. Correct them and render a new preview.`, + ); + } + const result = yield* runFolded( + Effect.gen(function* () { + const target = yield* resolveEditSession(scope, input); + const workspace = yield* PaywallWorkspaceService; + const document = yield* workspace.readConnectedDocumentTree(scope.projectId, { + paywallId: target.paywallId, + connectionId: target.editSessionId, + }); + const editSessions = yield* PaywallEditSessionService; + return yield* editSessions.finish({ + projectId: scope.projectId, + editSessionId: input.editSessionId, + agentSessionId: scope.agentSessionId, + reviewedDocumentSignature: input.reviewedDocumentSignature, + currentDocumentSignature: documentSignature(document.root), + currentDocumentVersion: document.version, + verdict: input.verdict, + }); + }), + ); + return result.ok + ? okResult( + `Finished edit session ${input.editSessionId} after visual review. Verdict: ${input.verdict}`, + ) + : errResult(`finish_paywall_edit rejected: ${result.message}`); + }); + +/** `revert_paywall_edit` — reconcile the live document to the captured baseline. */ +export const revertPaywallEdit = ( + scope: WorkspaceToolScope, + input: RevertPaywallEditInput, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* runFolded( + Effect.gen(function* () { + const editSessions = yield* PaywallEditSessionService; + return yield* scope.agentSessionId === undefined + ? editSessions.revert(scope.projectId, input.editSessionId) + : editSessions.revertForAgentSession( + scope.projectId, + input.editSessionId, + scope.agentSessionId, + ); + }), + ); + return result.ok + ? okResult( + `Reverted edit session ${input.editSessionId} for "${result.value.paywallSlug}" at version ${result.value.version} (${result.value.commandCount} command${result.value.commandCount === 1 ? "" : "s"}).`, + ) + : errResult(`revert_paywall_edit failed: ${result.message}`); + }); diff --git a/apps/backend/src/mcp/dispatch.test.ts b/apps/backend/src/mcp/dispatch.test.ts new file mode 100644 index 000000000..a23e74f25 --- /dev/null +++ b/apps/backend/src/mcp/dispatch.test.ts @@ -0,0 +1,265 @@ +/** + * Tests the MCP tool dispatch path end-to-end through the stateful document- + * editing core against a mocked workspace context. Proves: a valid + * call runs the core and returns its string; an invalid argument set folds to an + * `isError` result (never a throw / JSON-RPC error); `list_paywalls` lists + * directories; `get_paywall` cleans the document; `edit_paywall` validates then + * applies; the write folds a rejection into a clean message (no Cause/_tag leak). + */ +import { + ComponentCompiler, + ComponentManifestCacheService, + PaywallArtifactStore, + PaywallDeployService, + PaywallEditSessionService, + PaywallWorkspaceService, + SnapshotImageRenderer, +} from "@voidhash/core/services"; +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { Context, Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { findMcpTool } from "./tool-manifest.ts"; +import type { WorkspaceToolResult } from "../ai/workspace-tools.ts"; + +/** A decoded document root: root → screen. */ +const documentRoot = { + id: "root1", + type: "root", + parentId: null, + pos: "a0", + data: { name: "Paywall" }, + children: [ + { id: "screen1", type: "screen", parentId: "root1", pos: "a0", data: {}, children: [] }, + ], +}; + +const fakeWorkspace = (over: Partial = {}) => + ({ + listPaywalls: () => + Effect.succeed([ + { slug: "trial", paywallId: "pw_1" }, + { slug: "onboarding", paywallId: "pw_2" }, + ]), + readDocument: (_p: string, slug: string) => + Effect.succeed({ slug, name: "Trial", paywallId: "pw_1", root: documentRoot }), + readConnectedDocumentTree: () => Effect.succeed({ tree: {}, root: documentRoot, version: 8 }), + editDocument: () => Effect.succeed({ version: 9, commandCount: 2, mintedIds: {} }), + editConnectedDocument: () => Effect.succeed({ version: 9, commandCount: 2, mintedIds: {} }), + writeComponentSource: () => Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + writeConnectedComponentSource: () => + Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + moveComponentFile: () => Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + moveConnectedComponentFile: () => + Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + deleteComponentFile: () => Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + deleteConnectedComponentFile: () => + Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }), + ...over, + }) as unknown as PaywallWorkspaceService["Service"]; + +const fakeDeploy = (over: Partial = {}) => + ({ + listComponents: () => Effect.succeed([]), + ...over, + }) as unknown as PaywallDeployService["Service"]; + +const fakeManifestCache = (over: Partial = {}) => + ({ + getMany: () => Effect.succeed(new Map()), + record: () => Effect.void, + ...over, + }) as unknown as ComponentManifestCacheService["Service"]; + +const fakeCompiler = (over: Partial = {}) => + ({ + compileCheck: () => Effect.succeed({ status: "unavailable" as const }), + compileAndExtract: () => Effect.succeed({ status: "unavailable" as const }), + ...over, + }) as unknown as ComponentCompiler["Service"]; + +const fakeEditSessions = (over: Partial = {}) => + ({ + recordMutation: () => Effect.void, + connectActive: () => + Effect.succeed({ + editSessionId: "pw_edit_1", + projectId: "proj_1", + paywallId: "pw_1", + paywallSlug: "trial", + baselineVersion: 1, + }), + ...over, + }) as unknown as PaywallEditSessionService["Service"]; + +interface Fakes { + readonly workspace?: PaywallWorkspaceService["Service"]; + readonly deploy?: PaywallDeployService["Service"]; + readonly manifestCache?: ComponentManifestCacheService["Service"]; + readonly compiler?: ComponentCompiler["Service"]; + readonly editSessions?: PaywallEditSessionService["Service"]; +} + +const contextWith = (fakes: Fakes) => + Context.empty().pipe( + Context.add(PaywallWorkspaceService, fakes.workspace ?? fakeWorkspace()), + Context.add(PaywallDeployService, fakes.deploy ?? fakeDeploy()), + Context.add(ComponentManifestCacheService, fakes.manifestCache ?? fakeManifestCache()), + Context.add(ComponentCompiler, fakes.compiler ?? fakeCompiler()), + Context.add(PaywallEditSessionService, fakes.editSessions ?? fakeEditSessions()), + Context.add(PaywallArtifactStore, { + getObject: () => Effect.succeed(null), + } as unknown as PaywallArtifactStore["Service"]), + Context.add(SnapshotImageRenderer, { + render: () => Effect.succeed(new Uint8Array([1])), + } as SnapshotImageRenderer["Service"]), + Context.add(AuthSession, {} as never), + ); + +const dispatchWith = (fakes: Fakes, name: string, args: unknown): Promise => { + const tool = findMcpTool(name); + if (tool === undefined) { + throw new Error(`tool ${name} not found`); + } + return Effect.runPromise( + tool.dispatch({ projectId: "proj_1" }, args).pipe(Effect.provide(contextWith(fakes))), + ); +}; + +const dispatch = (name: string, args: unknown): Promise => + dispatchWith({}, name, args); + +describe("MCP tool dispatch", () => { + it("list_paywalls lists the project directories (no input)", async () => { + const result = await dispatch("list_paywalls", {}); + expect(result.isError).toBe(false); + expect(result.output).toContain("pw_1: slug trial (/paywalls/trial)"); + expect(result.output).toContain("pw_2: slug onboarding (/paywalls/onboarding)"); + }); + + it("get_paywall returns the cleaned document JSON with node ids", async () => { + const result = await dispatch("get_paywall", { editSessionId: "pw_edit_1" }); + expect(result.isError).toBe(false); + expect(result.output).toContain('"id": "root1"'); + expect(result.output).toContain('"type": "screen"'); + }); + + it("edit_paywall validates then applies, reporting the new version", async () => { + const result = await dispatch("edit_paywall", { + editSessionId: "pw_edit_1", + edits: [{ op: "insert", parentId: "screen1", node: { type: "view" } }], + }); + expect(result.isError).toBe(false); + expect(result.output).toContain("version 9"); + }); + + it("edit_paywall returns structured validation errors verbatim (no apply)", async () => { + const editConnectedDocument = () => { + throw new Error("editConnectedDocument must not be called on a validation failure"); + }; + const result = await dispatchWith( + { workspace: fakeWorkspace({ editConnectedDocument: editConnectedDocument as never }) }, + "edit_paywall", + // Unknown parent id → validation rejects before any submit. + { + editSessionId: "pw_edit_1", + edits: [{ op: "insert", parentId: "ghost", node: { type: "view" } }], + }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("edit_paywall rejected"); + expect(result.output).toContain("ghost"); + }); + + it("write_component rejects broken source with diagnostics (commits nothing)", async () => { + const writeConnectedComponentSource = () => { + throw new Error("writeConnectedComponentSource must not be called on a compile error"); + }; + const result = await dispatchWith( + { + workspace: fakeWorkspace({ + writeConnectedComponentSource: writeConnectedComponentSource as never, + }), + compiler: fakeCompiler({ + compileAndExtract: () => + Effect.succeed({ + status: "error" as const, + phase: "compile" as const, + diagnostics: [{ message: "Unexpected token", line: 3 }], + }), + }), + }, + "write_component", + { + editSessionId: "pw_edit_1", + path: "components/hero.tsx", + source: "export default (=> {", + }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("write_component rejected"); + expect(result.output).toContain("Unexpected token"); + }); + + it("write_component commits a clean component and records its manifest", async () => { + const recorded: unknown[] = []; + const manifest = { + manifestVersion: 2 as const, + props: {}, + actions: {}, + slot: false, + previewStates: ["default"], + hostData: [], + }; + const result = await dispatchWith( + { + manifestCache: fakeManifestCache({ + record: ((input: unknown) => Effect.sync(() => void recorded.push(input))) as never, + }), + compiler: fakeCompiler({ + compileAndExtract: () => + Effect.succeed({ status: "ready" as const, manifest, previewTrees: {} }), + }), + }, + "write_component", + { + editSessionId: "pw_edit_1", + path: "components/hero.tsx", + source: "export default () => null;", + }, + ); + expect(result.isError).toBe(false); + expect(result.output).toContain("version 9"); + expect(result.output).toContain("manifest recorded"); + expect(recorded).toHaveLength(1); + }); + + it("folds invalid arguments into an isError result (never throws)", async () => { + const result = await dispatch("get_paywall", {}); + expect(result.isError).toBe(true); + expect(result.output).toContain("get_paywall: invalid arguments"); + }); + + it("folds an edit rejection into an isError result with a CLEAN message (no Cause/_tag leak)", async () => { + const result = await dispatchWith( + { + workspace: fakeWorkspace({ + editConnectedDocument: () => + Effect.fail({ + _tag: "WorkspaceWriteConflictError", + message: "lost the concurrency race", + }) as never, + }), + }, + "edit_paywall", + { + editSessionId: "pw_edit_1", + edits: [{ op: "insert", parentId: "screen1", node: { type: "view" } }], + }, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("edit_paywall rejected: lost the concurrency race"); + expect(result.output).not.toContain("Cause("); + expect(result.output).not.toContain("_tag"); + }); +}); diff --git a/apps/backend/src/mcp/protocol.test.ts b/apps/backend/src/mcp/protocol.test.ts new file mode 100644 index 000000000..a16672b20 --- /dev/null +++ b/apps/backend/src/mcp/protocol.test.ts @@ -0,0 +1,216 @@ +/** + * Unit tests for the hand-rolled MCP JSON-RPC handler. The tool executor is + * mocked (a context-free `callTool`), so these cover method dispatch, protocol + * negotiation, the tools/list shape, and the tool-error → `isError` mapping + * without a worker or the workspace service. + */ +import { Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { + handleMcpMessage, + parseJsonRpcMessage, + JsonRpcErrorCode, + SUPPORTED_PROTOCOL_VERSIONS, + type CallTool, + type JsonRpcMessage, + type JsonRpcResponse, +} from "./protocol.ts"; +import type { WorkspaceToolResult } from "../ai/workspace-tools.ts"; + +/** A canned tool executor: echoes the name/args, or fails as an `isError` result. */ +const cannedCallTool = + (result: WorkspaceToolResult): CallTool => + () => + Effect.succeed(result); + +/** Run the handler with a canned executor and no real context (protocol-only). */ +const run = ( + message: JsonRpcMessage, + callTool: CallTool = cannedCallTool({ output: "ok", isError: false }), +): Promise => + Effect.runPromise(handleMcpMessage(message, callTool) as Effect.Effect); + +const msg = ( + method: string, + params?: Record, + id: string | number = 1, +): JsonRpcMessage => ({ + jsonrpc: "2.0", + method, + id, + params, +}); + +describe("parseJsonRpcMessage", () => { + it("accepts a valid request", () => { + const parsed = parseJsonRpcMessage({ jsonrpc: "2.0", method: "ping", id: 1 }); + expect(parsed.ok).toBe(true); + }); + + it("rejects a missing jsonrpc version", () => { + const parsed = parseJsonRpcMessage({ method: "ping", id: 1 }); + expect(parsed.ok).toBe(false); + }); + + it("rejects a missing method", () => { + const parsed = parseJsonRpcMessage({ jsonrpc: "2.0", id: 1 }); + expect(parsed.ok).toBe(false); + }); + + it("rejects a batch (array)", () => { + const parsed = parseJsonRpcMessage([{ jsonrpc: "2.0", method: "ping", id: 1 }]); + expect(parsed.ok).toBe(false); + }); +}); + +describe("initialize", () => { + it("negotiates the requested supported version and advertises tools", async () => { + for (const version of SUPPORTED_PROTOCOL_VERSIONS) { + const response = await run(msg("initialize", { protocolVersion: version })); + expect(response && "result" in response).toBe(true); + const result = (response as { result: Record }).result; + expect(result.protocolVersion).toBe(version); + expect(result.capabilities).toEqual({ tools: {}, resources: {}, prompts: {} }); + expect((result.serverInfo as { name: string }).name).toBe("voidhash-paywall-workspace"); + } + }); + + it("falls back to the latest version for an unsupported request", async () => { + const response = await run(msg("initialize", { protocolVersion: "1999-01-01" })); + const result = (response as { result: Record }).result; + expect(result.protocolVersion).toBe(SUPPORTED_PROTOCOL_VERSIONS[0]); + }); +}); + +describe("notifications/initialized", () => { + it("is accepted with no response (route → 202)", async () => { + const response = await run({ jsonrpc: "2.0", method: "notifications/initialized" }); + expect(response).toBeNull(); + }); +}); + +describe("ping", () => { + it("returns an empty result", async () => { + const response = await run(msg("ping")); + expect((response as { result: unknown }).result).toEqual({}); + }); +}); + +describe("tools/list", () => { + it("returns the tool descriptors with JSON Schema inputs", async () => { + const response = await run(msg("tools/list")); + const result = (response as { result: { tools: Array> } }).result; + expect(result.tools.length).toBe(14); + const listPaywalls = result.tools[0]; + expect(listPaywalls.name).toBe("list_paywalls"); + expect((listPaywalls.inputSchema as { type: string }).type).toBe("object"); + }); +}); + +describe("tools/call", () => { + it("maps a successful tool run to text content (isError false)", async () => { + const response = await run( + msg("tools/call", { name: "read_file", arguments: { path: "/x" } }), + cannedCallTool({ output: "FILE", isError: false }), + ); + const result = (response as { result: Record }).result; + expect(result.isError).toBe(false); + expect(result.content).toEqual([{ type: "text", text: "FILE" }]); + }); + + it("maps a tool failure to isError content, NOT a JSON-RPC error", async () => { + const response = await run( + msg("tools/call", { name: "apply_paywall", arguments: {} }), + cannedCallTool({ output: "apply_paywall rejected: bad", isError: true }), + ); + expect(response && "result" in response).toBe(true); + const result = (response as { result: Record }).result; + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: "text", text: "apply_paywall rejected: bad" }]); + }); + + it("preserves multimodal image content from preview tools", async () => { + const content = [ + { type: "text" as const, text: '{"documentSignature":"doc-1"}' }, + { type: "image" as const, data: "iVBORw0KGgo=", mimeType: "image/png" as const }, + ]; + const response = await run( + msg("tools/call", { name: "get_paywall_preview", arguments: {} }), + cannedCallTool({ output: "preview", isError: false, content }), + ); + expect((response as { result: { content: unknown } }).result.content).toEqual(content); + }); + + it("rejects a missing tool name with InvalidParams", async () => { + const response = await run(msg("tools/call", { arguments: {} })); + const error = (response as { error: { code: number } }).error; + expect(error.code).toBe(JsonRpcErrorCode.InvalidParams); + }); +}); + +describe("resources", () => { + it("lists and reads both authoring skills", async () => { + const listed = await run(msg("resources/list")); + const resources = (listed as { result: { resources: Array<{ uri: string }> } }).result + .resources; + expect(resources[0]?.uri).toBe("voidhash://skills/paywall-authoring"); + expect(resources[1]?.uri).toBe("voidhash://skills/code-component-authoring"); + + const read = await run(msg("resources/read", { uri: "voidhash://skills/paywall-authoring" })); + const text = (read as { result: { contents: Array<{ text: string }> } }).result.contents[0] + ?.text; + expect(text).toContain("begin_paywall_edit"); + expect(text).toContain("Document model"); + expect(text).toContain("Variables, states, and actions"); + expect(text).toContain("selected_product"); + + const componentRead = await run( + msg("resources/read", { uri: "voidhash://skills/code-component-authoring" }), + ); + const componentText = (componentRead as { result: { contents: Array<{ text: string }> } }) + .result.contents[0]?.text; + expect(componentText).toContain("Custom designer panels"); + expect(componentText).toContain("Runtime animation and gestures"); + expect(componentText).toContain("useMotionValue"); + }); +}); + +describe("prompts", () => { + it("offers a design prompt with the verified lifecycle", async () => { + const listed = await run(msg("prompts/list")); + expect( + (listed as { result: { prompts: Array<{ name: string }> } }).result.prompts[0]?.name, + ).toBe("design_paywall"); + const response = await run( + msg("prompts/get", { + name: "design_paywall", + arguments: { paywallId: "pw_1", request: "Improve hierarchy" }, + }), + ); + const text = ( + response as { + result: { messages: Array<{ content: { text: string } }> }; + } + ).result.messages[0]?.content.text; + expect(text).toContain('paywall "pw_1"'); + expect(text).toContain("get_paywall_preview"); + expect(text).toContain("dynamic behavior without code"); + expect(text).toContain("actionBindings"); + expect(text).toContain("Improve hierarchy"); + }); +}); + +describe("unknown method", () => { + it("returns method-not-found", async () => { + const response = await run(msg("completion/complete")); + const error = (response as { error: { code: number; message: string } }).error; + expect(error.code).toBe(JsonRpcErrorCode.MethodNotFound); + expect(error.message).toContain("completion/complete"); + }); + + it("accepts an unknown notification silently", async () => { + const response = await run({ jsonrpc: "2.0", method: "notifications/cancelled" }); + expect(response).toBeNull(); + }); +}); diff --git a/apps/backend/src/mcp/protocol.ts b/apps/backend/src/mcp/protocol.ts new file mode 100644 index 000000000..914d34b23 --- /dev/null +++ b/apps/backend/src/mcp/protocol.ts @@ -0,0 +1,310 @@ +/** + * Minimal, hand-rolled MCP JSON-RPC 2.0 handler for the STATELESS streamable-HTTP + * transport. Editing state lives behind `editSessionId`; the transport itself + * answers each `POST /api/mcp` with a single JSON response (or 202 for a + * notification), which is spec-compliant and exactly what Claude Code's + * streamable-HTTP client accepts. + * + * This module is transport- and auth-free: {@link handleMcpMessage} takes an + * already-parsed JSON-RPC message plus a `callTool` executor (the route supplies + * one bound to the authenticated project scope) and returns either a JSON-RPC + * response object to serialize, or `null` for an accepted notification (the route + * maps that to HTTP 202). Method dispatch, protocol-version negotiation, and the + * error taxonomy live here so they can be unit-tested without a worker. + * + * Implemented methods: `initialize`, `notifications/initialized`, `tools/list`, + * `tools/call`, `resources/list`, `resources/read`, `prompts/list`, + * `prompts/get`, and `ping`. Unknown methods → JSON-RPC method-not-found. Tool + * errors are `isError: true` content, never JSON-RPC errors (per MCP). + */ +import { Effect } from "effect"; + +import { mcpToolDescriptors } from "./tool-manifest.ts"; +import * as WorkspaceTools from "../ai/workspace-tools.ts"; +import { + findSkill, + listSkills, + skillFromResourceUri, + skillResourceUri, +} from "../ai/skills/registry.ts"; + +/** Protocol versions we speak, newest first (used to pick the negotiated version). */ +export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"] as const; +const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]; + +/** Server identity advertised in the `initialize` result. */ +const SERVER_INFO = { name: "voidhash-paywall-workspace", version: "1.0.0" } as const; +const AUTHORING_RESOURCE_URI = "voidhash://paywall-authoring/reference"; +const DESIGN_PROMPT_NAME = "design_paywall"; + +const mcpAuthoringGuide = (): string => findSkill("paywall-authoring")?.body() ?? ""; + +/** Standard JSON-RPC 2.0 error codes we emit. */ +export const JsonRpcErrorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, +} as const; + +/** A JSON-RPC id (string or number, or null on a pre-id parse error). */ +export type JsonRpcId = string | number | null; + +/** A parsed JSON-RPC request/notification (validated by {@link parseJsonRpcMessage}). */ +export interface JsonRpcMessage { + readonly jsonrpc: "2.0"; + readonly method: string; + readonly id?: JsonRpcId; + readonly params?: Record; +} + +/** A JSON-RPC success/error response object to serialize back to the client. */ +export type JsonRpcResponse = + | { jsonrpc: "2.0"; id: JsonRpcId; result: unknown } + | { jsonrpc: "2.0"; id: JsonRpcId; error: { code: number; message: string; data?: unknown } }; + +/** Executes a validated tool call against the authenticated scope. */ +export type CallTool = ( + name: string, + args: unknown, +) => Effect.Effect; + +const success = (id: JsonRpcId, result: unknown): JsonRpcResponse => ({ + jsonrpc: "2.0", + id, + result, +}); + +const failure = ( + id: JsonRpcId, + code: number, + message: string, + data?: unknown, +): JsonRpcResponse => ({ + jsonrpc: "2.0", + id, + error: data === undefined ? { code, message } : { code, message, data }, +}); + +/** + * Validate an already-JSON-parsed value as a JSON-RPC 2.0 message. Returns the + * narrowed message or an `InvalidRequest` reason (missing `jsonrpc`/`method`). + * A notification is a message with no `id`. + */ +export const parseJsonRpcMessage = ( + value: unknown, +): { ok: true; message: JsonRpcMessage } | { ok: false; reason: string } => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + // Batches (arrays) are not supported by this stateless single-response server. + return { ok: false, reason: "Expected a single JSON-RPC 2.0 request object" }; + } + const record = value as Record; + if (record.jsonrpc !== "2.0") { + return { ok: false, reason: 'Missing or invalid "jsonrpc": expected "2.0"' }; + } + if (typeof record.method !== "string") { + return { ok: false, reason: 'Missing or invalid "method"' }; + } + const id = record.id; + if (id !== undefined && id !== null && typeof id !== "string" && typeof id !== "number") { + return { ok: false, reason: 'Invalid "id": expected string, number, or null' }; + } + const params = + record.params !== undefined && typeof record.params === "object" && record.params !== null + ? (record.params as Record) + : undefined; + return { + ok: true, + message: { jsonrpc: "2.0", method: record.method, id: id as JsonRpcId, params }, + }; +}; + +/** Negotiate a protocol version: echo a supported one, else offer our latest. */ +const negotiateProtocolVersion = (requested: unknown): string => + typeof requested === "string" && + (SUPPORTED_PROTOCOL_VERSIONS as ReadonlyArray).includes(requested) + ? requested + : LATEST_PROTOCOL_VERSION; + +/** The `initialize` result: negotiated version, tool capability, server identity. */ +const initializeResult = (params: Record | undefined) => ({ + protocolVersion: negotiateProtocolVersion(params?.protocolVersion), + capabilities: { tools: {}, resources: {}, prompts: {} }, + serverInfo: SERVER_INFO, + instructions: + "Begin a paywall edit session before using scoped tools. The returned editSessionId is the connection handle. Prefer document variables, conditional states, and actions for dynamic behavior without code. Review the final PNG preview and finish with its exact document signature, or revert the session. Use the bash tool to research paywalls read-only before editing (`cat /README.md` for the layout).", +}); + +/** The `tools/list` result: the advertised tool descriptors. */ +const toolsListResult = () => ({ tools: mcpToolDescriptors() }); + +const resourcesListResult = () => ({ + resources: listSkills().map((skill) => ({ + uri: skillResourceUri(skill.name), + name: skill.name, + title: skill.name === "paywall-authoring" ? "Voidhash paywall authoring reference" : skill.name, + description: skill.description, + mimeType: "text/markdown", + })), +}); + +const promptsListResult = () => ({ + prompts: [ + { + name: DESIGN_PROMPT_NAME, + title: "Design a paywall", + description: + "Start a visually verified MCP paywall-authoring workflow with dynamic no-code behavior.", + arguments: [ + { name: "paywallId", description: "Stable paywall id to edit.", required: true }, + { name: "request", description: "What to change or create.", required: false }, + ], + }, + ], +}); + +/** + * Handle a `tools/call`: read `name` + `arguments`, run the executor, and shape + * the result as MCP content. A tool failure is `isError: true` content (never a + * JSON-RPC error). A missing/invalid `name` is `InvalidParams` (a protocol + * error, not a tool error). The executor never fails — the shared core folds + * workspace failures into `{ isError }` — so this always resolves to a response. + */ +const handleToolsCall = ( + id: JsonRpcId, + params: Record | undefined, + callTool: CallTool, +): Effect.Effect => { + const name = params?.name; + if (typeof name !== "string" || name.length === 0) { + return Effect.succeed( + failure(id, JsonRpcErrorCode.InvalidParams, 'tools/call requires a string "name"'), + ); + } + const args = params?.arguments ?? {}; + return callTool(name, args).pipe( + Effect.map((result) => + success(id, { + content: result.content ?? [{ type: "text", text: result.output }], + isError: result.isError, + }), + ), + ); +}; + +/** + * Dispatch one parsed JSON-RPC message. Returns a {@link JsonRpcResponse} to + * serialize, or `null` for an accepted notification (`notifications/*`, id-less) + * — the route answers those with HTTP 202 and an empty body. + * + * `callTool` runs a tool against the authenticated project scope; it (and this + * effect) require the workspace/chat/auth context, provided by the route. + */ +export const handleMcpMessage = ( + message: JsonRpcMessage, + callTool: CallTool, +): Effect.Effect => { + const id: JsonRpcId = message.id ?? null; + + switch (message.method) { + case "initialize": + return Effect.succeed(success(id, initializeResult(message.params))); + + case "notifications/initialized": + // Accepted notification: no response body (the route sends 202). + return Effect.succeed(null); + + case "ping": + // MCP ping → empty result. + return Effect.succeed(success(id, {})); + + case "tools/list": + return Effect.succeed(success(id, toolsListResult())); + + case "tools/call": + return handleToolsCall(id, message.params, callTool); + + case "resources/list": + return Effect.succeed(success(id, resourcesListResult())); + + case "resources/read": { + const requestedUri = message.params?.uri; + const skill = + requestedUri === AUTHORING_RESOURCE_URI + ? findSkill("paywall-authoring") + : typeof requestedUri === "string" + ? skillFromResourceUri(requestedUri) + : undefined; + if (skill === undefined || typeof requestedUri !== "string") { + return Effect.succeed( + failure(id, JsonRpcErrorCode.InvalidParams, "Unknown skill resource URI"), + ); + } + return Effect.succeed( + success(id, { + contents: [ + { + uri: requestedUri, + mimeType: "text/markdown", + text: skill.body(), + }, + ], + }), + ); + } + + case "prompts/list": + return Effect.succeed(success(id, promptsListResult())); + + case "prompts/get": { + if (message.params?.name !== DESIGN_PROMPT_NAME) { + return Effect.succeed( + failure(id, JsonRpcErrorCode.InvalidParams, "Unknown paywall authoring prompt"), + ); + } + const args = + message.params.arguments !== null && typeof message.params.arguments === "object" + ? (message.params.arguments as Record) + : {}; + const paywallId = args.paywallId; + if (typeof paywallId !== "string" || paywallId.length === 0) { + return Effect.succeed( + failure( + id, + JsonRpcErrorCode.InvalidParams, + 'design_paywall requires a string "paywallId"', + ), + ); + } + const request = + typeof args.request === "string" && args.request.length > 0 + ? `\nRequested outcome: ${args.request}` + : ""; + return Effect.succeed( + success(id, { + description: `Design paywall ${paywallId} with a visually verified edit session.`, + messages: [ + { + role: "user", + content: { + type: "text", + text: `Design paywall "${paywallId}" using the Voidhash MCP workflow.${request}\n\n${mcpAuthoringGuide()}`, + }, + }, + ], + }), + ); + } + + default: { + // Any other `notifications/*` is an id-less message we accept silently. + if (message.method.startsWith("notifications/") && message.id === undefined) { + return Effect.succeed(null); + } + return Effect.succeed( + failure(id, JsonRpcErrorCode.MethodNotFound, `Method not found: ${message.method}`), + ); + } + } +}; diff --git a/apps/backend/src/mcp/tool-manifest.test.ts b/apps/backend/src/mcp/tool-manifest.test.ts new file mode 100644 index 000000000..dd729b660 --- /dev/null +++ b/apps/backend/src/mcp/tool-manifest.test.ts @@ -0,0 +1,148 @@ +/** + * Contract test for the stateful MCP editing manifest: the advertised tool set, + * each descriptor's JSON Schema shape, and that a validated tool's dispatcher + * folds an invalid-argument call into an `isError` tool result (never a throw). + */ +import { Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { findMcpTool, MCP_TOOLS, mcpToolDescriptors } from "./tool-manifest.ts"; + +describe("MCP tool manifest", () => { + it("advertises the document-first tool set", () => { + const names = mcpToolDescriptors().map((d) => d.name); + expect(names).toEqual([ + "list_paywalls", + "bash", + "begin_paywall_edit", + "get_paywall", + "get_components", + "read_component", + "edit_paywall", + "duplicate_subtree", + "write_component", + "rename_component", + "delete_component", + "get_paywall_preview", + "finish_paywall_edit", + "revert_paywall_edit", + ]); + }); + + it("no longer advertises the deleted stateless-build tools", () => { + const names = mcpToolDescriptors().map((d) => d.name); + for (const removed of [ + "read_file", + "get_diagnostics", + "validate_paywall", + "apply_paywall", + ]) { + expect(names).not.toContain(removed); + } + }); + + it("bash requires a non-empty command", async () => { + const tool = findMcpTool("bash")!; + expect(tool.descriptor.inputSchema.required).toEqual(["command"]); + const result = await Effect.runPromise( + tool.dispatch({ projectId: "proj_1" }, { command: "" }) as Effect.Effect<{ + output: string; + isError: boolean; + }>, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("invalid arguments"); + }); + + it("every descriptor has an object JSON Schema with a description", () => { + for (const { descriptor } of MCP_TOOLS) { + expect(descriptor.description.length).toBeGreaterThan(0); + expect(descriptor.inputSchema.type).toBe("object"); + } + }); + + it("list_paywalls takes no input (empty object schema)", () => { + const tool = findMcpTool("list_paywalls"); + expect(tool?.descriptor.inputSchema.properties).toEqual({}); + }); + + it("list_paywalls rejects unexpected input", async () => { + const tool = findMcpTool("list_paywalls")!; + const result = await Effect.runPromise( + tool.dispatch({ projectId: "proj_1" }, { paywallId: "pw_1" }) as Effect.Effect<{ + output: string; + isError: boolean; + }>, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("invalid arguments"); + }); + + it("get_paywall requires an edit session and allows nodeId + depth", () => { + const schema = findMcpTool("get_paywall")!.descriptor.inputSchema; + expect((schema.required as string[]).sort()).toEqual(["editSessionId"]); + const props = schema.properties as Record; + expect(props).toHaveProperty("nodeId"); + expect(props).toHaveProperty("depth"); + }); + + it("edit_paywall requires an edit session + a non-empty edits array", () => { + const schema = findMcpTool("edit_paywall")!.descriptor.inputSchema; + const required = (schema.required as string[] | undefined) ?? []; + expect(required).toContain("editSessionId"); + expect(required).toContain("edits"); + const edits = (schema.properties as Record).edits; + expect(edits.type).toBe("array"); + }); + + it("write_component requires editSessionId + path + source", () => { + const schema = findMcpTool("write_component")!.descriptor.inputSchema; + const required = ((schema.required as string[] | undefined) ?? []).sort(); + expect(required).toEqual(["editSessionId", "path", "source"]); + }); + + it("rename_component requires editSessionId + fromPath + toPath", () => { + const schema = findMcpTool("rename_component")!.descriptor.inputSchema; + const required = ((schema.required as string[] | undefined) ?? []).sort(); + expect(required).toEqual(["editSessionId", "fromPath", "toPath"]); + }); + + it("advertises the explicit preview-gated lifecycle", () => { + expect(findMcpTool("begin_paywall_edit")!.descriptor.inputSchema.required as string[]).toEqual([ + "paywallId", + ]); + const previewRequired = + (findMcpTool("get_paywall_preview")!.descriptor.inputSchema.required as string[]) ?? []; + expect(previewRequired.sort()).toEqual(["editSessionId"]); + const finishRequired = + (findMcpTool("finish_paywall_edit")!.descriptor.inputSchema.required as string[]) ?? []; + expect(finishRequired).toEqual( + expect.arrayContaining(["editSessionId", "reviewedDocumentSignature", "verdict"]), + ); + }); + + it("a validated tool folds invalid arguments into an isError result (no throw)", async () => { + const tool = findMcpTool("edit_paywall")!; + const result = await Effect.runPromise( + // Missing required `edits` — the dispatcher validates and folds the failure. + tool.dispatch({ projectId: "proj_1" }, { editSessionId: "pw_edit_1" }) as Effect.Effect<{ + output: string; + isError: boolean; + }>, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("invalid arguments"); + }); + + it("rejects redundant paywallId arguments after a session is opened", async () => { + const tool = findMcpTool("get_paywall")!; + const result = await Effect.runPromise( + tool.dispatch( + { projectId: "proj_1" }, + { editSessionId: "pw_edit_1", paywallId: "pw_1" }, + ) as Effect.Effect<{ output: string; isError: boolean }>, + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("invalid arguments"); + }); +}); diff --git a/apps/backend/src/mcp/tool-manifest.ts b/apps/backend/src/mcp/tool-manifest.ts new file mode 100644 index 000000000..bbe6356fb --- /dev/null +++ b/apps/backend/src/mcp/tool-manifest.ts @@ -0,0 +1,304 @@ +/** + * The MCP tool manifest: the JSON-Schema-described tools advertised over + * `tools/list` and dispatched over `tools/call`, plus the mapping from a tool + * name to the shared workspace-tool core ({@link WorkspaceTools}). + * + * MCP is document-first and stateful at the editing layer — there is no fork, no + * `paywall.tsx`, and no whole-target overwrite. Composition is read as cleaned + * document JSON and edited over a leased Mimic participant connection; code + * components are managed by their canonical `components/.tsx` path. + * Explicit edit sessions wrap scoped operations, subtree duplication is + * first-class, and finishing is gated on inspecting a version-bound PNG preview. + * + * **Schema alignment**: each validated tool's `inputSchema` is derived at module + * load from the SAME zod schema its dispatcher parses with, via `z.toJSONSchema` + * (zod 4, native). Deriving — rather than hand-writing — means the JSON Schema an + * MCP client validates against can never drift from the zod schema the executor + * parses with; the contract test (`mcp/tool-manifest.test.ts`) round-trips valid + * + invalid samples through both to prove structural agreement. + */ +import { documentEditSchema } from "@voidhash/ai-shared"; +import { Effect } from "effect"; +import { z } from "zod"; + +import * as WorkspaceTools from "../ai/workspace-tools.ts"; + +/** A JSON Schema object (the `inputSchema` advertised for a tool). */ +export type JsonSchema = Record; + +/** The description of one advertised MCP tool. */ +export interface McpToolDescriptor { + readonly name: string; + readonly description: string; + readonly inputSchema: JsonSchema; +} + +/** + * One MCP tool: its advertised descriptor + a dispatcher that validates the raw + * arguments with the aligned zod schema and runs the shared workspace-tool + * core. `dispatch` never fails (`E = never`) — the core folds workspace failures + * into a `{ isError: true }` result; an invalid-arguments failure is likewise + * folded into a tool result (MCP maps a bad-input tool call to `isError`, not a + * JSON-RPC error). + */ +export interface McpTool { + readonly descriptor: McpToolDescriptor; + readonly dispatch: ( + scope: WorkspaceTools.WorkspaceToolScope, + args: unknown, + ) => Effect.Effect; +} + +/** + * MCP tool input schemas. The internal Pi agent consumes this same manifest, + * and `documentEditSchema` remains the single validation vocabulary for every + * server-executed document batch. + */ +const mcpToolSchemas = { + list_paywalls: z.strictObject({}), + bash: z.strictObject({ + command: z + .string() + .min(1) + .describe( + "Bash command line. Pipes, &&, and redirects into /tmp are allowed; each call is a fresh shell (no state across calls).", + ), + }), + begin_paywall_edit: z.strictObject({ + paywallId: z.string().describe("Stable id of the paywall to edit (from list_paywalls)."), + }), + get_paywall: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + nodeId: z + .string() + .optional() + .describe( + "Optional node id to root the returned tree at (defaults to the whole document). Use to zoom into a subtree; ids come from a prior get_paywall.", + ), + depth: z + .number() + .int() + .optional() + .describe( + "Optional max depth from the root. Nodes past the limit render as stubs `{ id, type, name?, childCount }` you expand with a follow-up get_paywall(nodeId).", + ), + }), + get_components: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + }), + read_component: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + path: z + .string() + .describe("Canonical document-relative path of a LOCAL component (`components/.tsx`)."), + }), + edit_paywall: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + edits: z + .array(documentEditSchema) + .min(1) + .describe( + "Ordered batch of document edits, applied ATOMICALLY (all-or-nothing) against the LIVE document. Returns minted ids for inserts, or per-edit structured errors naming the offending node/field/value. Setting any background/border/shadow (or path fill/stroke) style field automatically sets the group's `*Enabled` flag to true; set it to `false` explicitly to hide the group non-destructively.", + ), + }), + duplicate_subtree: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + nodeId: z.string().describe("Id of the visual subtree to clone."), + parentId: z.string().describe("Id of the destination parent."), + index: z.number().int().optional().describe("Optional destination child index."), + nextName: z.string().optional().describe("Optional display name for the cloned root."), + }), + write_component: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + path: z + .string() + .describe( + "Canonical document-relative path (`components/.tsx`). Writing a path that does not exist yet CREATES the component (its path IS its identity).", + ), + source: z.string().describe("Full TSX component source."), + }), + rename_component: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + fromPath: z.string().describe("Existing component path (`components/.tsx`)."), + toPath: z + .string() + .describe( + "New component path. Instances referencing the old path are re-pointed automatically.", + ), + }), + delete_component: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + path: z.string().describe("Component path to delete (`components/.tsx`)."), + }), + get_paywall_preview: z.strictObject({ + editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."), + width: z + .number() + .int() + .min(240) + .max(1440) + .optional() + .describe("Viewport width; defaults to 375."), + height: z + .number() + .int() + .min(240) + .max(1600) + .optional() + .describe("Viewport height; defaults to 812."), + scale: z + .union([z.literal(1), z.literal(2)]) + .optional() + .describe("Device scale; defaults to 1."), + }), + finish_paywall_edit: z.strictObject({ + editSessionId: z.string().describe("Active edit session to finish."), + reviewedDocumentSignature: z + .string() + .describe("Exact documentSignature from the latest get_paywall_preview result."), + verdict: z.string().min(1).describe("Concise visual QA verdict based on the preview image."), + unresolvedIssues: z + .array(z.string()) + .default([]) + .describe("Any remaining visual issues. Must be empty to finish."), + }), + revert_paywall_edit: z.strictObject({ + editSessionId: z.string().describe("Edit session whose edits should be reverted."), + }), +} as const; + +/** + * Build a tool that validates its arguments with `schema`, then runs `run` with + * the parsed input. A zod parse failure is folded into an `isError` tool result + * (never a JSON-RPC error), matching the shared core's fold-don't-throw contract. + */ +const validatedTool = ( + name: string, + description: string, + schema: z.ZodType, + run: ( + scope: WorkspaceTools.WorkspaceToolScope, + input: Input, + ) => Effect.Effect, +): McpTool => ({ + descriptor: { + name, + description, + inputSchema: z.toJSONSchema(schema) as JsonSchema, + }, + dispatch: (scope, args) => { + const parsed = schema.safeParse(args); + if (!parsed.success) { + const issues = parsed.error.issues + .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("; "); + return Effect.succeed({ + output: `${name}: invalid arguments — ${issues}`, + isError: true, + }); + } + return run(scope, parsed.data); + }, +}); + +/** + * The MCP tools, in advertised order: discovery (`list_paywalls`), research + * (`bash` over the read-only workspace VFS), read (`get_paywall` / + * `get_components` / `read_component`), then the write surface (`edit_paywall` + * for composition, `write_component` / `rename_component` / `delete_component` + * for code components). + */ +export const MCP_TOOLS: ReadonlyArray = [ + validatedTool( + "list_paywalls", + "List every paywall in the project with its stable paywallId, display slug, and workspace path. Pass a paywallId to begin_paywall_edit, then use its editSessionId for every scoped tool.", + mcpToolSchemas.list_paywalls, + (scope) => WorkspaceTools.listPaywalls(scope), + ), + validatedTool( + "bash", + "Run a read-only bash command over a virtual filesystem projecting this project's paywall workspace — use it to RESEARCH, not edit. Standard tools work: ls, cat, grep, rg, find, head, tail, sed, awk, jq, wc, sort, diff, tree. Layout: /README.md (this map), /paywalls//document.json (cleaned document JSON, same shape as get_paywall), /paywalls//components/.tsx (local component TSX sources). Directory names are paywall ids — pass them directly to begin_paywall_edit. Everything except /tmp is READ-ONLY (writes fail with EROFS); /tmp lives only within this single call, so chain steps with && and pipes. Custom command: `voidhash` (help), `voidhash paywalls` (paywallId + slug listing). No network, no state across calls; stdout is capped at ~40KB — prefer targeted greps over dumping whole trees (grep -r /paywalls loads every paywall document; fine occasionally, not per query). To modify anything, use begin_paywall_edit + edit_paywall/write_component.", + mcpToolSchemas.bash, + WorkspaceTools.runBash, + ), + validatedTool( + "begin_paywall_edit", + "Begin an explicit edit session for one paywall. This opens an independent Mimic participant connection, captures its revert baseline, and returns the editSessionId required by every scoped tool. Multiple users and agents may edit the same paywall concurrently.", + mcpToolSchemas.begin_paywall_edit, + WorkspaceTools.beginPaywallEdit, + ), + validatedTool( + "get_paywall", + "Read a paywall's LIVE document as cleaned JSON: a nested tree of `{ id, type, name?, ...data, children }` nodes with schema-default fields stripped. Every node has a stable `id` you address in edit_paywall. Pass `nodeId` to root at a subtree and `depth` to cap the tree (deeper nodes come back as stubs you can expand). `codeComponent` definitions come back with their `path` and a `sourceLength` only — the TSX source is NOT inlined; read it with read_component. Call this before editing to learn the current structure and ids.", + mcpToolSchemas.get_paywall, + WorkspaceTools.getPaywall, + ), + validatedTool( + "get_components", + "List every component you can place in a paywall: CATALOG components (deployed, shared across the project), the paywall's LOCAL code components, and BUILTIN primitives. Entries carry their slug/path and authoring contract (props, actions, preview states) plus the insertion identity. A local component with no compiled manifest yet is listed with a note; read_component to see its source.", + mcpToolSchemas.get_components, + WorkspaceTools.getComponents, + ), + validatedTool( + "read_component", + "Read a LOCAL code component's TSX source by its `components/.tsx` path. Use get_components first to discover paths.", + mcpToolSchemas.read_component, + WorkspaceTools.readComponent, + ), + validatedTool( + "edit_paywall", + "Apply an ATOMIC batch of edits to a paywall's LIVE document (all-or-nothing). Ops: `insert` (add a subtree under a parent at an index — ids are engine-minted and RETURNED so you can address the new nodes next), `update` (partial data change; `set.style` merges per style field, other objects merge per-field, arrays/scalars replace wholesale), `move` (reparent; cycles and illegal containment are rejected), `remove` (delete a subtree), `replaceChildren` (swap a node's whole child list). Address nodes by the ids from get_paywall. Setting any background/border/shadow (or path fill/stroke) style field automatically sets the group's `*Enabled` flag to true; set it to `false` explicitly to hide the group non-destructively. Invalid fields/values are rejected with per-edit errors naming the offending node, the allowed fields (with a did-you-mean), and the allowed values — read them and correct your edit, then retry. On success the minted ids are returned keyed by op index.", + mcpToolSchemas.edit_paywall, + WorkspaceTools.editPaywall, + ), + validatedTool( + "duplicate_subtree", + "Duplicate an existing visual subtree under a destination parent. Engine-managed root/library/codeComponent nodes cannot be cloned. All cloned ids are freshly minted and returned by the underlying atomic edit.", + mcpToolSchemas.duplicate_subtree, + WorkspaceTools.duplicateSubtree, + ), + validatedTool( + "write_component", + "Create-or-replace a LOCAL code component at `components/.tsx` (its path is its identity). The source is COMPILED server-side first: on compile/runtime diagnostics nothing is committed and the diagnostics are returned — fix them and retry. On success the component is committed and becomes placeable via a `component` node (insert one with edit_paywall). Use for anything that is genuinely code (custom logic/layout), not for plain composition — compose visual structure with edit_paywall.", + mcpToolSchemas.write_component, + WorkspaceTools.writeComponent, + ), + validatedTool( + "rename_component", + "Rename a local component from one `components/.tsx` path to another. Instances referencing the old path are re-pointed automatically (rename cascade).", + mcpToolSchemas.rename_component, + WorkspaceTools.renameComponent, + ), + validatedTool( + "delete_component", + "Delete a local component by path. Existing instances of it degrade to placeholders (they are not cascade-deleted), so replace or remove them with edit_paywall afterward.", + mcpToolSchemas.delete_component, + WorkspaceTools.deleteComponent, + ), + validatedTool( + "get_paywall_preview", + "Render the current live paywall to a PNG and return it as MCP image content plus document version and signature. Visually inspect the image. A successful full preview is required before finish_paywall_edit, and any later document change invalidates it.", + mcpToolSchemas.get_paywall_preview, + WorkspaceTools.getPaywallPreview, + ), + validatedTool( + "finish_paywall_edit", + "Finish an active edit session only after visual QA of its latest get_paywall_preview. Pass the exact document signature, a concise verdict, and an empty unresolvedIssues list. Stale or missing previews are rejected.", + mcpToolSchemas.finish_paywall_edit, + WorkspaceTools.finishPaywallEdit, + ), + validatedTool( + "revert_paywall_edit", + "Revert an edit session by reconciling the live paywall to the baseline captured by begin_paywall_edit. Revert is refused when doing so could overwrite another participant's multiplayer edits.", + mcpToolSchemas.revert_paywall_edit, + WorkspaceTools.revertPaywallEdit, + ), +]; + +/** The advertised tool descriptors for `tools/list`. */ +export const mcpToolDescriptors = (): ReadonlyArray => + MCP_TOOLS.map((tool) => tool.descriptor); + +/** Look up a tool by name for `tools/call`; `undefined` for an unknown tool. */ +export const findMcpTool = (name: string): McpTool | undefined => + MCP_TOOLS.find((tool) => tool.descriptor.name === name); diff --git a/apps/backend/src/routes/event-capture.ts b/apps/backend/src/routes/event-capture.ts new file mode 100644 index 000000000..d3fc3c312 --- /dev/null +++ b/apps/backend/src/routes/event-capture.ts @@ -0,0 +1,177 @@ +/** + * HTTP route handlers for the analytics-ingest event-capture endpoints (`/i/*`), + * served by the Cloudflare-native backend worker (the former standalone + * AnalyticsPipelineWorker was merged into it). + * + * The capture business logic lives in {@link EventCaptureService} (which + * already encapsulates token validation, policy enforcement, route selection, + * and queue publication). This file is responsible for HTTP-shape concerns + * only: request-id minting, client-IP extraction, response shaping, and error + * mapping at the wire boundary. + * + * Mirrors `internal/apps/event-capture/src/http/routes.ts` line-for-line in + * intent; the only deltas are the Cloudflare-native client-IP extraction + * (`CF-Connecting-IP`) and the absence of a `/i/ready` route (Cloudflare + * Workers don't have a startup-readiness check the way Bun servers do). + */ +import { + CaptureAcceptedResponse, + CaptureDependencyUnavailableError, + CaptureInternalServerError, + CaptureRateLimitedError, + EventCaptureApi, +} from "@voidhash/api-contracts/event-capture"; +import { EventCaptureService } from "@voidhash/core/services/analyticsIngest/EventCaptureService"; +import { Effect } from "effect"; +import * as HttpEffect from "effect/unstable/http/HttpEffect"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +/** + * Extract the originating client IP from the request headers. Cloudflare + * always populates `cf-connecting-ip`; the `x-forwarded-for` fallback only + * matters for local `wrangler dev` requests. + */ +const extractClientIp = ( + headers: Readonly>, +): string | undefined => { + const cfIp = headers["cf-connecting-ip"]?.trim(); + if (cfIp) return cfIp; + + const forwardedFor = headers["x-forwarded-for"]?.split(",")[0]?.trim(); + return forwardedFor || undefined; +}; + +const appendRequestIdHeader = (requestId: string) => + HttpEffect.appendPreResponseHandler((_req, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "x-request-id", requestId)), + ); + +const appendRetryAfterHeader = (retryAfterMs: number) => + HttpEffect.appendPreResponseHandler((_req, response) => + Effect.succeed( + HttpServerResponse.setHeader(response, "retry-after", String(Math.ceil(retryAfterMs / 1000))), + ), + ); + +export const EventCaptureGroupLive = HttpApiBuilder.group( + EventCaptureApi, + "event_capture", + (handlers) => + Effect.gen(function* () { + const captureService = yield* EventCaptureService; + return handlers + .handle("capture", ({ request, payload }) => + Effect.gen(function* () { + const requestId = `req_${crypto.randomUUID()}`; + yield* appendRequestIdHeader(requestId); + + const result = yield* captureService + .captureEvents({ + events: [payload], + request: { + clientIp: extractClientIp(request.headers), + path: "/i/v1/capture", + receivedAt: new Date(), + sentAt: payload.sent_at, + token: payload.token, + headers: request.headers, + requestId, + }, + }) + .pipe( + Effect.catchTag("CaptureRateLimitedError", (error) => + Effect.gen(function* () { + if (typeof error.retry_after_ms === "number") { + yield* appendRetryAfterHeader(error.retry_after_ms); + } + return yield* Effect.fail( + new CaptureRateLimitedError({ + code: error.code, + error: error.error, + }), + ); + }), + ), + Effect.catchTag("EventCaptureServiceError", () => + Effect.fail( + new CaptureDependencyUnavailableError({ + code: "dependency_unavailable", + error: "capture dependency is unavailable", + }), + ), + ), + Effect.catchDefect(() => + Effect.fail( + new CaptureInternalServerError({ + code: "internal_error", + error: "internal server error", + }), + ), + ), + ); + + return new CaptureAcceptedResponse({ + accepted: result.accepted, + rejected: result.rejected, + }); + }), + ) + .handle("batch", ({ request, payload }) => + Effect.gen(function* () { + const requestId = `req_${crypto.randomUUID()}`; + yield* appendRequestIdHeader(requestId); + + const result = yield* captureService + .captureEvents({ + events: payload.events, + request: { + clientIp: extractClientIp(request.headers), + path: "/i/v1/batch", + receivedAt: new Date(), + sentAt: payload.sent_at, + token: payload.token, + headers: request.headers, + requestId, + }, + }) + .pipe( + Effect.catchTag("CaptureRateLimitedError", (error) => + Effect.gen(function* () { + if (typeof error.retry_after_ms === "number") { + yield* appendRetryAfterHeader(error.retry_after_ms); + } + return yield* Effect.fail( + new CaptureRateLimitedError({ + code: error.code, + error: error.error, + }), + ); + }), + ), + Effect.catchTag("EventCaptureServiceError", () => + Effect.fail( + new CaptureDependencyUnavailableError({ + code: "dependency_unavailable", + error: "capture dependency is unavailable", + }), + ), + ), + Effect.catchDefect(() => + Effect.fail( + new CaptureInternalServerError({ + code: "internal_error", + error: "internal server error", + }), + ), + ), + ); + + return new CaptureAcceptedResponse({ + accepted: result.accepted, + rejected: result.rejected, + }); + }), + ); + }), +); diff --git a/apps/backend/src/routes/mcp-authkit.test.ts b/apps/backend/src/routes/mcp-authkit.test.ts new file mode 100644 index 000000000..808a21544 --- /dev/null +++ b/apps/backend/src/routes/mcp-authkit.test.ts @@ -0,0 +1,46 @@ +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { describe, expect, it } from "vite-plus/test"; + +import { makeMcpAuthKit, McpAuthKit } from "../McpAuthKit.ts"; +import { McpAuthKitRouteLayer } from "./mcp-authkit.ts"; + +const serve = (path: string, authorizationServer?: string) => + Effect.gen(function* () { + const authKit = Layer.succeed( + McpAuthKit, + McpAuthKit.of(makeMcpAuthKit(authorizationServer, undefined)), + ); + const handler = yield* HttpRouter.toHttpEffect( + McpAuthKitRouteLayer.pipe(HttpRouter.provideRequest(authKit)), + ); + const response = yield* handler.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request(`https://api.example.com${path}`)), + ), + ); + return HttpServerResponse.toWeb(response); + }).pipe(Effect.scoped, Effect.runPromise); + +describe("MCP AuthKit metadata", () => { + it("points the protected resource at the configured AuthKit issuer", async () => { + const response = await serve( + "/.well-known/oauth-protected-resource/api/mcp", + "https://example.authkit.app", + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + authorization_servers: ["https://example.authkit.app"], + bearer_methods_supported: ["header"], + resource: "https://api.example.com/api/mcp", + }); + }); + + it("fails closed when no AuthKit issuer is configured", async () => { + const response = await serve("/.well-known/oauth-protected-resource"); + + expect(response.status).toBe(503); + }); +}); diff --git a/apps/backend/src/routes/mcp-authkit.ts b/apps/backend/src/routes/mcp-authkit.ts new file mode 100644 index 000000000..5563f4079 --- /dev/null +++ b/apps/backend/src/routes/mcp-authkit.ts @@ -0,0 +1,57 @@ +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { McpAuthKit } from "../McpAuthKit.ts"; + +const METADATA_HEADERS = { "cache-control": "public, max-age=300" } as const; + +const requestOrigin = (request: HttpServerRequest.HttpServerRequest): string => { + try { + return new URL(request.originalUrl).origin; + } catch { + const host = request.headers.host ?? "localhost"; + const protocol = request.headers["x-forwarded-proto"] ?? "http"; + return `${protocol}://${host}`; + } +}; + +const unavailable = HttpServerResponse.json( + { error: "MCP OAuth is not configured" }, + { status: 503 }, +); + +const protectedResourceMetadata = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const authKit = yield* McpAuthKit; + if (!authKit.authorizationServer) return yield* unavailable; + return yield* HttpServerResponse.json( + { + authorization_servers: [authKit.authorizationServer], + bearer_methods_supported: ["header"], + resource: `${requestOrigin(request)}/api/mcp`, + }, + { headers: METADATA_HEADERS }, + ); +}); + +const authorizationServerMetadata = Effect.gen(function* () { + const authKit = yield* McpAuthKit; + const result = yield* Effect.result(authKit.fetchAuthorizationServerMetadata()); + return result._tag === "Success" + ? yield* HttpServerResponse.json(result.success, { headers: METADATA_HEADERS }) + : yield* HttpServerResponse.json({ error: result.failure.message }, { status: 502 }); +}); + +const registerMcpAuthKitRoutes = Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + yield* router.add("GET", "/.well-known/oauth-protected-resource", protectedResourceMetadata); + yield* router.add( + "GET", + "/.well-known/oauth-protected-resource/api/mcp", + protectedResourceMetadata, + ); + yield* router.add("GET", "/.well-known/oauth-authorization-server", authorizationServerMetadata); +}); + +/** AuthKit-backed OAuth discovery endpoints for the MCP protected resource. */ +export const McpAuthKitRouteLayer = Layer.effectDiscard(registerMcpAuthKitRoutes); diff --git a/apps/backend/src/routes/mcp.test.ts b/apps/backend/src/routes/mcp.test.ts new file mode 100644 index 000000000..ec2cf71f0 --- /dev/null +++ b/apps/backend/src/routes/mcp.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { mcpBearerChallenge, selectMcpProject } from "./mcp.ts"; + +const projects = [ + { id: "proj_1", slug: "alpha" }, + { id: "proj_2", slug: "beta" }, +]; + +describe("selectMcpProject", () => { + it("selects an authorized project by id or slug", () => { + expect(selectMcpProject(projects, "proj_2")).toEqual({ ok: true, project: projects[1] }); + expect(selectMcpProject(projects, "alpha")).toEqual({ ok: true, project: projects[0] }); + }); + + it("requires an explicit selector when a user has multiple projects", () => { + expect(selectMcpProject(projects, undefined)).toMatchObject({ + ok: false, + status: 400, + }); + }); + + it("defaults only when exactly one project is accessible", () => { + expect(selectMcpProject([projects[0]!], undefined)).toEqual({ + ok: true, + project: projects[0], + }); + expect(selectMcpProject([], undefined)).toMatchObject({ ok: false, status: 403 }); + }); +}); + +describe("mcpBearerChallenge", () => { + it("advertises RFC 9728 discovery through the AuthKit challenge", () => { + expect(mcpBearerChallenge("https://api.example.com")).toBe( + 'Bearer error="unauthorized", error_description="Authorization needed", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/api/mcp"', + ); + }); +}); diff --git a/apps/backend/src/routes/mcp.ts b/apps/backend/src/routes/mcp.ts new file mode 100644 index 000000000..f4e5af0e9 --- /dev/null +++ b/apps/backend/src/routes/mcp.ts @@ -0,0 +1,330 @@ +/** + * Model Context Protocol endpoint — `POST /api/mcp` (streamable HTTP, STATELESS). + * + * Exposes the paywall workspace to MCP clients as JSON-RPC 2.0 tools, resources, + * and prompts. The tools use the stateless, document-first implementation in + * `ai/workspace-tools.ts`; the authoring resource and prompt teach clients the + * same schema-derived workflow. + * + * **Auth.** AuthKit is the OAuth authorization server. Its access token carries + * the WorkOS user, selected organization, and MCP resource audience. Project- + * scoped secret keys and CLI user keys remain accepted for backwards + * compatibility. Every path constructs a normal {@link AuthSession}, so + * workspace authorization is still enforced by the domain services. + * + * **Stateless transport.** Each POST is answered with a single JSON response (or + * 202 for a notification). No SSE stream, no session ids, no server-initiated + * messages — spec-compliant for a stateless streamable-HTTP server and what + * Claude Code's client accepts. `GET`/`DELETE` on the endpoint → 405 (there is + * no stream to open and no session to terminate). Malformed JSON → 400; missing + * or invalid bearer → 401 with a `WWW-Authenticate` header. + * + */ +import { ApiKeyService, LocalUserSessionService, Workos } from "@voidhash/core/services"; +import { + AuthSession, + makeInternalProjectAuthSession, + type AnyAuthSession, +} from "@voidhash/core/domain/auth/Auth"; +import { Cause, Effect, Layer, Result } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import * as HttpHeaders from "effect/unstable/http/Headers"; + +import { + handleMcpMessage, + parseJsonRpcMessage, + JsonRpcErrorCode, + type CallTool, + type JsonRpcResponse, +} from "../mcp/protocol.ts"; +import { findMcpTool } from "../mcp/tool-manifest.ts"; +import type { WorkspaceToolScope } from "../ai/workspace-tools.ts"; +import { McpAuthKit } from "../McpAuthKit.ts"; + +const requestOrigin = (request: HttpServerRequest.HttpServerRequest): string => { + try { + return new URL(request.originalUrl).origin; + } catch { + const host = request.headers.host ?? "localhost"; + const protocol = request.headers["x-forwarded-proto"] ?? "http"; + return `${protocol}://${host}`; + } +}; + +/** AuthKit discovery challenge returned by the protected MCP resource. */ +export const mcpBearerChallenge = (origin: string): string => + [ + 'Bearer error="unauthorized"', + 'error_description="Authorization needed"', + `resource_metadata="${origin}/.well-known/oauth-protected-resource/api/mcp"`, + ].join(", "); + +/** A bare JSON-RPC error response (used for pre-dispatch failures with a null id). */ +const jsonRpcErrorResponse = (status: number, code: number, message: string) => + HttpServerResponse.json({ jsonrpc: "2.0", id: null, error: { code, message } }, { status }); + +/** Extract a `Bearer ` credential from the `authorization` header. */ +const bearerToken = (headers: HttpHeaders.Headers): string | undefined => { + const raw = HttpHeaders.get(headers, "authorization"); + const value = raw._tag === "Some" ? raw.value : undefined; + if (value === undefined) { + return undefined; + } + const match = /^Bearer\s+(.+)$/i.exec(value.trim()); + return match ? match[1].trim() : undefined; +}; + +const headerValue = (headers: HttpHeaders.Headers, name: string): string | undefined => { + const value = HttpHeaders.get(headers, name); + return value._tag === "Some" && value.value.trim().length > 0 ? value.value.trim() : undefined; +}; + +/** Selects an authorized MCP project without allowing a user key to widen its session. */ +export const selectMcpProject = ( + projects: ReadonlyArray, + selector: string | undefined, +): + | { readonly ok: true; readonly project: Project } + | { readonly ok: false; readonly status: 400 | 403; readonly message: string } => { + if (selector !== undefined) { + const project = projects.find( + (candidate) => candidate.id === selector || candidate.slug === selector, + ); + return project === undefined + ? { + ok: false, + status: 403, + message: `The authenticated user cannot access project "${selector}".`, + } + : { ok: true, project }; + } + if (projects.length === 1) { + return { ok: true, project: projects[0]! }; + } + if (projects.length === 0) { + return { + ok: false, + status: 403, + message: "The authenticated user has no accessible projects.", + }; + } + return { + ok: false, + status: 400, + message: + "The authenticated user has multiple projects. Set the X-Voidhash-Project header to a project id or slug.", + }; +}; + +/** + * The dispatcher passed to {@link handleMcpMessage}: look up the tool by name, + * run it against the authenticated `scope` — an unknown tool folds to an + * `isError` tool result (MCP maps a bad tool name to a tool error, not a + * JSON-RPC error, so a client retry loop can recover). + */ +const makeCallTool = + (scope: WorkspaceToolScope): CallTool => + (name, args) => { + const tool = findMcpTool(name); + if (tool === undefined) { + return Effect.succeed({ output: `Unknown tool: ${name}`, isError: true }); + } + return tool.dispatch(scope, args); + }; + +/** Handle a single stateless `POST /api/mcp` JSON-RPC message. */ +const handlePost = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const origin = requestOrigin(request); + const challenge = mcpBearerChallenge(origin); + const resource = `${origin}/api/mcp`; + + // 1. Auth: prefer a resource-bound OAuth access token; retain API keys for + // existing clients during migration. + const token = bearerToken(request.headers); + if (token === undefined) { + return HttpServerResponse.setHeader( + yield* jsonRpcErrorResponse(401, JsonRpcErrorCode.InvalidRequest, "Missing bearer token"), + "www-authenticate", + challenge, + ); + } + + let session: AnyAuthSession; + let scope: WorkspaceToolScope; + if (token.split(".").length === 3) { + const authKit = yield* McpAuthKit; + const validatedToken = yield* Effect.result(authKit.verifyAccessToken(token, resource)); + if (Result.isFailure(validatedToken)) { + const status = validatedToken.failure.kind === "misconfigured" ? 503 : 401; + return HttpServerResponse.setHeader( + yield* jsonRpcErrorResponse( + status, + JsonRpcErrorCode.InvalidRequest, + status === 503 + ? "MCP OAuth is not configured" + : "Invalid or expired AuthKit access token", + ), + "www-authenticate", + challenge, + ); + } + + const claims = validatedToken.success; + const localUserSessions = yield* LocalUserSessionService; + const workos = yield* Workos; + const workosUser = yield* workos.getUser(claims.subject); + const localUser = yield* localUserSessions.resolveLocalUser(workosUser); + const access = yield* localUserSessions.loadUserAccess(localUser.id); + const organization = access.organizations.find( + (candidate) => candidate.workosOrganizationId === claims.organizationId, + ); + if (organization === undefined) { + return yield* jsonRpcErrorResponse( + 403, + JsonRpcErrorCode.InvalidRequest, + "The AuthKit grant no longer has access to its selected organization", + ); + } + const organizationProjects = access.projects.filter( + (candidate) => candidate.organizationId === organization.id, + ); + const selection = selectMcpProject( + organizationProjects, + headerValue(request.headers, "x-voidhash-project"), + ); + if (!selection.ok) { + return yield* jsonRpcErrorResponse( + selection.status, + JsonRpcErrorCode.InvalidRequest, + selection.message, + ); + } + const userSession = localUserSessions.toUserSession( + localUser, + { organizations: [organization], projects: [selection.project] }, + null, + workosUser.id, + ); + session = userSession as unknown as AnyAuthSession; + scope = { projectId: selection.project.id }; + } else { + const apiKeys = yield* ApiKeyService; + const validatedSecret = yield* Effect.result(apiKeys.validateSecretKey(token)); + if (Result.isSuccess(validatedSecret)) { + const record = validatedSecret.success; + session = makeInternalProjectAuthSession({ + id: record.project.id, + name: record.project.name, + organizationId: record.project.organizationId, + slug: record.project.slug, + }); + scope = { projectId: record.project.id }; + } else { + const validatedUser = yield* Effect.result(apiKeys.validateUserApiKey(token)); + if (Result.isFailure(validatedUser)) { + return HttpServerResponse.setHeader( + yield* jsonRpcErrorResponse( + 401, + JsonRpcErrorCode.InvalidRequest, + "Invalid or expired API key", + ), + "www-authenticate", + challenge, + ); + } + const localUserSessions = yield* LocalUserSessionService; + const access = yield* localUserSessions.loadUserAccess(validatedUser.success.user.id); + const selection = selectMcpProject( + access.projects, + headerValue(request.headers, "x-voidhash-project"), + ); + if (!selection.ok) { + return yield* jsonRpcErrorResponse( + selection.status, + JsonRpcErrorCode.InvalidRequest, + selection.message, + ); + } + const userSession = localUserSessions.toUserSession( + validatedUser.success.user, + access, + null, + null, + ); + session = { ...userSession, projects: [selection.project] } as unknown as AnyAuthSession; + scope = { projectId: selection.project.id }; + } + } + + // 2. Parse the JSON body → JSON-RPC message. + const rawBody = yield* request.text; + const parsedJson = yield* Effect.result( + Effect.try({ + try: () => JSON.parse(rawBody) as unknown, + catch: () => new Error("Invalid JSON"), + }), + ); + if (Result.isFailure(parsedJson)) { + return yield* jsonRpcErrorResponse( + 400, + JsonRpcErrorCode.ParseError, + "Parse error: request body is not valid JSON", + ); + } + + const parsed = parseJsonRpcMessage(parsedJson.success); + if (!parsed.ok) { + return yield* jsonRpcErrorResponse(400, JsonRpcErrorCode.InvalidRequest, parsed.reason); + } + + // 3. Dispatch the message against the authenticated project scope. The tool + // effects are AuthSession-bound; provide the constructed scoped session. + const response: JsonRpcResponse | null = yield* handleMcpMessage( + parsed.message, + makeCallTool(scope), + ).pipe(Effect.provideService(AuthSession, session)); + + // A notification (no response) is answered with 202 + empty body. + if (response === null) { + return HttpServerResponse.empty({ status: 202 }); + } + return yield* HttpServerResponse.json(response); +}); + +/** `GET`/`DELETE` on the stateless endpoint: no stream, no session → 405. */ +const methodNotAllowed = HttpServerResponse.json( + { + jsonrpc: "2.0", + id: null, + error: { code: JsonRpcErrorCode.InvalidRequest, message: "Method Not Allowed" }, + }, + { status: 405, headers: { allow: "POST" } }, +); + +const registerMcpRoute = Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + yield* router.add( + "POST", + "/api/mcp", + handlePost.pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError(`MCP request error: ${Cause.pretty(cause)}`); + return yield* jsonRpcErrorResponse(500, JsonRpcErrorCode.InternalError, "Internal error"); + }), + ), + ), + ); + yield* router.add("GET", "/api/mcp", methodNotAllowed); + yield* router.add("DELETE", "/api/mcp", methodNotAllowed); +}); + +/** + * Registers `POST /api/mcp` (+ 405 on GET/DELETE). The request-scoped + * requirements — OAuth/API-key auth, workspace services, and `Db` — are + * satisfied via `HttpRouter.provideRequest` by the caller + * (`BackendApp`), mirroring the agent-session and webhook routes. `AuthSession` is + * provided in-handler from the validated project or user key. + */ +export const McpRouteLayer = Layer.effectDiscard(registerMcpRoute); diff --git a/apps/backend/src/routes/paywall-serving.test.ts b/apps/backend/src/routes/paywall-serving.test.ts new file mode 100644 index 000000000..498add3a6 --- /dev/null +++ b/apps/backend/src/routes/paywall-serving.test.ts @@ -0,0 +1,219 @@ +import { + PaywallArtifactStore, + PaywallArtifactStoreError, + type PaywallArtifactStoreShape, +} from "@voidhash/core/services"; +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { describe, expect, it } from "vite-plus/test"; + +import { PaywallServingRouteLayer } from "./paywall-serving.ts"; + +const CONTENT_HASH = "a".repeat(64); + +/** In-memory store with one serving layout entry per declared object. */ +const storeLayer = (objects: Record) => { + const shape: PaywallArtifactStoreShape = { + bucketName: "test-bucket", + getObject: (key) => + Effect.sync(() => { + const object = objects[key]; + return object + ? { body: new TextEncoder().encode(object.body), contentType: object.contentType } + : null; + }), + head: () => Effect.succeed(null), + putObject: () => Effect.void, + }; + return Layer.succeed(PaywallArtifactStore, shape); +}; + +const failingStoreLayer = Layer.succeed(PaywallArtifactStore, { + bucketName: "test-bucket", + getObject: () => + Effect.fail(new PaywallArtifactStoreError({ cause: "boom", message: "store down" })), + head: () => Effect.succeed(null), + putObject: () => Effect.void, +}); + +const serve = (path: string, store: Layer.Layer) => + Effect.gen(function* () { + const handler = yield* HttpRouter.toHttpEffect( + PaywallServingRouteLayer.pipe(HttpRouter.provideRequest(store)), + ); + const response = yield* handler.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request(`http://localhost${path}`)), + ), + ); + return HttpServerResponse.toWeb(response); + }).pipe(Effect.scoped, Effect.runPromise); + +/** §5 security headers expected on EVERY /p/* response. */ +const expectSecurityHeaders = (response: Response) => { + expect(response.headers.get("content-security-policy")).toBe("sandbox allow-scripts allow-forms"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); +}; + +describe("GET /p/:contentHash/* (deploy contract §5)", () => { + it("serves a stored artifact with stored Content-Type, immutable caching, and permissive CORS", async () => { + const store = storeLayer({ + [`p/${CONTENT_HASH}/index.html`]: { + body: "", + contentType: "text/html; charset=utf-8", + }, + }); + + const response = await serve(`/p/${CONTENT_HASH}/index.html`, store); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(await response.text()).toBe(""); + }); + + it("stamps the CSP sandbox, nosniff, and no-referrer security headers on served artifacts", async () => { + const store = storeLayer({ + [`p/${CONTENT_HASH}/index.html`]: { + body: "", + contentType: "text/html; charset=utf-8", + }, + }); + + const response = await serve(`/p/${CONTENT_HASH}/index.html`, store); + + expect(response.status).toBe(200); + expectSecurityHeaders(response); + // The hardening headers must not displace the §5 CORS + caching headers. + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + }); + + it("serves nested asset paths under the contentHash prefix", async () => { + const store = storeLayer({ + [`p/${CONTENT_HASH}/assets/hero-AB12CD.png`]: { + body: "png-bytes", + contentType: "image/png", + }, + }); + + const response = await serve(`/p/${CONTENT_HASH}/assets/hero-AB12CD.png`, store); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/png"); + }); + + it("falls back to application/octet-stream when no Content-Type is stored", async () => { + const store = storeLayer({ + [`p/${CONTENT_HASH}/bundle.js`]: { body: "js", contentType: null }, + }); + + const response = await serve(`/p/${CONTENT_HASH}/bundle.js`, store); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/octet-stream"); + }); + + it("returns 404 JSON for a missing object, with security headers and readable CORS", async () => { + const response = await serve(`/p/${CONTENT_HASH}/missing.js`, storeLayer({})); + + expect(response.status).toBe(404); + expectSecurityHeaders(response); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(await response.json()).toEqual({ error: "Not found" }); + }); + + it("returns 404 for a non-hex contentHash without touching the store", async () => { + const response = await serve("/p/not-a-hash/index.html", failingStoreLayer); + + expect(response.status).toBe(404); + }); + + it("returns 404 for dot-dot path segments without touching the store", async () => { + const response = await serve(`/p/${CONTENT_HASH}/../escape.html`, failingStoreLayer); + + expect(response.status).toBe(404); + }); + + it("returns 502 when the artifact store fails, with security headers and readable CORS", async () => { + const response = await serve(`/p/${CONTENT_HASH}/index.html`, failingStoreLayer); + + expect(response.status).toBe(502); + expectSecurityHeaders(response); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(await response.json()).toEqual({ error: "Failed to load paywall artifact" }); + }); +}); + +describe("GET /c/:contentHash/* (deploy contract §5.1)", () => { + const componentStore = () => + storeLayer({ + [`c/${CONTENT_HASH}/manifest.json`]: { + body: '{"manifestVersion":1}', + contentType: "application/json", + }, + [`c/${CONTENT_HASH}/previews/default.json`]: { + body: '{"treeVersion":1}', + contentType: "application/json", + }, + [`c/${CONTENT_HASH}/runtime.js`]: { + body: "export {};", + contentType: "text/javascript; charset=utf-8", + }, + }); + + it("serves component artifacts with the same §5 success headers", async () => { + for (const [path, contentType] of [ + [`/c/${CONTENT_HASH}/manifest.json`, "application/json"], + [`/c/${CONTENT_HASH}/previews/default.json`, "application/json"], + [`/c/${CONTENT_HASH}/runtime.js`, "text/javascript; charset=utf-8"], + ] as const) { + const response = await serve(path, componentStore()); + + expect(response.status, path).toBe(200); + expect(response.headers.get("content-type")).toBe(contentType); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expectSecurityHeaders(response); + } + }); + + it("does not serve /p/ objects from the /c/ prefix", async () => { + const store = storeLayer({ + [`p/${CONTENT_HASH}/index.html`]: { + body: "", + contentType: "text/html; charset=utf-8", + }, + }); + + const response = await serve(`/c/${CONTENT_HASH}/index.html`, store); + + expect(response.status).toBe(404); + }); + + it("returns a CORS-readable 404 for a missing preview state", async () => { + const response = await serve(`/c/${CONTENT_HASH}/previews/trial.json`, componentStore()); + + expect(response.status).toBe(404); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expectSecurityHeaders(response); + expect(await response.json()).toEqual({ error: "Not found" }); + }); + + it("returns 404 for a non-hex contentHash without touching the store", async () => { + const response = await serve("/c/not-a-hash/manifest.json", failingStoreLayer); + + expect(response.status).toBe(404); + }); + + it("returns a CORS-readable 502 when the artifact store fails", async () => { + const response = await serve(`/c/${CONTENT_HASH}/manifest.json`, failingStoreLayer); + + expect(response.status).toBe(502); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expectSecurityHeaders(response); + }); +}); diff --git a/apps/backend/src/routes/paywall-serving.ts b/apps/backend/src/routes/paywall-serving.ts new file mode 100644 index 000000000..c827676c2 --- /dev/null +++ b/apps/backend/src/routes/paywall-serving.ts @@ -0,0 +1,112 @@ +/** + * Public paywall artifact serving — `GET /p/:contentHash/*` (deploy contract + * §5) and `GET /c/:contentHash/*` (§5.1). + * + * Released artifacts are public, immutable, and content-addressed: the + * finalize step copies paywall blobs to `p//index.html`, + * `p//bundle.js`, and `p//assets/`, and + * component blobs to `c//manifest.json`, + * `c//previews/.json`, `c//runtime.js`, and + * `c//panel.js` in the {@link PaywallArtifactStore}. These routes + * stream them back with the stored `Content-Type`, an immutable cache policy, + * permissive CORS, and the §5 security headers (CSP sandbox / nosniff / + * no-referrer — tenant HTML is co-hosted with the authenticated API origin) — + * no auth (the contentHash itself is the capability). + */ +import { PaywallArtifactStore } from "@voidhash/core/services"; +import { SHA256_HEX_PATTERN } from "@voidhash/core/services/paywallDeploys/PaywallDeployManifest"; +import { Cause, Effect, Layer } from "effect"; +import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; + +const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; + +/** + * Security headers stamped on EVERY `/p/*` and `/c/*` response (contract §5). + * The CSP `sandbox` directive forces tenant-authored HTML served from this + * (API) origin into an opaque origin: no same-origin credentialed API access, + * no `document.cookie`. `allow-scripts allow-forms` keeps the paywall runtime + * functional; nosniff + no-referrer close the type-confusion and URL-leak + * side channels. + */ +const SECURITY_HEADERS = { + "content-security-policy": "sandbox allow-scripts allow-forms", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", +} as const; + +/** + * 404/error responses carry CORS too (contract §5.1): cross-origin consumers + * (the studio fetching preview trees) must observe a readable 404 instead of + * an opaque CORS failure. + */ +const ERROR_HEADERS = { + ...SECURITY_HEADERS, + "access-control-allow-origin": "*", +} as const; + +const notFound = HttpServerResponse.json( + { error: "Not found" }, + { headers: ERROR_HEADERS, status: 404 }, +); + +const handleServe = (prefix: "p" | "c") => + Effect.gen(function* () { + const store = yield* PaywallArtifactStore; + const params = yield* HttpRouter.params; + + const contentHash = params.contentHash; + const rest = params["*"]; + // The serving layouts only ever contain lowercase-hex contentHash + // prefixes (§1.2); anything else can 404 without touching the store. `..` + // has no traversal semantics in object storage, but reject it anyway so + // the route never echoes a creative key back into the store. + if ( + contentHash === undefined || + !SHA256_HEX_PATTERN.test(contentHash) || + rest === undefined || + rest.length === 0 || + rest.split("/").some((segment) => segment.length === 0 || segment === "..") + ) { + return yield* notFound; + } + + const object = yield* store.getObject(`${prefix}/${contentHash}/${rest}`); + if (object === null) { + return yield* notFound; + } + + return HttpServerResponse.uint8Array(object.body, { + contentType: object.contentType ?? "application/octet-stream", + headers: { + ...SECURITY_HEADERS, + "access-control-allow-origin": "*", + "cache-control": IMMUTABLE_CACHE_CONTROL, + }, + }); + }); + +const registerPaywallServingRoutes = Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + + for (const prefix of ["p", "c"] as const) { + yield* router.add( + "GET", + `/${prefix}/:contentHash/*`, + handleServe(prefix).pipe( + // catchCause so store defects are logged with their full cause instead + // of escaping the worker as an opaque exception. + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError(`Paywall artifact serving error: ${Cause.pretty(cause)}`); + return yield* HttpServerResponse.json( + { error: "Failed to load paywall artifact" }, + { headers: ERROR_HEADERS, status: 502 }, + ); + }), + ), + ), + ); + } +}); + +export const PaywallServingRouteLayer = Layer.effectDiscard(registerPaywallServingRoutes); diff --git a/apps/backend/src/routes/public-file-serving.test.ts b/apps/backend/src/routes/public-file-serving.test.ts new file mode 100644 index 000000000..6b84e84ec --- /dev/null +++ b/apps/backend/src/routes/public-file-serving.test.ts @@ -0,0 +1,103 @@ +import { + PublicFileStore, + PublicFileStoreError, + type PublicFileStoreShape, +} from "@voidhash/core/services"; +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { describe, expect, it } from "vite-plus/test"; + +import { PublicFileServingRouteLayer } from "./public-file-serving.ts"; + +const BASE_URL = "https://files.test.invalid"; + +/** In-memory store with one entry per declared object key. */ +const storeLayer = (objects: Record) => { + const shape: PublicFileStoreShape = { + publicBaseUrl: BASE_URL, + publicUrl: (key) => `${BASE_URL}/files/${key}`, + getObject: (key) => + Effect.sync(() => { + const object = objects[key]; + return object + ? { body: new TextEncoder().encode(object.body), contentType: object.contentType } + : null; + }), + putObject: () => Effect.void, + deleteObject: () => Effect.void, + }; + return Layer.succeed(PublicFileStore, shape); +}; + +const failingStoreLayer = Layer.succeed(PublicFileStore, { + publicBaseUrl: BASE_URL, + publicUrl: (key: string) => `${BASE_URL}/files/${key}`, + getObject: () => Effect.fail(new PublicFileStoreError({ cause: "boom", message: "store down" })), + putObject: () => Effect.void, + deleteObject: () => Effect.void, +}); + +const serve = (path: string, store: Layer.Layer) => + Effect.gen(function* () { + const handler = yield* HttpRouter.toHttpEffect( + PublicFileServingRouteLayer.pipe(HttpRouter.provideRequest(store)), + ); + const response = yield* handler.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request(`http://localhost${path}`)), + ), + ); + return HttpServerResponse.toWeb(response); + }).pipe(Effect.scoped, Effect.runPromise); + +describe("GET /files/*", () => { + it("serves a stored object with its Content-Type, immutable caching, CORS, and nosniff", async () => { + const store = storeLayer({ + "avatars/organization/org_1/abc.webp": { body: "webp-bytes", contentType: "image/webp" }, + }); + + const response = await serve("/files/avatars/organization/org_1/abc.webp", store); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/webp"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(await response.text()).toBe("webp-bytes"); + }); + + it("does NOT stamp a CSP sandbox (images load cross-origin as , unlike paywall HTML)", async () => { + const store = storeLayer({ "avatars/x.webp": { body: "x", contentType: "image/webp" } }); + + const response = await serve("/files/avatars/x.webp", store); + + expect(response.status).toBe(200); + expect(response.headers.get("content-security-policy")).toBeNull(); + }); + + it("falls back to application/octet-stream when no Content-Type is stored", async () => { + const store = storeLayer({ "avatars/x": { body: "x", contentType: null } }); + + const response = await serve("/files/avatars/x", store); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/octet-stream"); + }); + + it("returns 404 JSON for a missing object, with readable CORS", async () => { + const response = await serve("/files/avatars/missing.webp", storeLayer({})); + + expect(response.status).toBe(404); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(await response.json()).toEqual({ error: "Not found" }); + }); + + it("returns 502 when the store fails, with readable CORS", async () => { + const response = await serve("/files/avatars/x.webp", failingStoreLayer); + + expect(response.status).toBe(502); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(await response.json()).toEqual({ error: "Failed to load file" }); + }); +}); diff --git a/apps/backend/src/routes/public-file-serving.ts b/apps/backend/src/routes/public-file-serving.ts new file mode 100644 index 000000000..0a865e4fd --- /dev/null +++ b/apps/backend/src/routes/public-file-serving.ts @@ -0,0 +1,75 @@ +/** + * Public asset serving — `GET /files/*`. + * + * Stored public files are unauthenticated images served cross-origin as + * ``. Most use immutable content-addressed keys; mutable paywall + * thumbnails append their document sequence to the public URL as a cache + * buster. Unlike the paywall HTML routes, there is NO CSP sandbox; just the + * stored `Content-Type`, an immutable cache policy, permissive CORS, and + * `nosniff`. + */ +import { PublicFileStore } from "@voidhash/core/services"; +import { Cause, Effect, Layer } from "effect"; +import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; + +const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; + +const HEADERS = { + "access-control-allow-origin": "*", + "x-content-type-options": "nosniff", +} as const; + +const notFound = HttpServerResponse.json({ error: "Not found" }, { headers: HEADERS, status: 404 }); + +const handleServe = Effect.gen(function* () { + const store = yield* PublicFileStore; + const params = yield* HttpRouter.params; + const key = params["*"]; + + // `..` has no traversal semantics in object storage, but reject it anyway so + // the route never echoes a creative key back into the store. + if ( + key === undefined || + key.length === 0 || + key.split("/").some((segment) => segment.length === 0 || segment === "..") + ) { + return yield* notFound; + } + + const object = yield* store.getObject(key); + if (object === null) { + return yield* notFound; + } + + return HttpServerResponse.uint8Array(object.body, { + contentType: object.contentType ?? "application/octet-stream", + headers: { + ...HEADERS, + "cache-control": IMMUTABLE_CACHE_CONTROL, + }, + }); +}); + +const registerPublicFileServingRoutes = Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + + yield* router.add( + "GET", + "/files/*", + handleServe.pipe( + // catchCause so store defects are logged with their full cause instead of + // escaping the worker as an opaque exception. + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError(`Public file serving error: ${Cause.pretty(cause)}`); + return yield* HttpServerResponse.json( + { error: "Failed to load file" }, + { headers: HEADERS, status: 502 }, + ); + }), + ), + ), + ); +}); + +export const PublicFileServingRouteLayer = Layer.effectDiscard(registerPublicFileServingRoutes); diff --git a/apps/backend/src/routes/v1/api-keys.ts b/apps/backend/src/routes/v1/api-keys.ts new file mode 100644 index 000000000..a58ddd88a --- /dev/null +++ b/apps/backend/src/routes/v1/api-keys.ts @@ -0,0 +1,93 @@ +import { ApiKey, ApiKeyWithRawKey, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiApiKeyNotFoundError, + ApiApiKeyServiceError, +} from "@voidhash/api-contracts/errors"; +import { ApiKeyService } from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const ApiKeysGroupLive = HttpApiBuilder.group(VoidhashV1Api, "api_keys", (handlers) => + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + return handlers + .handle("createSecretKey", ({ payload }) => + bridgeAuthSession(apiKeyService.createSecretKey(payload)).pipe( + Effect.map((apiKey) => new ApiKeyWithRawKey(apiKey)), + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + ApiKeyServiceError: (e) => Effect.fail(new ApiApiKeyServiceError({ cause: e.cause })), + }), + ), + ) + .handle("listApiKeys", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const apiKeys = yield* apiKeyService.getApiKeys(projectId); + return apiKeys.map( + (apiKey) => + new ApiKey({ + ...apiKey, + ...(apiKey.isPublic ? { rawKey: apiKey.key } : {}), + }), + ); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + ApiKeyServiceError: (e) => Effect.fail(new ApiApiKeyServiceError({ cause: e.cause })), + }), + ), + ) + .handle("getApiKeyById", ({ params }) => + bridgeAuthSession(apiKeyService.getApiKeyById(params.apiKeyId)).pipe( + Effect.map( + (apiKey) => + new ApiKey({ + ...apiKey, + ...(apiKey.isPublic ? { rawKey: apiKey.key } : {}), + }), + ), + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + ApiKeyNotFoundError: (e) => + Effect.fail(new ApiApiKeyNotFoundError({ message: e.message })), + ApiKeyServiceError: (e) => Effect.fail(new ApiApiKeyServiceError({ cause: e.cause })), + }), + ), + ) + .handle("rotateSecretKey", ({ params }) => + bridgeAuthSession(apiKeyService.rotateSecretKey({ secretKeyId: params.apiKeyId })).pipe( + Effect.map((apiKey) => new ApiKeyWithRawKey(apiKey)), + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + ApiKeyNotFoundError: (e) => + Effect.fail(new ApiApiKeyNotFoundError({ message: e.message })), + ApiKeyServiceError: (e) => Effect.fail(new ApiApiKeyServiceError({ cause: e.cause })), + }), + ), + ) + .handle("deleteApiKey", ({ params }) => + bridgeAuthSession(apiKeyService.deleteSecretKey({ secretKeyId: params.apiKeyId })).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + ApiKeyNotFoundError: (e) => + Effect.fail(new ApiApiKeyNotFoundError({ message: e.message })), + ApiKeyServiceError: (e) => Effect.fail(new ApiApiKeyServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/auth.ts b/apps/backend/src/routes/v1/auth.ts new file mode 100644 index 000000000..2631f31b6 --- /dev/null +++ b/apps/backend/src/routes/v1/auth.ts @@ -0,0 +1,35 @@ +import { VoidhashV1Api } from "@voidhash/api-contracts"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const AuthGroupLive = HttpApiBuilder.group(VoidhashV1Api, "auth", (handlers) => + handlers.handle("session", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const method = + authSession.method === "user" + ? "api-key" + : (authSession.method as "api-key" | "publishable-key" | "secret-key"); + return { + method, + name: authSession.name, + organizations: authSession.organizations.map((o) => ({ + id: o.id, + name: o.name, + slug: o.slug, + })), + projects: authSession.projects.map((p) => ({ + id: p.id, + name: p.name, + organizationId: p.organizationId, + slug: p.slug, + })), + }; + }), + ), + ), +); diff --git a/apps/backend/src/routes/v1/notifications.ts b/apps/backend/src/routes/v1/notifications.ts new file mode 100644 index 000000000..40221f13d --- /dev/null +++ b/apps/backend/src/routes/v1/notifications.ts @@ -0,0 +1,145 @@ +import { SendNotificationResponse, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiAuthenticationError, + ApiPushDeviceValidationError, + ApiPushSendNotEnabledError, + ApiPushSendServiceError, +} from "@voidhash/api-contracts/errors"; +import { + InternalFeatureFlagService, + NotificationSendingService, +} from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { PushNotificationSendStatus } from "@voidhash/db"; +import { AuthSession, INTERNAL_FEATURE_FLAGS } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +/** Map the numeric send roll-up status to the API's string enum. */ +const sendStatusToString = (status: number): SendNotificationResponse["status"] => { + switch (status) { + case PushNotificationSendStatus.InProgress: + return "in_progress"; + case PushNotificationSendStatus.Succeeded: + return "succeeded"; + case PushNotificationSendStatus.PartialFailed: + return "partial_failed"; + case PushNotificationSendStatus.Failed: + return "failed"; + case PushNotificationSendStatus.NoRecipients: + return "no_recipients"; + default: + return "pending"; + } +}; + +/** + * Server-to-server push dispatch (`POST /api/v1/notifications/send`). A + * management surface — secret-key authenticated (never a publishable client key, + * so a device can't push to arbitrary persons) and gated by the `notifications` + * internal feature flag. Delegates to {@link NotificationSendingService}, which + * writes the trail rows and enqueues per-device deliveries; the response carries + * the tracking id and up-front counts. + */ +export const NotificationsGroupLive = HttpApiBuilder.group( + VoidhashV1Api, + "notifications", + (handlers) => + Effect.gen(function* () { + const sendService = yield* NotificationSendingService; + const internalFeatureFlags = yield* InternalFeatureFlagService; + + return handlers.handle("sendNotification", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const session = yield* AuthSession; + // Server-side only: a publishable (client-embedded) key must NEVER be + // able to push to arbitrary persons in its project. Require a + // secret/api key (the same trust level as the `persons` management API). + if (session?.method === "publishable-key") { + return yield* Effect.fail( + new ApiActionForbiddenError({ + message: "Push dispatch requires a secret API key, not a publishable key", + }), + ); + } + const projectId = yield* extractAuthorizedProjectId(session); + const organizationId = session?.projects.find( + (project) => project.id === projectId, + )?.organizationId; + if (!organizationId) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No organization associated with this authentication session", + message: "No organization associated with this authentication session", + }), + ); + } + + // Internal feature-flag gate (voidhash-internal, per-org). + const enabled = yield* internalFeatureFlags + .isEnabled(organizationId, INTERNAL_FEATURE_FLAGS.notifications.key) + .pipe( + Effect.catchTag("InternalFeatureFlagServiceError", (error) => + Effect.fail(new ApiPushSendServiceError({ cause: error.message })), + ), + ); + if (!enabled) { + return yield* Effect.fail( + new ApiActionForbiddenError({ + message: "Notifications are not enabled for this organization", + }), + ); + } + + const personIds = payload.personIds ?? []; + const distinctIds = payload.distinctIds ?? []; + if (personIds.length === 0 && distinctIds.length === 0) { + return yield* Effect.fail( + new ApiPushDeviceValidationError({ + message: "at least one of personIds or distinctIds is required", + }), + ); + } + + const result = yield* sendService.send({ + projectId, + message: { + title: payload.title, + body: payload.body, + data: payload.data, + sound: payload.sound, + badge: payload.badge, + priority: payload.priority, + ttl: payload.ttl, + channelId: payload.channelId, + collapseId: payload.collapseId, + }, + personIds, + distinctIds, + idempotencyKey: payload.idempotencyKey, + }); + + return new SendNotificationResponse({ + pushNotificationSendId: result.pushNotificationSendId, + deviceCount: result.deviceCount, + status: sendStatusToString(result.status), + unresolvedDistinctIds: [...result.unresolvedDistinctIds], + }); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new ApiActionForbiddenError({ message: error.message })), + NotificationConfigNotEnabledError: (error) => + Effect.fail(new ApiPushSendNotEnabledError({ message: error.message })), + NotificationSendingServiceError: (error) => + Effect.fail(new ApiPushSendServiceError({ cause: error.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/organizations.ts b/apps/backend/src/routes/v1/organizations.ts new file mode 100644 index 000000000..0e11796de --- /dev/null +++ b/apps/backend/src/routes/v1/organizations.ts @@ -0,0 +1,28 @@ +import { Organization, VoidhashV1Api } from "@voidhash/api-contracts"; +import { ApiOrganizationServiceError } from "@voidhash/api-contracts/errors"; +import { OrganizationService } from "@voidhash/core/services"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const OrganizationsGroupLive = HttpApiBuilder.group( + VoidhashV1Api, + "organizations", + (handlers) => + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + return handlers.handle("createOrganization", ({ payload }) => + bridgeAuthSession( + organizationService + .createOrganization({ name: payload.name }) + .pipe(Effect.map((org) => new Organization(org))), + ).pipe( + Effect.catchTags({ + OrganizationServiceError: (e) => + Effect.fail(new ApiOrganizationServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/payment-provider-configurations.ts b/apps/backend/src/routes/v1/payment-provider-configurations.ts new file mode 100644 index 000000000..326697456 --- /dev/null +++ b/apps/backend/src/routes/v1/payment-provider-configurations.ts @@ -0,0 +1,49 @@ +import { PaymentProviderConfiguration, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiPaymentProviderConfigurationServiceError, +} from "@voidhash/api-contracts/errors"; +import { PaymentProviderConfigurationService } from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const PaymentProviderConfigurationsGroupLive = HttpApiBuilder.group( + VoidhashV1Api, + "payment_provider_configurations", + (handlers) => + Effect.gen(function* () { + const service = yield* PaymentProviderConfigurationService; + + return handlers.handle("listPaymentProviderConfigurations", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const configs = yield* service.getPaymentProviderConfigurations(projectId); + + return configs.map( + (c) => + new PaymentProviderConfiguration({ + enabled: c.enabled, + id: c.id, + name: c.name, + projectId: c.projectId, + providerId: c.providerId, + }), + ); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + PaymentProviderConfigurationServiceError: (e) => + Effect.fail(new ApiPaymentProviderConfigurationServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/payment-provider-products.ts b/apps/backend/src/routes/v1/payment-provider-products.ts new file mode 100644 index 000000000..c43b4e55d --- /dev/null +++ b/apps/backend/src/routes/v1/payment-provider-products.ts @@ -0,0 +1,49 @@ +import { PaymentProviderProduct, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiPaymentProviderProductServiceError, +} from "@voidhash/api-contracts/errors"; +import { PaymentProviderProductService } from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const PaymentProviderProductsGroupLive = HttpApiBuilder.group( + VoidhashV1Api, + "payment_provider_products", + (handlers) => + Effect.gen(function* () { + const service = yield* PaymentProviderProductService; + + return handlers.handle("listPaymentProviderProducts", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const products = yield* service.getProviderProductsByProjectId(projectId); + + return products.map( + (p) => + new PaymentProviderProduct({ + configuration: (p.configuration ?? {}) as Record, + id: p.id, + paymentProviderConfigurationId: p.paymentProviderConfigurationId, + productId: p.productId, + providerId: p.providerId, + }), + ); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + PaymentProviderProductServiceError: (e) => + Effect.fail(new ApiPaymentProviderProductServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/paywall-deploys.ts b/apps/backend/src/routes/v1/paywall-deploys.ts new file mode 100644 index 000000000..05c4f49ec --- /dev/null +++ b/apps/backend/src/routes/v1/paywall-deploys.ts @@ -0,0 +1,154 @@ +import { + CreatePaywallDeployResponse, + FinalizePaywallDeployResponse, + FinalizedPaywallDeployComponent, + FinalizedPaywallDeployPaywall, + UploadPaywallDeployBlobResponse, + VoidhashV1Api, +} from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiDeployBlobHashMismatchError, + ApiDeployBlobNotDeclaredError, + ApiIncompleteDeployError, + ApiPaywallDeployNotFoundError, + ApiPaywallDeployNotPendingError, + ApiPaywallDeployServiceError, + ApiPaywallDeployUpgradeRequiredError, + ApiPaywallDeployValidationError, +} from "@voidhash/api-contracts/errors"; +import { PaywallDeployService } from "@voidhash/core/services"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +/** + * Handlers for the paywall code-deploy surface (deploy contract §4): + * `POST /api/v1/paywall-deploys`, blob upload, and finalize. All three call + * {@link PaywallDeployService} under the caller's bridged `AuthSession`; the + * service authorizes against the manifest's team/project slugs. + */ +export const PaywallDeploysGroupLive = HttpApiBuilder.group( + VoidhashV1Api, + "paywall_deploys", + (handlers) => + Effect.gen(function* () { + const deployService = yield* PaywallDeployService; + + return handlers + .handle("createDeploy", ({ payload }) => + bridgeAuthSession( + deployService.createDeploy({ manifest: payload }).pipe( + Effect.map( + (result) => + new CreatePaywallDeployResponse({ + deployId: result.deployId, + missing: result.missing, + }), + ), + ), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + AuditLogPortError: (e) => + Effect.fail(new ApiPaywallDeployServiceError({ cause: e.cause })), + PaywallDeployServiceError: (e) => + Effect.fail(new ApiPaywallDeployServiceError({ cause: e.cause })), + PaywallDeployValidationError: (e) => + Effect.fail( + new ApiPaywallDeployValidationError({ + message: e.message, + violations: e.violations, + }), + ), + UnsupportedDeploySchemaVersionError: (e) => + Effect.fail( + new ApiPaywallDeployUpgradeRequiredError({ + message: e.message, + schemaVersion: e.schemaVersion, + }), + ), + }), + ), + ) + .handle("uploadBlob", ({ params, payload }) => + bridgeAuthSession( + deployService + .uploadBlob({ + body: payload, + deployId: params.deployId, + sha256: params.sha256, + }) + .pipe(Effect.map(() => new UploadPaywallDeployBlobResponse({}))), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + DeployBlobHashMismatchError: (e) => + Effect.fail( + new ApiDeployBlobHashMismatchError({ + actualSha256: e.actualSha256, + expectedSha256: e.expectedSha256, + }), + ), + DeployBlobNotDeclaredError: (e) => + Effect.fail(new ApiDeployBlobNotDeclaredError({ sha256: e.sha256 })), + PaywallDeployNotFoundError: (e) => + Effect.fail(new ApiPaywallDeployNotFoundError({ message: e.message })), + PaywallDeployNotPendingError: (e) => + Effect.fail(new ApiPaywallDeployNotPendingError({ message: e.message })), + PaywallDeployServiceError: (e) => + Effect.fail(new ApiPaywallDeployServiceError({ cause: e.cause })), + PaywallDeployValidationError: (e) => + Effect.fail( + new ApiPaywallDeployValidationError({ + message: e.message, + violations: e.violations, + }), + ), + }), + ), + ) + .handle("finalizeDeploy", ({ params }) => + bridgeAuthSession( + deployService.finalizeDeploy({ deployId: params.deployId }).pipe( + Effect.map( + (result) => + new FinalizePaywallDeployResponse({ + components: result.components.map( + (component) => new FinalizedPaywallDeployComponent(component), + ), + deployId: result.deployId, + paywalls: result.paywalls.map( + (paywall) => new FinalizedPaywallDeployPaywall(paywall), + ), + status: result.status, + }), + ), + ), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + AuditLogPortError: (e) => + Effect.fail(new ApiPaywallDeployServiceError({ cause: e.cause })), + IncompleteDeployError: (e) => + Effect.fail(new ApiIncompleteDeployError({ missing: e.missing })), + PaywallDeployNotFoundError: (e) => + Effect.fail(new ApiPaywallDeployNotFoundError({ message: e.message })), + PaywallDeployServiceError: (e) => + Effect.fail(new ApiPaywallDeployServiceError({ cause: e.cause })), + PaywallDeployValidationError: (e) => + Effect.fail( + new ApiPaywallDeployValidationError({ + message: e.message, + violations: e.violations, + }), + ), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/paywall-locations.ts b/apps/backend/src/routes/v1/paywall-locations.ts new file mode 100644 index 000000000..79fa7a97e --- /dev/null +++ b/apps/backend/src/routes/v1/paywall-locations.ts @@ -0,0 +1,52 @@ +import { PaywallLocation, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiPaywallLocationServiceError, +} from "@voidhash/api-contracts/errors"; +import { PaywallLocationService } from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const PaywallLocationsGroupLive = HttpApiBuilder.group( + VoidhashV1Api, + "paywall_locations", + (handlers) => + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + + return handlers.handle("listPaywallLocations", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const locations = yield* paywallLocationService.listLocations({ + includeArchived: false, + projectId, + }); + + return locations.map( + (location) => + new PaywallLocation({ + description: location.description, + id: location.id, + name: location.name, + projectId: location.projectId, + slug: location.slug, + }), + ); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + PaywallLocationServiceError: (e) => + Effect.fail(new ApiPaywallLocationServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/perks.ts b/apps/backend/src/routes/v1/perks.ts new file mode 100644 index 000000000..a827d35cc --- /dev/null +++ b/apps/backend/src/routes/v1/perks.ts @@ -0,0 +1,39 @@ +import { Perk, VoidhashV1Api } from "@voidhash/api-contracts"; +import { ApiActionForbiddenError, ApiPerkServiceError } from "@voidhash/api-contracts/errors"; +import { PerkService } from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const PerksGroupLive = HttpApiBuilder.group(VoidhashV1Api, "perks", (handlers) => + Effect.gen(function* () { + const perkService = yield* PerkService; + return handlers.handle("listPerks", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const perks = yield* perkService.getPerks(projectId); + return perks.map( + (perk) => + new Perk({ + id: perk.id, + name: perk.name, + projectId: perk.projectId, + slug: perk.slug, + }), + ); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + PerkServiceError: (e) => Effect.fail(new ApiPerkServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/persons.ts b/apps/backend/src/routes/v1/persons.ts new file mode 100644 index 000000000..a38a870d8 --- /dev/null +++ b/apps/backend/src/routes/v1/persons.ts @@ -0,0 +1,107 @@ +import { Person, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiPersonNotFoundError, + ApiPersonServiceError, +} from "@voidhash/api-contracts/errors"; +import { PersonService } from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { PersonOrigin } from "@voidhash/db"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +const toApiPerson = (person: { + personId: string; + distinctId: string; + email: string | null; + name: string | null; +}) => + new Person({ + personId: person.personId, + distinctId: person.distinctId, + email: person.email, + name: person.name, + }); + +export const PersonsGroupLive = HttpApiBuilder.group(VoidhashV1Api, "persons", (handlers) => + Effect.gen(function* () { + const personService = yield* PersonService; + return handlers + .handle("createPerson", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + return yield* personService.createPerson({ + distinctId: payload.distinctId, + email: payload.email ?? null, + name: payload.name ?? null, + projectId, + origin: PersonOrigin.API, + }); + }), + ).pipe( + Effect.map((person) => toApiPerson(person!)), + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + PersonServiceError: (e) => Effect.fail(new ApiPersonServiceError({ cause: e.cause })), + }), + ), + ) + .handle("listPersons", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + return yield* personService + .getPersons({ projectId }) + .pipe( + Effect.map((persons) => + (persons ?? []).flatMap((person) => (person ? [toApiPerson(person)] : [])), + ), + ); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + PersonServiceError: (e) => Effect.fail(new ApiPersonServiceError({ cause: e.cause })), + }), + ), + ) + .handle("getPersonById", ({ params: { personId } }) => + bridgeAuthSession( + personService.getPersonById(personId).pipe(Effect.map((person) => toApiPerson(person!))), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + PersonNotFoundError: (e) => Effect.fail(new ApiPersonNotFoundError({ id: e.id })), + PersonServiceError: (e) => Effect.fail(new ApiPersonServiceError({ cause: e.cause })), + }), + ), + ) + .handle("getPersonByDistinctId", ({ params: { distinctId } }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + return yield* personService + .getPersonByDistinctId(distinctId, projectId) + .pipe(Effect.map((person) => toApiPerson(person!))); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + PersonNotFoundError: (e) => Effect.fail(new ApiPersonNotFoundError({ id: e.id })), + PersonServiceError: (e) => Effect.fail(new ApiPersonServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/product-perks.ts b/apps/backend/src/routes/v1/product-perks.ts new file mode 100644 index 000000000..0fb48632b --- /dev/null +++ b/apps/backend/src/routes/v1/product-perks.ts @@ -0,0 +1,46 @@ +import { ProductPerk, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiProductPerkServiceError, + ApiProductPerkValidationError, +} from "@voidhash/api-contracts/errors"; +import { ProductPerkService } from "@voidhash/core/services"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const ProductPerksGroupLive = HttpApiBuilder.group( + VoidhashV1Api, + "product_perks", + (handlers) => + Effect.gen(function* () { + const productService = yield* ProductPerkService; + + return handlers.handle("listProductPerksByProductId", ({ params: { productId } }) => + bridgeAuthSession( + productService.getProductPerksByProductId(productId).pipe( + Effect.map((productPerks) => + productPerks.map( + (productPerk) => + new ProductPerk({ + id: productPerk.id, + perkId: productPerk.perkId, + productId: productPerk.productId, + }), + ), + ), + ), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + ProductPerkServiceError: (e) => + Effect.fail(new ApiProductPerkServiceError({ cause: e.cause })), + ProductPerkValidationError: (e) => + Effect.fail(new ApiProductPerkValidationError({ message: e.message })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/products.ts b/apps/backend/src/routes/v1/products.ts new file mode 100644 index 000000000..0a52280e6 --- /dev/null +++ b/apps/backend/src/routes/v1/products.ts @@ -0,0 +1,35 @@ +import { Product, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiProductServiceError, +} from "@voidhash/api-contracts/errors"; +import { ProductService } from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const ProductsGroupLive = HttpApiBuilder.group(VoidhashV1Api, "products", (handlers) => + Effect.gen(function* () { + const productService = yield* ProductService; + + return handlers.handle("listProducts", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const products = yield* productService.getProducts(projectId); + return products.map((product) => new Product(product)); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + ProductServiceError: (e) => Effect.fail(new ApiProductServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/projects.ts b/apps/backend/src/routes/v1/projects.ts new file mode 100644 index 000000000..d73cec4b5 --- /dev/null +++ b/apps/backend/src/routes/v1/projects.ts @@ -0,0 +1,51 @@ +import { Project, VoidhashV1Api } from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiAuthenticationError, + ApiProjectServiceError, +} from "@voidhash/api-contracts/errors"; +import { ProjectService } from "@voidhash/core/services"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const ProjectsGroupLive = HttpApiBuilder.group(VoidhashV1Api, "projects", (handlers) => + Effect.gen(function* () { + const projectService = yield* ProjectService; + return handlers + .handle("createProject", ({ payload }) => + bridgeAuthSession( + projectService + .createProject({ + name: payload.name, + organizationId: payload.organizationId, + }) + .pipe(Effect.map((project) => new Project(project))), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + AuthenticationError: (e) => + Effect.fail(new ApiAuthenticationError({ cause: e.cause, message: e.message })), + ProjectServiceError: (e) => Effect.fail(new ApiProjectServiceError({ cause: e.cause })), + }), + ), + ) + .handle("listProjects", ({ params: { organizationId } }) => + bridgeAuthSession( + projectService + .getProjects(organizationId) + .pipe( + Effect.map((projectsList) => projectsList.map((project) => new Project(project))), + ), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + ProjectServiceError: (e) => Effect.fail(new ApiProjectServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/schema.ts b/apps/backend/src/routes/v1/schema.ts new file mode 100644 index 000000000..94b384faf --- /dev/null +++ b/apps/backend/src/routes/v1/schema.ts @@ -0,0 +1,147 @@ +/** + * `GET /api/v1/schema` and `GET /api/v1/schema/version` — the CLI-facing + * consolidated schema reads. Both share the underlying `SchemaService` query + * and honour `If-None-Match` against the `sha256:` version hash so the + * CLI watch loop and SDK drift-warning paths can revalidate cheaply. + */ +import { + ProjectSchemaResponse, + SchemaLocation, + SchemaPerk, + SchemaProduct, + SchemaProductProvider, + SchemaVersion, + VoidhashV1Api, +} from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiSchemaServiceError, +} from "@voidhash/api-contracts/errors"; +import { SchemaService } from "@voidhash/core/services"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect, Option } from "effect"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import * as HttpHeaders from "effect/unstable/http/Headers"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +const SCHEMA_CACHE_HEADERS = { + "cache-control": "no-cache, must-revalidate", +} as const; + +/** + * Returns a `304 Not Modified` response when the client's `If-None-Match` + * matches the current schema version. Returns `undefined` otherwise so the + * caller can serve the body. + */ +export const schemaNotModifiedResponse = ( + ifNoneMatch: string | undefined, + version: string, +): HttpServerResponse.HttpServerResponse | undefined => { + if (!ifNoneMatch) { + return undefined; + } + const trimmed = ifNoneMatch.replace(/^"|"$/g, ""); + if (trimmed !== version) { + return undefined; + } + return HttpServerResponse.empty({ + status: 304, + headers: { ...SCHEMA_CACHE_HEADERS, etag: `"${version}"` }, + }); +}; + +export const schemaResponseHeaders = (version: string): Record => ({ + ...SCHEMA_CACHE_HEADERS, + etag: `"${version}"`, +}); + +export const SchemaGroupLive = HttpApiBuilder.group(VoidhashV1Api, "schema", (handlers) => + Effect.gen(function* () { + const schemaService = yield* SchemaService; + + return handlers + .handle("getSchema", () => + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const ifNoneMatch = HttpHeaders.get(req.headers, "if-none-match"); + + return yield* bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const schema = yield* schemaService.getProjectSchema(projectId); + + const notModified = schemaNotModifiedResponse( + Option.getOrUndefined(ifNoneMatch), + schema.version, + ); + if (notModified) { + return notModified; + } + + return yield* HttpServerResponse.schemaJson(ProjectSchemaResponse)( + new ProjectSchemaResponse({ + enabledProviders: schema.enabledProviders, + locations: schema.locations.map((location) => new SchemaLocation(location)), + perks: schema.perks.map((perk) => new SchemaPerk(perk)), + products: schema.products.map( + (product) => + new SchemaProduct({ + ...product, + providers: product.providers.map( + (provider) => new SchemaProductProvider(provider), + ), + }), + ), + version: schema.version, + }), + { headers: schemaResponseHeaders(schema.version) }, + ).pipe(Effect.orDie); + }), + ); + }).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + SchemaServiceError: (e) => Effect.fail(new ApiSchemaServiceError({ cause: e.cause })), + }), + ), + ) + .handle("getSchemaVersion", () => + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const ifNoneMatch = HttpHeaders.get(req.headers, "if-none-match"); + + return yield* bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const { version } = yield* schemaService.computeProjectSchemaVersion(projectId); + + const notModified = schemaNotModifiedResponse( + Option.getOrUndefined(ifNoneMatch), + version, + ); + if (notModified) { + return notModified; + } + + return yield* HttpServerResponse.schemaJson(SchemaVersion)( + new SchemaVersion({ version }), + { headers: schemaResponseHeaders(version) }, + ).pipe(Effect.orDie); + }), + ); + }).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + SchemaServiceError: (e) => Effect.fail(new ApiSchemaServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/sdk.test.ts b/apps/backend/src/routes/v1/sdk.test.ts new file mode 100644 index 000000000..8e694688a --- /dev/null +++ b/apps/backend/src/routes/v1/sdk.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { mapSdkTransactionSubmission } from "./sdk.ts"; + +describe("SDK transaction route mapping", () => { + it("forwards the Android native product id as the Google Play product hint", () => { + expect( + mapSdkTransactionSubmission( + { + platform: "android", + providerProductId: "com.voidhash.yearly.android", + productSlug: "yearly_sub", + purchaseToken: "purchase-token", + transactionId: "order-id", + }, + "com.voidhash.test", + ), + ).toEqual({ + packageName: "com.voidhash.test", + productId: "com.voidhash.yearly.android", + providerId: "google-play", + purchaseToken: "purchase-token", + }); + }); + + it("falls back to the product slug for older Android SDK payloads", () => { + expect( + mapSdkTransactionSubmission( + { + platform: "android", + productSlug: "legacy-play-product-id", + purchaseToken: "purchase-token", + transactionId: "order-id", + }, + "com.voidhash.test", + ), + ).toMatchObject({ productId: "legacy-play-product-id" }); + }); + + it("maps iOS to the App Store transaction and bundle identifiers", () => { + expect( + mapSdkTransactionSubmission( + { + platform: "ios", + productSlug: "monthly_sub", + transactionId: "transaction-id", + }, + "com.voidhash.test", + ), + ).toEqual({ + bundleId: "com.voidhash.test", + providerId: "apple-app-store", + transactionId: "transaction-id", + }); + }); +}); diff --git a/apps/backend/src/routes/v1/sdk.ts b/apps/backend/src/routes/v1/sdk.ts new file mode 100644 index 000000000..b1f2f15ee --- /dev/null +++ b/apps/backend/src/routes/v1/sdk.ts @@ -0,0 +1,650 @@ +import { + RegisterDeviceResponse, + SdkCurrentSubscription, + SdkEntitlementGrant, + SdkFeatureFlagResult, + SdkFeatureFlagsResponse, + SdkHeaders, + SdkPerson, + SdkPurchaseHistoryEntry, + SdkResolvedPaywall, + SdkResolvedPaywallShowing, + SdkSchema, + SdkSchemaLocation, + SdkSchemaPerk, + SdkSchemaProduct, + SdkSubscriptionHistoryEntry, + SdkSyncTransactionResponse, + VoidhashV1Api, +} from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiAuthenticationError, + ApiPushDeviceNotFoundError, + ApiPushDeviceServiceError, + ApiPushDeviceValidationError, + ApiSchemaServiceError, + ApiSdkPersonAlreadyIdentifiedError, + ApiSdkPersonNotFoundError, + ApiSdkServiceError, + ApiSdkValidationError, +} from "@voidhash/api-contracts/errors"; +import type { AuthenticationError } from "@voidhash/core/domain/auth/Auth"; +import type { SdkPersonSnapshot } from "@voidhash/core/domain/sdkPerson/SdkPerson"; +import { SdkValidationError } from "@voidhash/core/domain/sdkPerson/SdkPerson"; +import { + FeatureFlagService, + InternalFeatureFlagService, + NotificationTokenService, + PaywallLocationService, + PersonIdentityService, + SchemaService, + SdkService, + type SdkServiceError, +} from "@voidhash/core/services"; +import { Db } from "@voidhash/db"; +import { AuthSession, INTERNAL_FEATURE_FLAGS } from "@voidhash/rpc"; +import { Effect, Option, Schema } from "effect"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import * as HttpHeaders from "effect/unstable/http/Headers"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession, getPersonMetadataFromSdkHeaders } from "../../ApiMiddlewares.ts"; +import { schemaNotModifiedResponse, schemaResponseHeaders } from "./schema.ts"; + +const toSdkPerson = (snapshot: SdkPersonSnapshot) => + new SdkPerson({ + distinctId: snapshot.distinctId, + email: snapshot.email, + entitlements: { + grants: snapshot.entitlements.grants.map( + (grant) => + new SdkEntitlementGrant({ + expiresAt: grant.expiresAt, + perkId: grant.perkId, + source: grant.source, + sourceId: grant.sourceId, + sourcePersonId: grant.sourcePersonId, + status: grant.status, + }), + ), + }, + name: snapshot.name, + personId: snapshot.personId, + purchases: { + history: snapshot.purchases.history.map( + (purchase) => + new SdkPurchaseHistoryEntry({ + createdAt: purchase.createdAt, + productId: purchase.productId, + providerKey: purchase.providerKey, + purchaseId: purchase.purchaseId, + sourcePersonId: purchase.sourcePersonId, + type: purchase.type, + }), + ), + }, + snapshotContext: { + includedPersonIds: snapshot.snapshotContext.includedPersonIds, + migrationJobId: snapshot.snapshotContext.migrationJobId, + mode: snapshot.snapshotContext.mode, + }, + subscriptions: { + current: snapshot.subscriptions.current + ? new SdkCurrentSubscription({ + expiresAt: snapshot.subscriptions.current.expiresAt, + productId: snapshot.subscriptions.current.productId, + status: snapshot.subscriptions.current.status, + subscriptionId: snapshot.subscriptions.current.subscriptionId, + }) + : null, + history: snapshot.subscriptions.history.map( + (entry) => + new SdkSubscriptionHistoryEntry({ + canceledAt: entry.canceledAt, + expiresAt: entry.expiresAt, + isTrial: entry.isTrial, + productId: entry.productId, + sourcePersonId: entry.sourcePersonId, + startsAt: entry.startsAt, + status: entry.status, + subscriptionId: entry.subscriptionId, + }), + ), + }, + }); + +/** Maps the public SDK transaction payload to the provider-specific service input. */ +export const mapSdkTransactionSubmission = ( + payload: { + readonly platform: "ios" | "android"; + readonly providerProductId?: string; + readonly productSlug: string; + readonly purchaseToken?: string; + readonly transactionId: string; + }, + clientBundleId: string, +) => + payload.platform === "android" + ? { + packageName: clientBundleId, + productId: payload.providerProductId ?? payload.productSlug, + providerId: "google-play" as const, + purchaseToken: payload.purchaseToken, + } + : { + bundleId: clientBundleId, + providerId: "apple-app-store" as const, + transactionId: payload.transactionId, + }; + +export const SdkGroupLive = HttpApiBuilder.group(VoidhashV1Api, "sdk", (handlers) => + Effect.gen(function* () { + const sdkService = yield* SdkService; + const featureFlagService = yield* FeatureFlagService; + const paywallLocationService = yield* PaywallLocationService; + const schemaService = yield* SchemaService; + const dbService = yield* Db; + const personIdentityService = yield* PersonIdentityService; + const notificationTokenService = yield* NotificationTokenService; + const internalFeatureFlagService = yield* InternalFeatureFlagService; + + const requireNotificationsEnabled = (organizationId: string) => + internalFeatureFlagService + .isEnabled(organizationId, INTERNAL_FEATURE_FLAGS.notifications.key) + .pipe( + Effect.catchTag("InternalFeatureFlagServiceError", (error) => + Effect.fail(new ApiPushDeviceServiceError({ cause: error.message })), + ), + Effect.filterOrFail( + (enabled) => enabled, + () => + new ApiActionForbiddenError({ + message: "Notifications are not enabled for this organization", + }), + ), + ); + + return handlers + .handle("getPerson", () => + bridgeAuthSession(sdkService.getPerson().pipe(Effect.map(toSdkPerson))).pipe( + Effect.catchTags({ + AuthenticationError: (e) => + Effect.fail(new ApiAuthenticationError({ cause: e.cause, message: e.message })), + SdkPersonNotFoundError: (e) => + Effect.fail(new ApiSdkPersonNotFoundError({ message: e.message })), + SdkServiceError: (e) => Effect.fail(new ApiSdkServiceError({ cause: e.cause })), + SdkValidationError: (e) => + Effect.fail(new ApiSdkValidationError({ message: e.message })), + }), + ), + ) + .handle("identifyPerson", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const session = yield* AuthSession; + const project = session?.projects[0]; + if (!project) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No project associated with this SDK authentication session", + message: "No project associated with this SDK authentication session", + }), + ); + } + + if (!session?.person?.distinctId) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No SDK person identity found in this authentication session", + message: "No SDK person identity found in this authentication session", + }), + ); + } + + const snapshot = yield* sdkService.identifyPerson({ + distinctId: payload.distinctId, + email: payload.email ?? null, + name: payload.name ?? null, + traits: payload.traits ? { ...payload.traits } : undefined, + }); + + return toSdkPerson(snapshot); + }), + ).pipe( + Effect.catchTags({ + AuthenticationError: (e) => + Effect.fail(new ApiAuthenticationError({ cause: e.cause, message: e.message })), + SdkPersonAlreadyIdentifiedError: (e) => + Effect.fail(new ApiSdkPersonAlreadyIdentifiedError({ distinctId: e.distinctId })), + SdkPersonNotFoundError: (e) => + Effect.fail(new ApiSdkPersonNotFoundError({ message: e.message })), + SdkValidationError: (e) => + Effect.fail(new ApiSdkValidationError({ message: e.message })), + SdkServiceError: (e) => Effect.fail(new ApiSdkServiceError({ cause: e.cause })), + }), + ), + ) + .handle("syncPersonAttributes", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const parsedHeaders = yield* Schema.decodeUnknownEffect(SdkHeaders)(req.headers).pipe( + Effect.mapError((error) => new ApiSdkValidationError({ message: error.message })), + ); + const personMetadata = getPersonMetadataFromSdkHeaders(parsedHeaders); + + const result = yield* sdkService.syncPersonAttributes({ + personMetadata, + email: payload.email, + name: payload.name, + traits: payload.traits ? { ...payload.traits } : undefined, + setOnce: payload.setOnce ? { ...payload.setOnce } : undefined, + ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}), + }); + return toSdkPerson(result.snapshot); + }), + ).pipe( + Effect.catchTags({ + AuthenticationError: (e) => + Effect.fail(new ApiAuthenticationError({ cause: e.cause, message: e.message })), + SdkPersonNotFoundError: (e) => + Effect.fail(new ApiSdkPersonNotFoundError({ message: e.message })), + SdkServiceError: (e) => Effect.fail(new ApiSdkServiceError({ cause: e.cause })), + SdkValidationError: (e) => + Effect.fail(new ApiSdkValidationError({ message: e.message })), + }), + ), + ) + .handle("syncTransaction", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const parsedHeaders = yield* Schema.decodeUnknownEffect(SdkHeaders)(req.headers).pipe( + Effect.mapError((error) => new ApiSdkValidationError({ message: error.message })), + ); + // The Android package name equals the client bundle id. + const clientBundleId = parsedHeaders["x-client-bundle-id"]; + + // `submitPurchaseTransaction`'s `Effect.fn` wrapper widens the error + // channel; cast to a concrete `Effect` so the outer `catchTags` + // matches the underlying tags. + yield* sdkService.submitPurchaseTransaction( + mapSdkTransactionSubmission(payload, clientBundleId), + ) as Effect.Effect< + unknown, + AuthenticationError | SdkValidationError | SdkServiceError, + never + >; + + return new SdkSyncTransactionResponse({ accepted: true }); + }), + ).pipe( + Effect.catchTags({ + AuthenticationError: (e) => + Effect.fail(new ApiAuthenticationError({ cause: e.cause, message: e.message })), + SdkServiceError: (e) => Effect.fail(new ApiSdkServiceError({ cause: e.cause })), + SdkValidationError: (e) => + Effect.fail(new ApiSdkValidationError({ message: e.message })), + }), + ), + ) + .handle("resolvePaywall", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const session = yield* AuthSession; + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new ApiSdkServiceError({ cause: "No project associated with this key" }), + ); + } + + // Resolve the subject the same way `evaluateFeatureFlags` does so an + // experiment-backed location buckets on a stable identity. + const distinctId = session?.person?.distinctId ?? undefined; + let personId: string | undefined; + if (distinctId) { + const mapping = yield* dbService.query.personIdentities.findFirst({ + where: { distinctId, projectId }, + }); + personId = mapping?.personId; + } + + const resolved = yield* paywallLocationService.resolveLocationShowingForSdk({ + locationSlug: payload.locationSlug, + projectId, + personId, + distinctId, + }); + if (resolved === null) { + return null; + } + + // `resolved.exposure` (when non-null) carries { experimentId, + // variantKey, personId, distinctId } for the assigned subject. + // Server-side `$experiment.exposed` emission is wired here once the + // analytics dispatch producer (`AnalyticsDispatchService` over the + // worker's `CaptureIngressLive`) + `RuntimeContext` are threaded into + // the SDK route runtime — see `EXPERIMENT_TRUSTED_SOURCE_TOPIC` / + // `makeCapturedEventFromInternalAnalyticsEvent`. Assignment + serving + // (below) are fully live; emission is the remaining infra step. + + return new SdkResolvedPaywall({ + location: resolved.location, + showing: new SdkResolvedPaywallShowing(resolved.showing), + }); + }), + ).pipe( + Effect.catchTags({ + EffectDrizzleQueryError: (e) => + Effect.fail(new ApiSdkServiceError({ cause: String(e.message) })), + PaywallLocationServiceError: (e) => + Effect.fail(new ApiSdkServiceError({ cause: e.cause })), + }), + ), + ) + .handle("getSchema", () => + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const ifNoneMatch = HttpHeaders.get(req.headers, "if-none-match"); + + return yield* bridgeAuthSession( + Effect.gen(function* () { + const session = yield* AuthSession; + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No project associated with this publishable key", + message: "No project associated with this publishable key", + }), + ); + } + + const schema = yield* schemaService.getProjectSchemaForSdk(projectId); + + const notModified = schemaNotModifiedResponse( + Option.getOrUndefined(ifNoneMatch), + schema.version, + ); + if (notModified) { + return notModified; + } + + const perks: Record = {}; + for (const perk of schema.perks) { + perks[perk.slug] = new SdkSchemaPerk(perk); + } + + const locations: Record = {}; + for (const location of schema.locations) { + locations[location.slug] = new SdkSchemaLocation(location); + } + + const products: Record = {}; + for (const product of schema.products) { + const perksRecord: Record = {}; + for (const perkSlug of product.perks) { + perksRecord[perkSlug] = true; + } + const providers: { + appleAppStore?: Record; + googlePlay?: Record; + } = {}; + for (const provider of product.providers) { + providers[provider.providerId] = provider.configuration; + } + products[product.slug] = new SdkSchemaProduct({ + configuration: { perks: perksRecord, providers }, + properties: { name: product.name }, + slug: product.slug, + type: product.type, + }); + } + + return yield* HttpServerResponse.schemaJson(SdkSchema)( + new SdkSchema({ locations, perks, products, version: schema.version }), + { headers: schemaResponseHeaders(schema.version) }, + ).pipe(Effect.orDie); + }), + ); + }).pipe( + Effect.catchTags({ + SchemaServiceError: (e) => Effect.fail(new ApiSchemaServiceError({ cause: e.cause })), + }), + ), + ) + .handle("evaluateFeatureFlags", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const session = yield* AuthSession; + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new ApiSdkServiceError({ cause: "No project associated with this key" }), + ); + } + + const distinctId = session?.person?.distinctId ?? undefined; + + let personId: string | undefined; + if (distinctId) { + const mapping = yield* dbService.query.personIdentities.findFirst({ + where: { distinctId, projectId }, + }); + personId = mapping?.personId; + } + + const results = yield* featureFlagService.evaluateFlagsBatch({ + personId, + distinctId, + keys: payload.flagKeys ? [...payload.flagKeys] : undefined, + projectId, + }); + + return new SdkFeatureFlagsResponse({ + flags: results.map( + (r) => + new SdkFeatureFlagResult({ + enabled: r.enabled, + key: r.key, + payload: r.payload, + variantKey: r.variantKey, + }), + ), + }); + }), + ).pipe( + Effect.catchTags({ + FeatureFlagServiceError: (e) => Effect.fail(new ApiSdkServiceError({ cause: e.cause })), + EffectDrizzleQueryError: (e) => + Effect.fail(new ApiSdkServiceError({ cause: String(e.message) })), + }), + ), + ) + .handle("registerDevice", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const session = yield* AuthSession; + const project = session?.projects[0]; + if (!project) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No project associated with this SDK authentication session", + message: "No project associated with this SDK authentication session", + }), + ); + } + yield* requireNotificationsEnabled(project.organizationId); + const projectId = project.id; + const distinctId = session?.person?.distinctId; + if (!distinctId) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No SDK person identity found in this authentication session", + message: "No SDK person identity found in this authentication session", + }), + ); + } + + // Resolve the CANONICAL caller person from the (spoofable) distinct + // id, creating an anonymous person if needed — ownership is bound + // here server-side, never trusted from the raw header downstream. + const resolution = yield* personIdentityService.resolveDistinctId({ + projectId, + distinctId, + shouldCreatePerson: true, + eventTimestamp: new Date(), + setAttributes: {}, + setOnceAttributes: {}, + }); + const callerPersonId = resolution.identity.personId; + if (!callerPersonId) { + return yield* Effect.fail( + new ApiPushDeviceServiceError({ cause: "could not resolve caller person" }), + ); + } + + const { pushDeviceTokenId } = yield* notificationTokenService.register({ + projectId, + callerPersonId, + platform: payload.platform, + provider: payload.provider, + platformToken: payload.platformToken, + bundleId: payload.bundleId, + environment: payload.environment, + previousPushDeviceTokenId: payload.previousPushDeviceTokenId, + }); + return new RegisterDeviceResponse({ pushDeviceTokenId }); + }), + ).pipe( + Effect.catchTags({ + PersonServiceError: (e) => + Effect.fail(new ApiPushDeviceServiceError({ cause: String(e.cause) })), + NotificationTokenServiceError: (e) => + Effect.fail(new ApiPushDeviceServiceError({ cause: e.cause })), + InvalidPushMessageError: (e) => + Effect.fail(new ApiPushDeviceValidationError({ message: e.message })), + }), + ), + ) + .handle("refreshDevice", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const session = yield* AuthSession; + const project = session?.projects[0]; + if (!project) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No project associated with this SDK authentication session", + message: "No project associated with this SDK authentication session", + }), + ); + } + yield* requireNotificationsEnabled(project.organizationId); + const projectId = project.id; + const distinctId = session?.person?.distinctId; + if (!distinctId) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No SDK person identity found in this authentication session", + message: "No SDK person identity found in this authentication session", + }), + ); + } + + // Do NOT create a person here: a non-existent person cannot own the + // device, so an unresolved caller maps to the UNIFORM NotFound. + const resolution = yield* personIdentityService.resolveDistinctId({ + projectId, + distinctId, + shouldCreatePerson: false, + eventTimestamp: new Date(), + setAttributes: {}, + setOnceAttributes: {}, + }); + const callerPersonId = resolution.identity.personId; + if (!callerPersonId) { + return yield* Effect.fail( + new ApiPushDeviceNotFoundError({ message: "device token not found" }), + ); + } + + yield* notificationTokenService.refresh({ + projectId, + callerPersonId, + pushDeviceTokenId: payload.pushDeviceTokenId, + newPlatformToken: payload.platformToken, + }); + }), + ).pipe( + Effect.catchTags({ + PersonServiceError: (e) => + Effect.fail(new ApiPushDeviceServiceError({ cause: String(e.cause) })), + NotificationTokenServiceError: (e) => + Effect.fail(new ApiPushDeviceServiceError({ cause: e.cause })), + PushDeviceTokenNotFoundError: () => + Effect.fail(new ApiPushDeviceNotFoundError({ message: "device token not found" })), + }), + ), + ) + .handle("unregisterDevice", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const session = yield* AuthSession; + const project = session?.projects[0]; + if (!project) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No project associated with this SDK authentication session", + message: "No project associated with this SDK authentication session", + }), + ); + } + yield* requireNotificationsEnabled(project.organizationId); + const projectId = project.id; + const distinctId = session?.person?.distinctId; + if (!distinctId) { + return yield* Effect.fail( + new ApiAuthenticationError({ + cause: "No SDK person identity found in this authentication session", + message: "No SDK person identity found in this authentication session", + }), + ); + } + + const resolution = yield* personIdentityService.resolveDistinctId({ + projectId, + distinctId, + shouldCreatePerson: false, + eventTimestamp: new Date(), + setAttributes: {}, + setOnceAttributes: {}, + }); + const callerPersonId = resolution.identity.personId; + if (!callerPersonId) { + return yield* Effect.fail( + new ApiPushDeviceNotFoundError({ message: "device token not found" }), + ); + } + + yield* notificationTokenService.unregister({ + projectId, + callerPersonId, + pushDeviceTokenId: payload.pushDeviceTokenId, + }); + }), + ).pipe( + Effect.catchTags({ + PersonServiceError: (e) => + Effect.fail(new ApiPushDeviceServiceError({ cause: String(e.cause) })), + NotificationTokenServiceError: (e) => + Effect.fail(new ApiPushDeviceServiceError({ cause: e.cause })), + PushDeviceTokenNotFoundError: () => + Effect.fail(new ApiPushDeviceNotFoundError({ message: "device token not found" })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/users.ts b/apps/backend/src/routes/v1/users.ts new file mode 100644 index 000000000..07847f427 --- /dev/null +++ b/apps/backend/src/routes/v1/users.ts @@ -0,0 +1,21 @@ +import { User, VoidhashV1Api } from "@voidhash/api-contracts"; +import { ApiAuthenticationError } from "@voidhash/api-contracts/errors"; +import { UserService } from "@voidhash/core/services"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const UsersGroupLive = HttpApiBuilder.group(VoidhashV1Api, "users", (handlers) => + Effect.gen(function* () { + const userService = yield* UserService; + return handlers.handle("getUser", () => + bridgeAuthSession(userService.getUser().pipe(Effect.map((user) => new User(user)))).pipe( + Effect.catchTags({ + AuthenticationError: (e) => + Effect.fail(new ApiAuthenticationError({ cause: e.cause, message: e.message })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/v1/webhooks.ts b/apps/backend/src/routes/v1/webhooks.ts new file mode 100644 index 000000000..64d707c84 --- /dev/null +++ b/apps/backend/src/routes/v1/webhooks.ts @@ -0,0 +1,233 @@ +import { + VoidhashV1Api, + WebhookDelivery, + WebhookDeliveryAttempt, + WebhookDeliveryWithAttempts, + WebhookEndpoint, +} from "@voidhash/api-contracts"; +import { + ApiActionForbiddenError, + ApiWebhookDeliveryNotFoundError, + ApiWebhookEndpointNotFoundError, + ApiWebhookServiceError, + ApiWebhookValidationError, +} from "@voidhash/api-contracts/errors"; +import { WebhookManagerService } from "@voidhash/core/services/webhookManager/WebhookManagerService"; +import { extractAuthorizedProjectId } from "@voidhash/core/utils"; +import { AuthSession } from "@voidhash/rpc"; +import { Effect } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { bridgeAuthSession } from "../../ApiMiddlewares.ts"; + +export const WebhooksGroupLive = HttpApiBuilder.group(VoidhashV1Api, "webhooks", (handlers) => + Effect.gen(function* () { + const webhookManagerService = yield* WebhookManagerService; + + return handlers + .handle("createWebhookEndpoint", ({ payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const endpoint = yield* webhookManagerService.createEndpoint({ ...payload, projectId }); + return new WebhookEndpoint(endpoint); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookValidationError: (e) => + Effect.fail(new ApiWebhookValidationError({ message: e.message })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("listWebhookEndpoints", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const endpoints = yield* webhookManagerService.getEndpoints({ projectId }); + return endpoints.map((endpoint) => new WebhookEndpoint(endpoint)); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("getWebhookEndpoint", ({ params }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const endpoint = yield* webhookManagerService.getEndpointById({ + endpointId: params.endpointId, + projectId, + }); + return new WebhookEndpoint(endpoint); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookEndpointNotFoundError: (e) => + Effect.fail(new ApiWebhookEndpointNotFoundError({ endpointId: e.endpointId })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("updateWebhookEndpoint", ({ params, payload }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const endpoint = yield* webhookManagerService.updateEndpoint({ + ...payload, + endpointId: params.endpointId, + projectId, + }); + return new WebhookEndpoint(endpoint); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookEndpointNotFoundError: (e) => + Effect.fail(new ApiWebhookEndpointNotFoundError({ endpointId: e.endpointId })), + WebhookValidationError: (e) => + Effect.fail(new ApiWebhookValidationError({ message: e.message })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("deleteWebhookEndpoint", ({ params }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + return yield* webhookManagerService.deleteEndpoint({ + endpointId: params.endpointId, + projectId, + }); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookEndpointNotFoundError: (e) => + Effect.fail(new ApiWebhookEndpointNotFoundError({ endpointId: e.endpointId })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("rotateWebhookSecret", ({ params }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const endpoint = yield* webhookManagerService.rotateSecret({ + endpointId: params.endpointId, + projectId, + }); + return new WebhookEndpoint(endpoint); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookEndpointNotFoundError: (e) => + Effect.fail(new ApiWebhookEndpointNotFoundError({ endpointId: e.endpointId })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("testWebhookEndpoint", ({ params }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const delivery = yield* webhookManagerService.testEndpoint({ + endpointId: params.endpointId, + projectId, + }); + return new WebhookDelivery(delivery); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookEndpointNotFoundError: (e) => + Effect.fail(new ApiWebhookEndpointNotFoundError({ endpointId: e.endpointId })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("listWebhookDeliveries", () => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const deliveries = yield* webhookManagerService.getDeliveries({ projectId }); + return deliveries.map((delivery) => new WebhookDelivery(delivery)); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("getWebhookDelivery", ({ params }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const delivery = yield* webhookManagerService.getDeliveryById({ + deliveryId: params.deliveryId, + projectId, + }); + return new WebhookDeliveryWithAttempts({ + ...delivery, + attempts: delivery.attempts.map((attempt) => new WebhookDeliveryAttempt(attempt)), + }); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookDeliveryNotFoundError: (e) => + Effect.fail(new ApiWebhookDeliveryNotFoundError({ deliveryId: e.deliveryId })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ) + .handle("retryWebhookDelivery", ({ params }) => + bridgeAuthSession( + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = yield* extractAuthorizedProjectId(authSession); + const delivery = yield* webhookManagerService.retryDelivery({ + deliveryId: params.deliveryId, + projectId, + }); + return new WebhookDelivery(delivery); + }), + ).pipe( + Effect.catchTags({ + ActionForbiddenError: (e) => + Effect.fail(new ApiActionForbiddenError({ message: e.message })), + WebhookDeliveryNotFoundError: (e) => + Effect.fail(new ApiWebhookDeliveryNotFoundError({ deliveryId: e.deliveryId })), + WebhookValidationError: (e) => + Effect.fail(new ApiWebhookValidationError({ message: e.message })), + WebhookServiceError: (e) => Effect.fail(new ApiWebhookServiceError({ cause: e.cause })), + }), + ), + ); + }), +); diff --git a/apps/backend/src/routes/webhook-endpoints/apple-server-to-server.ts b/apps/backend/src/routes/webhook-endpoints/apple-server-to-server.ts new file mode 100644 index 000000000..82532093b --- /dev/null +++ b/apps/backend/src/routes/webhook-endpoints/apple-server-to-server.ts @@ -0,0 +1,119 @@ +/** + * Apple App Store Server-to-Server notification endpoint — + * `POST /api/v1/webhook-endpoints/apple-server-to-server/:paymentProviderConfigurationId`. + * + * Decodes the signed-payload envelope (`{ signedPayload: string }`) and + * forwards to `AppStorePaymentProviderService.acceptServerNotification`, + * which owns Apple JWS verification, transaction decoding, and dispatch to + * the matching `record*` method. The JWS body itself is opaque at this layer. + * + * The backend supplies the public App Store provider and webhook-handler + * engine. Terminal signature or payload failures are acknowledged by that + * engine, while transient infrastructure failures surface as 500 so Apple can + * retry delivery. + */ +import { + AppStorePaymentProviderService, + AppStorePaymentProviderServiceError, +} from "@voidhash/core/services"; +import { Effect, Layer, Schema } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +const AppleServerToServerPathParamsSchema = Schema.Struct({ + paymentProviderConfigurationId: Schema.String, +}); + +/** + * Wire shape of the Apple S2S notification envelope — a single JWS in + * `signedPayload`. Mirrors `ResponseBodyV2Schema` from + * `@voidhash/app-store-server-sdk`; inlined here so the new package + * doesn't have to depend on that SDK just to decode the envelope. + */ +const AppleServerNotificationBodySchema = Schema.Struct({ + signedPayload: Schema.String, +}); + +const decodeAppleServerNotificationBody = Schema.decodeUnknownEffect( + AppleServerNotificationBodySchema, +); + +const invalidPayloadResponse = HttpServerResponse.json( + { error: "Invalid Apple server notification payload" }, + { status: 400 }, +); + +const registerAppleServerToServerNotificationRoute = Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + + yield* router.add( + "POST", + "/api/v1/webhook-endpoints/apple-server-to-server/:paymentProviderConfigurationId", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const pathParamsResult = yield* Effect.result( + HttpRouter.schemaPathParams(AppleServerToServerPathParamsSchema), + ); + + if (pathParamsResult._tag === "Failure") { + return yield* invalidPayloadResponse; + } + + const bodyResult = yield* Effect.result( + request.json.pipe(Effect.flatMap(decodeAppleServerNotificationBody)), + ); + + if (bodyResult._tag === "Failure") { + return yield* invalidPayloadResponse; + } + + const appStorePaymentProviderService = yield* AppStorePaymentProviderService; + yield* appStorePaymentProviderService.acceptServerNotification({ + paymentProviderConfigurationId: pathParamsResult.success.paymentProviderConfigurationId, + receivedAt: new Date(), + signedPayload: bodyResult.success.signedPayload, + }); + + yield* Effect.logInfo("Apple server-to-server notification accepted", { + paymentProviderConfigurationId: pathParamsResult.success.paymentProviderConfigurationId, + signedPayloadLength: bodyResult.success.signedPayload.length, + }); + + return yield* HttpServerResponse.json({ received: true }, { status: 202 }); + }).pipe( + // The real handler resolves terminal failures (signature/verification/ + // parse/app-identifier mismatches) to `{ accepted: true, handled: false }` + // — those reach the 202 ack above so Apple stops retrying. An + // `AppStorePaymentProviderServiceError` therefore signals a TRANSIENT / + // infrastructure failure (config lookup, DB, Apple 5xx), which must return + // 5xx so Apple's retry loop re-delivers the notification — never 501, + // which Apple treats as terminal. + Effect.catchTag( + "AppStorePaymentProviderServiceError", + (error: AppStorePaymentProviderServiceError) => + Effect.gen(function* () { + yield* Effect.logWarning( + "Apple server-to-server notification failed transiently; signaling retry", + { cause: error.cause }, + ); + return yield* HttpServerResponse.json( + { error: "Apple server notification processing failed", received: false }, + { status: 500 }, + ); + }), + ), + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logError("Apple server-to-server notification error", error); + return yield* HttpServerResponse.json( + { error: "Apple server notification processing failed" }, + { status: 500 }, + ); + }), + ), + ), + ); +}); + +export const AppleServerToServerNotificationRouteLayer = Layer.effectDiscard( + registerAppleServerToServerNotificationRoute, +); diff --git a/apps/backend/src/routes/webhook-endpoints/google-play-rtdn.test.ts b/apps/backend/src/routes/webhook-endpoints/google-play-rtdn.test.ts new file mode 100644 index 000000000..99061e0f0 --- /dev/null +++ b/apps/backend/src/routes/webhook-endpoints/google-play-rtdn.test.ts @@ -0,0 +1,126 @@ +import { + GooglePlayPaymentProviderService, + type GooglePlayPaymentProviderServiceShape, +} from "@voidhash/core/services"; +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { describe, expect, it } from "vite-plus/test"; + +import { + GooglePubSubPushVerificationError, + GooglePubSubPushVerifier, + type GooglePubSubPushVerifierShape, +} from "../../GooglePubSubPushVerifier.ts"; +import { GooglePlayRtdnNotificationRouteLayer } from "./google-play-rtdn.ts"; + +const path = "/api/v1/webhook-endpoints/google-play-rtdn/config-1"; +const body = { + message: { + data: btoa(JSON.stringify({ packageName: "com.example", testNotification: {} })), + messageId: "message-1", + }, +}; + +const serve = ( + authorization: string | undefined, + verifier: GooglePubSubPushVerifierShape, + onAccept: () => void, +) => + Effect.gen(function* () { + const paymentProvider: GooglePlayPaymentProviderServiceShape = { + acceptRtdnNotification: (input) => + Effect.sync(() => { + onAccept(); + expect(input.paymentProviderConfigurationId).toBe("config-1"); + expect(input.pubsubBody).toEqual(body); + return { + accepted: true, + handled: true, + notificationType: "TEST", + notificationUUID: undefined, + subtype: undefined, + }; + }), + processSdkTransaction: () => Effect.die("not used"), + }; + const dependencies = Layer.mergeAll( + Layer.succeed(GooglePubSubPushVerifier, verifier), + Layer.succeed(GooglePlayPaymentProviderService, paymentProvider), + ); + const handler = yield* HttpRouter.toHttpEffect( + GooglePlayRtdnNotificationRouteLayer.pipe(HttpRouter.provideRequest(dependencies)), + ); + const headers = new Headers({ "content-type": "application/json" }); + if (authorization) { + headers.set("authorization", authorization); + } + const response = yield* handler.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb( + new Request(`http://localhost${path}`, { + body: JSON.stringify(body), + headers, + method: "POST", + }), + ), + ), + ); + return HttpServerResponse.toWeb(response); + }).pipe(Effect.scoped, Effect.runPromise); + +describe("Google Play RTDN route authentication", () => { + it("rejects an unauthenticated request before processing its body", async () => { + let acceptCalls = 0; + const response = await serve( + undefined, + { + verify: () => + Effect.fail( + new GooglePubSubPushVerificationError({ + kind: "unauthorized", + message: "missing token", + }), + ), + }, + () => { + acceptCalls += 1; + }, + ); + + expect(response.status).toBe(401); + expect(acceptCalls).toBe(0); + }); + + it("returns a retryable error when authenticated push is not configured", async () => { + let acceptCalls = 0; + const response = await serve( + "Bearer token", + { + verify: () => + Effect.fail( + new GooglePubSubPushVerificationError({ + kind: "misconfigured", + message: "missing configuration", + }), + ), + }, + () => { + acceptCalls += 1; + }, + ); + + expect(response.status).toBe(503); + expect(acceptCalls).toBe(0); + }); + + it("processes a request only after caller authentication succeeds", async () => { + let acceptCalls = 0; + const response = await serve("Bearer signed-token", { verify: () => Effect.void }, () => { + acceptCalls += 1; + }); + + expect(response.status).toBe(200); + expect(acceptCalls).toBe(1); + }); +}); diff --git a/apps/backend/src/routes/webhook-endpoints/google-play-rtdn.ts b/apps/backend/src/routes/webhook-endpoints/google-play-rtdn.ts new file mode 100644 index 000000000..25fd65081 --- /dev/null +++ b/apps/backend/src/routes/webhook-endpoints/google-play-rtdn.ts @@ -0,0 +1,125 @@ +/** + * Google Play Real-Time Developer Notification (RTDN) endpoint — + * `POST /api/v1/webhook-endpoints/google-play-rtdn/:paymentProviderConfigurationId`. + * + * Google delivers RTDN through a Pub/Sub PUSH subscription, so the HTTP body is + * a Pub/Sub envelope (`{ message: { data: base64(DeveloperNotification), ... } }`). + * This handler forwards the raw envelope to + * `GooglePlayPaymentProviderService.acceptRtdnNotification`, which owns the + * base64 decode, the authoritative Play API re-fetch, and dispatch to the + * matching `record*` method. + * + * Ack semantics mirror Pub/Sub's retry contract: terminal/business outcomes + * (bad envelope, unmapped product, verified-but-unhandled) are folded into a + * 2xx ack so Pub/Sub stops redelivering; only a transient/infra failure + * (`GooglePlayPaymentProviderServiceError`) returns 5xx so Pub/Sub re-delivers. + */ +import { + GooglePlayPaymentProviderService, + GooglePlayPaymentProviderServiceError, +} from "@voidhash/core/services"; +import { Effect, Layer, Schema } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { GooglePubSubPushVerifier } from "../../GooglePubSubPushVerifier.ts"; + +const GooglePlayRtdnPathParamsSchema = Schema.Struct({ + paymentProviderConfigurationId: Schema.String, +}); + +const invalidPayloadResponse = HttpServerResponse.json( + { error: "Invalid Google Play RTDN payload" }, + { status: 400 }, +); + +const registerGooglePlayRtdnNotificationRoute = Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + + yield* router.add( + "POST", + "/api/v1/webhook-endpoints/google-play-rtdn/:paymentProviderConfigurationId", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const pathParamsResult = yield* Effect.result( + HttpRouter.schemaPathParams(GooglePlayRtdnPathParamsSchema), + ); + + if (pathParamsResult._tag === "Failure") { + return yield* invalidPayloadResponse; + } + + const pubSubPushVerifier = yield* GooglePubSubPushVerifier; + const authenticationResult = yield* Effect.result( + pubSubPushVerifier.verify(request.headers.authorization), + ); + if (authenticationResult._tag === "Failure") { + const error = authenticationResult.failure; + const status = error.kind === "misconfigured" ? 503 : 401; + yield* Effect.logWarning("Google Play RTDN caller authentication failed", { + kind: error.kind, + paymentProviderConfigurationId: pathParamsResult.success.paymentProviderConfigurationId, + }); + return yield* HttpServerResponse.json( + { error: "Google Play RTDN caller authentication failed", received: false }, + { status }, + ); + } + + // The Pub/Sub envelope shape is validated inside the service (it owns the + // base64/RTDN decode); here we only need the parsed JSON body. + const bodyResult = yield* Effect.result(request.json); + if (bodyResult._tag === "Failure") { + return yield* invalidPayloadResponse; + } + + const googlePlayPaymentProviderService = yield* GooglePlayPaymentProviderService; + const result = yield* googlePlayPaymentProviderService.acceptRtdnNotification({ + paymentProviderConfigurationId: pathParamsResult.success.paymentProviderConfigurationId, + pubsubBody: bodyResult.success, + receivedAt: new Date(), + }); + + yield* Effect.logInfo("Google Play RTDN notification accepted", { + handled: result.handled, + notificationType: result.notificationType, + paymentProviderConfigurationId: pathParamsResult.success.paymentProviderConfigurationId, + }); + + // 204 acks the Pub/Sub delivery (terminal + handled outcomes both ack). + return yield* HttpServerResponse.json({ received: true }, { status: 200 }); + }).pipe( + // The service folds terminal/business outcomes into a success value, so a + // `GooglePlayPaymentProviderServiceError` signals a TRANSIENT/infra failure + // (config lookup, DB, Play 5xx/rate-limit) — return 5xx so Pub/Sub + // re-delivers. Never return a status Pub/Sub would treat differently for a + // transient error. + Effect.catchTag( + "GooglePlayPaymentProviderServiceError", + (error: GooglePlayPaymentProviderServiceError) => + Effect.gen(function* () { + yield* Effect.logWarning( + "Google Play RTDN notification failed transiently; signaling retry", + { cause: error.cause }, + ); + return yield* HttpServerResponse.json( + { error: "Google Play RTDN processing failed", received: false }, + { status: 500 }, + ); + }), + ), + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logError("Google Play RTDN notification error", error); + return yield* HttpServerResponse.json( + { error: "Google Play RTDN processing failed" }, + { status: 500 }, + ); + }), + ), + ), + ); +}); + +export const GooglePlayRtdnNotificationRouteLayer = Layer.effectDiscard( + registerGooglePlayRtdnNotificationRoute, +); diff --git a/apps/backend/src/routes/webhook-endpoints/stripe.ts b/apps/backend/src/routes/webhook-endpoints/stripe.ts new file mode 100644 index 000000000..5025a8db1 --- /dev/null +++ b/apps/backend/src/routes/webhook-endpoints/stripe.ts @@ -0,0 +1,108 @@ +/** + * Stripe webhook endpoint — + * `POST /api/v1/webhook-endpoints/stripe/:paymentProviderConfigurationId`. + * + * Stripe signs the webhook with an HMAC over the EXACT raw request body, so + * this handler reads `request.text` (never `request.json`) and forwards the raw + * body + the `Stripe-Signature` header to + * `StripePaymentProviderService.acceptWebhookEvent`, which owns verification, + * decoding, and dispatch to the matching `record*` method. + * + * HTTP contract (Stripe retries on any non-2xx): + * - verified + handled / parked / permanently-unhandleable → 200 (stop retrying), + * - bad signature → 400 (permanent; visible in the Stripe dashboard), + * - unknown configuration/project → 404 (permanent), + * - transient infra failure (DB / Stripe API) → 500 (Stripe re-delivers). + * The kind→status mapping is driven by `StripePaymentProviderServiceError.kind`. + */ +import { + StripePaymentProviderService, + StripePaymentProviderServiceError, +} from "@voidhash/core/services"; +import { Effect, Layer, Schema } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +const StripeWebhookPathParamsSchema = Schema.Struct({ + paymentProviderConfigurationId: Schema.String, +}); + +const invalidPayloadResponse = HttpServerResponse.json( + { error: "Invalid Stripe webhook request" }, + { status: 400 }, +); + +const registerStripeWebhookRoute = Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + + yield* router.add( + "POST", + "/api/v1/webhook-endpoints/stripe/:paymentProviderConfigurationId", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const pathParamsResult = yield* Effect.result( + HttpRouter.schemaPathParams(StripeWebhookPathParamsSchema), + ); + if (pathParamsResult._tag === "Failure") { + return yield* invalidPayloadResponse; + } + + const signatureHeader = request.headers["stripe-signature"] ?? ""; + if (!signatureHeader) { + return yield* HttpServerResponse.json( + { error: "Missing stripe-signature header" }, + { status: 400 }, + ); + } + + // Raw body bytes — Stripe's HMAC is computed over the exact payload, so we + // must NOT parse/re-serialize before verification. + const rawBody = yield* request.text; + + const stripePaymentProviderService = yield* StripePaymentProviderService; + const result = yield* stripePaymentProviderService.acceptWebhookEvent({ + paymentProviderConfigurationId: pathParamsResult.success.paymentProviderConfigurationId, + rawBody, + receivedAt: new Date(), + signatureHeader, + }); + + yield* Effect.logInfo("Stripe webhook accepted", { + eventId: result.eventId, + eventType: result.eventType, + handled: result.handled, + paymentProviderConfigurationId: pathParamsResult.success.paymentProviderConfigurationId, + }); + + return yield* HttpServerResponse.json({ received: true }, { status: 200 }); + }).pipe( + Effect.catchTag( + "StripePaymentProviderServiceError", + (error: StripePaymentProviderServiceError) => + Effect.gen(function* () { + const status = + error.kind === "signature" ? 400 : error.kind === "not_found" ? 404 : 500; + yield* Effect.logWarning("Stripe webhook processing failed", { + cause: error.cause, + kind: error.kind ?? "transient", + status, + }); + return yield* HttpServerResponse.json( + { error: "Stripe webhook processing failed", received: false }, + { status }, + ); + }), + ), + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logError("Stripe webhook error", error); + return yield* HttpServerResponse.json( + { error: "Stripe webhook processing failed" }, + { status: 500 }, + ); + }), + ), + ), + ); +}); + +export const StripeWebhookNotificationRouteLayer = Layer.effectDiscard(registerStripeWebhookRoute); diff --git a/apps/backend/src/routes/webhooks/workos.ts b/apps/backend/src/routes/webhooks/workos.ts new file mode 100644 index 000000000..cd1250296 --- /dev/null +++ b/apps/backend/src/routes/webhooks/workos.ts @@ -0,0 +1,314 @@ +/** + * 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/apps/backend/src/rpc-smoke.integration.test.ts b/apps/backend/src/rpc-smoke.integration.test.ts new file mode 100644 index 000000000..8eda92f78 --- /dev/null +++ b/apps/backend/src/rpc-smoke.integration.test.ts @@ -0,0 +1,502 @@ +/** + * Backend RPC + WorkOS-webhook smoke, run IN-PROCESS against the real backend + * stack provisioned once by `packages/core/test/_testing/globalSetup.ts` (the + * same deploy the core service integration tests use). Nothing is deployed or + * called over the wire here: + * + * - **RPC cases** dispatch through the production handler graph + * (`buildBackendRpcServices`) via `RpcTest.makeClient(RpcGroups)` — an + * in-memory client↔server with no HTTP/serialization. The `rpcSmokeCases` + * manifest, its payloads, and the `runRpcSmokeCase` runner are reused + * unchanged; only the transport differs from the old over-the-wire client. + * - **WorkOS webhook cases** POST synthetic, correctly-signed requests to the + * real `buildBackendFetch` route graph via a synthetic `HttpServerRequest`, + * exercising the real SDK signature verification (`TestRealWorkosLive` wraps + * real `verifyWebhook` with faked networked user lookups). + * + * Infra is real where the deployed stack is real: `Db`/`Clickhouse`/`Workos` + * are built from the gated `testConnections`. Only the genuine external/platform + * seams are doubled — `WorkosOrgPort` is faked so organization RPCs never + * mutate real WorkOS; payment providers use local stubs; the webhook manager + * stays a thin DB-backed stub (the production + * `webhook-rpcs.ts` passes `projectId: ""`, which the tolerant stub accepts). + * The smoke fixture is seeded in-process via `seedSmokeData` (formerly the + * `/__test/seed` route). + */ +import { Effect, Layer } from "effect"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { RpcClient, RpcTest } from "effect/unstable/rpc"; +import { describe, expect, inject, test } from "vitest"; + +import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { PaywallArtifactStore, Workos } from "@voidhash/core/services"; +import { Db } from "@voidhash/db"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; + +import { + BackendComponentCompilerStubLive, + BackendMimicHostStubLive, + BackendNoopIdentityProjectionPublisherLive, + BackendPaymentProviderStubsLive, + BackendPaywallArtifactStoreStubLive, + BackendPaywallAssetConfigLive, + BackendPublicFileStoreStubLive, + BackendSnapshotImageRendererStubLive, + NoBackendFeatures, + NoBackendRpcExtension, + buildBackendFetch, + buildBackendRpcServices, +} from "./BackendApp.ts"; +import { BackendRpcGroups as RpcGroups } from "./BackendRpcGroups.ts"; +import { + TEST_WORKOS_WEBHOOK_SECRET, + TestClickhouseLive, + TestProjectSchemaCacheLive, + TestRealWorkosLive, + TestWebhookManagerServiceLive, + TestWorkflowPortsLive, + TestWorkosOrgPortLive, +} from "./testing/TestLayers.ts"; +import { TestRpcAuthLive } from "./testing/TestRpcAuth.ts"; +import type { BackendTestConnections } from "./testing/BackendTestConnections.ts"; +import { + assertRpcSmokeManifestCoverage, + makeRpcSmokeContext, + rpcSmokeCases, + type RpcSmokeCase, + type RpcSmokeContext, + type RpcSmokeRole, +} from "./testing/rpc-smoke-cases.ts"; +import { makeSmokeIds, SMOKE_ROLE_HEADER, SMOKE_RUN_ID_HEADER } from "./testing/smoke-ids.ts"; +import { seedSmokeData } from "./testing/smoke-seed.ts"; + +/** + * Read the gated connection credentials shared by the once-per-run deploy. Fails + * loudly when absent or null — that means `globalSetup` failed, or it ran on a + * production/preview stage (where `testConnections` is intentionally withheld). + */ +const requireTestConnections = (): BackendTestConnections => { + const tc = inject("coreStackOutput")?.testConnections ?? null; + if (tc === null) { + throw new Error( + "rpc-smoke: shared deploy output missing or testConnections is null — globalSetup failed, or it ran on a production/preview stage.", + ); + } + return tc; +}; + +/** + * Minimal {@link PlatformRuntime} stub — discharges the runtime-phase marker the + * push-dispatch port colors its effects with (the noop dispatch never reads it). + */ +const SmokePlatformRuntimeStub = Layer.succeed(PlatformRuntime, PlatformRuntime.of({})); + +const SmokePaywallArtifactStoreLive = Layer.sync(PaywallArtifactStore, () => { + const objects = new Map< + string, + { readonly body: Uint8Array; readonly contentType: string | null } + >(); + return PaywallArtifactStore.of({ + bucketName: "rpc-smoke-paywall-artifacts", + getObject: (key) => Effect.succeed(objects.get(key) ?? null), + head: (key) => + Effect.succeed(objects.has(key) ? { size: objects.get(key)?.body.length ?? 0 } : null), + putObject: ({ body, contentType, key }) => + Effect.sync(() => { + objects.set(key, { body, contentType: contentType ?? null }); + }), + }); +}); + +/** Real `Workos` SDK layer built from the deployed stack's credentials. */ +const workosLayer = (tc: BackendTestConnections): Layer.Layer => + Workos.layer({ + apiKey: Effect.succeed(tc.workos.apiKey), + clientId: Effect.succeed(tc.workos.clientId), + cookieName: Effect.succeed(tc.workos.cookieName), + cookiePassword: Effect.succeed(tc.workos.cookiePassword), + webhookSecret: Effect.succeed(tc.workos.webhookSecret), + }); + +/** + * In-process infrastructure for the RPC handler graph: real `Db`/`Clickhouse`/ + * `Workos` from the deployed stack, an in-memory schema cache, a faked + * `WorkosOrgPort`, and local payment/paywall/identity stubs. + */ +const makeRpcInfra = (tc: BackendTestConnections) => + Layer.mergeAll( + Db.layer(tc.db), + ClickhouseWebClient.layer(tc.clickhouse).pipe(Layer.orDie), + workosLayer(tc), + TestProjectSchemaCacheLive, + TestWorkosOrgPortLive, + BackendMimicHostStubLive, + BackendComponentCompilerStubLive, + BackendSnapshotImageRendererStubLive, + BackendPaymentProviderStubsLive, + BackendPaywallAssetConfigLive, + SmokePaywallArtifactStoreLive, + BackendPublicFileStoreStubLive, + BackendNoopIdentityProjectionPublisherLive, + ); + +/** + * In-process infrastructure for the WorkOS webhook route: real `Db`, the + * real-verify/fake-getUser `Workos` wrapper, and the same stubs otherwise (the + * webhook path needs no ClickHouse, so a no-op stands in). + */ +const makeWebhookInfra = (tc: BackendTestConnections) => + Layer.mergeAll( + Db.layer(tc.db), + TestClickhouseLive, + TestProjectSchemaCacheLive, + TestWorkosOrgPortLive, + BackendMimicHostStubLive, + BackendComponentCompilerStubLive, + BackendSnapshotImageRendererStubLive, + BackendPaymentProviderStubsLive, + BackendPaywallAssetConfigLive, + BackendPaywallArtifactStoreStubLive, + BackendPublicFileStoreStubLive, + BackendNoopIdentityProjectionPublisherLive, + TestRealWorkosLive, + ); + +const smokeHeaders = (context: RpcSmokeContext, role: RpcSmokeRole) => ({ + [SMOKE_ROLE_HEADER]: role, + [SMOKE_RUN_ID_HEADER]: context.runId, +}); + +const formatFailure = (failure: unknown): string => { + if (failure instanceof Error) { + const entries = Object.entries(failure as unknown as Record); + const details = entries.length > 0 ? ` ${JSON.stringify(Object.fromEntries(entries))}` : ""; + return `${failure.name}: ${failure.message}${details}`; + } + try { + return JSON.stringify(failure); + } catch { + return String(failure); + } +}; + +const runRpcSmokeCase = ( + client: Record Effect.Effect>, + smokeCase: RpcSmokeCase, + context: RpcSmokeContext, +) => + Effect.gen(function* () { + const payload = smokeCase.payload?.(context); + const request = client[smokeCase.tag]; + if (!request) { + throw new Error(`RPC client is missing ${smokeCase.tag}`); + } + + const result = yield* request(payload).pipe( + RpcClient.withHeaders(smokeHeaders(context, smokeCase.role)), + Effect.match({ + onFailure: (failure) => ({ _tag: "Failure" as const, failure }), + onSuccess: (value) => ({ _tag: "Success" as const, value }), + }), + ); + const expected = smokeCase.expected ?? { success: true }; + + if ("errorTag" in expected) { + expect(result._tag, `${smokeCase.tag} should fail`).toBe("Failure"); + if (result._tag === "Failure") { + const actual = result.failure as { readonly _tag?: string }; + expect(actual._tag, `${smokeCase.tag} error tag`).toBe(expected.errorTag); + } + return; + } + + if (result._tag === "Failure") { + throw new Error(`${smokeCase.tag} failed unexpectedly: ${formatFailure(result.failure)}`); + } + expect(result._tag, `${smokeCase.tag} should succeed`).toBe("Success"); + if (result._tag === "Success") { + smokeCase.afterSuccess?.(context, result.value); + } + }); + +/** + * Run id that namespaces the seeded fixtures so independent runs against the + * shared database don't collide; set `VOIDHASH_RPC_SMOKE_RUN_ID` to reproduce a + * specific run. + */ +const runId = + (process.env.VOIDHASH_RPC_SMOKE_RUN_ID ?? crypto.randomUUID()) + .toLowerCase() + .replaceAll(/[^a-z0-9]/g, "") + .slice(0, 10) || "default"; + +describe("Backend RPC smoke", () => { + test("dispatches every RPC against the in-process handler graph", async () => { + const tc = requireTestConnections(); + assertRpcSmokeManifestCoverage(); + + // Seed the fixture in-process (formerly the `/__test/seed` HTTP route). + await Effect.runPromise(seedSmokeData(runId).pipe(Effect.provide(Db.layer(tc.db)))); + + const ids = makeSmokeIds(runId); + const context = makeRpcSmokeContext(runId, ids, "https://example.test/webhook-target"); + + const infra = makeRpcInfra(tc); + const rpcServices = buildBackendRpcServices({ + auth: TestRpcAuthLive, + features: NoBackendFeatures, + rpcExtension: NoBackendRpcExtension, + infrastructure: infra, + webhookManager: TestWebhookManagerServiceLive.pipe(Layer.provide(infra)), + }); + + // The infra layer (incl. `Db`) is scoped: its `Db.make` finalizer closes + // the mysql2 connection when the scope it was built into closes. It must + // therefore be provided to the WHOLE block — `makeClient` *and* the + // dispatch loop — not just to `makeClient`. Providing it to `makeClient` + // alone ties the layer's scope to `makeClient`'s completion, so the Db + // connection is closed the instant the client resolves; the first + // dispatch then dies with "Can't add new command when connection is in + // closed state". The in-memory server captures the handler context at + // build time, so the loop only needs the backing resources to stay live. + await Effect.runPromise( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(RpcGroups); + + for (const smokeCase of rpcSmokeCases) { + yield* runRpcSmokeCase(client as never, smokeCase, context); + } + }).pipe(Effect.provide(rpcServices), Effect.provide(TestWorkflowPortsLive), Effect.scoped), + ); + }, 600_000); +}); + +// --- WorkOS webhook (in-process against the real route graph) ---------------- + +const toHex = (buffer: ArrayBuffer): string => + Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join(""); + +/** + * Sign a webhook payload the way the WorkOS SDK verifies it: HMAC-SHA256 over + * `${timestamp}.${JSON.stringify(payload)}` keyed by the webhook secret, header + * formatted `t=, v1=`. The returned `body` is the exact bytes to POST + * so the handler's `JSON.parse(rawBody)` round-trips to the signed string. + */ +const signWorkosWebhook = async (payload: object, secret: string, timestamp: number) => { + const body = JSON.stringify(payload); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(`${timestamp}.${body}`), + ); + return { body, header: `t=${timestamp}, v1=${toHex(signature)}` }; +}; + +const makeUserCreatedEvent = () => { + const suffix = crypto.randomUUID().replaceAll("-", ""); + return { + created_at: new Date().toISOString(), + data: { + created_at: new Date().toISOString(), + email: `webhook-test-${suffix}@example.com`, + email_verified: true, + external_id: null, + first_name: "Webhook", + id: `user_${suffix}`, + last_name: "Test", + object: "user", + profile_picture_url: null, + updated_at: new Date().toISOString(), + }, + event: "user.created", + id: `event_${suffix}`, + }; +}; + +const makeOrganizationUpdatedEvent = (ids: ReturnType) => { + const suffix = crypto.randomUUID().replaceAll("-", ""); + return { + created_at: new Date().toISOString(), + data: { + allow_profiles_outside_organization: false, + created_at: new Date().toISOString(), + domains: [], + external_id: ids.organizationId, + id: ids.workosOrganizationId, + metadata: {}, + name: "Webhook Renamed Organization", + object: "organization", + updated_at: new Date().toISOString(), + }, + event: "organization.updated", + id: `event_${suffix}`, + }; +}; + +const makeOrganizationMembershipCreatedEvent = (ids: ReturnType) => { + const suffix = crypto.randomUUID().replaceAll("-", ""); + return { + created_at: new Date().toISOString(), + data: { + created_at: new Date().toISOString(), + custom_attributes: {}, + directory_managed: false, + id: `workos_mem_webhook_${suffix.slice(0, 16)}`, + object: "organization_membership", + organization_id: ids.workosOrganizationId, + organization_name: "Webhook Renamed Organization", + role: { slug: "admin" }, + status: "active", + updated_at: new Date().toISOString(), + user_id: ids.workosNormalUserId, + }, + event: "organization_membership.created", + id: `event_${suffix}`, + }; +}; + +/** + * Sends a request to the real backend route graph in-process: builds + * `buildBackendFetch`, feeds it a synthetic `HttpServerRequest`, and reads the + * response back as a web `Response`. Mirrors how the deployed worker invokes the + * built handler (provide the workflow-port no-ops to both the build and the + * per-request handler). + */ +const requestBackend = (tc: BackendTestConnections, path: string, init: RequestInit) => + Effect.scoped( + Effect.gen(function* () { + const handler = yield* buildBackendFetch({ + auth: TestRpcAuthLive, + features: NoBackendFeatures, + rpcExtension: NoBackendRpcExtension, + infrastructure: makeWebhookInfra(tc), + }).pipe(Effect.provide(TestWorkflowPortsLive)); + + const request = HttpServerRequest.fromWeb(new Request(`http://backend.local${path}`, init)); + const response = yield* handler.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.provide(TestWorkflowPortsLive), + // The push send path colors its effects with the runtime-phase marker + // (queue dispatch); the deployed worker's fetch provides it, so mirror + // that here with a minimal stub. + Effect.provide(SmokePlatformRuntimeStub), + ); + + const web = HttpServerResponse.toWeb(response); + const text = yield* Effect.promise(() => web.text()); + return { status: web.status, text }; + // The auth middleware now resolves `Db` ambiently, so its requirement + // surfaces on the built handler (exactly as in the deployed worker, which + // provides `Db` at the outer fetch scope). Provide it here to match. + }).pipe(Effect.provide(Db.layer(tc.db))), + ); + +describe("Backend runtime capabilities", () => { + test("keeps Enterprise UI capabilities dormant in the core-only composition", async () => { + const tc = requireTestConnections(); + const { status, text } = await Effect.runPromise( + requestBackend(tc, "/api/runtime-capabilities", { method: "GET" }), + ); + + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ enterprise: { auditLogs: false, billing: false } }); + }); +}); + +const seedWebhookFixture = (tc: BackendTestConnections, webhookRunId: string) => + Effect.runPromise(seedSmokeData(webhookRunId).pipe(Effect.provide(Db.layer(tc.db)))); + +describe("Backend WorkOS webhook smoke", () => { + test("processes a correctly-signed user.created event", async () => { + const tc = requireTestConnections(); + const { body, header } = await signWorkosWebhook( + makeUserCreatedEvent(), + TEST_WORKOS_WEBHOOK_SECRET, + Date.now(), + ); + + const { status, text } = await Effect.runPromise( + requestBackend(tc, "/api/webhooks/workos", { + body, + headers: { "content-type": "application/json", "workos-signature": header }, + method: "POST", + }), + ); + + // On failure the body explains why (the route returns a 500 with detail). + expect(status, `webhook returned ${status}: ${text}`).toBe(200); + const json = JSON.parse(text) as { duplicate?: boolean; received?: boolean }; + expect(json.received).toBe(true); + expect(json.duplicate ?? false).toBe(false); + }, 120_000); + + test("processes a correctly-signed organization.updated event", async () => { + const tc = requireTestConnections(); + const webhookRunId = `org${crypto.randomUUID().replaceAll("-", "").slice(0, 7)}`; + const ids = makeSmokeIds(webhookRunId); + await seedWebhookFixture(tc, webhookRunId); + + const { body, header } = await signWorkosWebhook( + makeOrganizationUpdatedEvent(ids), + TEST_WORKOS_WEBHOOK_SECRET, + Date.now(), + ); + + const { status, text } = await Effect.runPromise( + requestBackend(tc, "/api/webhooks/workos", { + body, + headers: { "content-type": "application/json", "workos-signature": header }, + method: "POST", + }), + ); + + expect(status, `webhook returned ${status}: ${text}`).toBe(200); + }, 120_000); + + test("processes a correctly-signed organization_membership.created event", async () => { + const tc = requireTestConnections(); + const webhookRunId = `mem${crypto.randomUUID().replaceAll("-", "").slice(0, 7)}`; + const ids = makeSmokeIds(webhookRunId); + await seedWebhookFixture(tc, webhookRunId); + + const { body, header } = await signWorkosWebhook( + makeOrganizationMembershipCreatedEvent(ids), + TEST_WORKOS_WEBHOOK_SECRET, + Date.now(), + ); + + const { status, text } = await Effect.runPromise( + requestBackend(tc, "/api/webhooks/workos", { + body, + headers: { "content-type": "application/json", "workos-signature": header }, + method: "POST", + }), + ); + + expect(status, `webhook returned ${status}: ${text}`).toBe(200); + }, 120_000); + + test("rejects an invalid signature", async () => { + const tc = requireTestConnections(); + const { status } = await Effect.runPromise( + requestBackend(tc, "/api/webhooks/workos", { + body: JSON.stringify(makeUserCreatedEvent()), + headers: { + "content-type": "application/json", + "workos-signature": `t=${Date.now()}, v1=${"0".repeat(64)}`, + }, + method: "POST", + }), + ); + + expect(status).not.toBe(200); + }, 120_000); +}); diff --git a/apps/backend/src/rpcs/agent-session-rpcs.test.ts b/apps/backend/src/rpcs/agent-session-rpcs.test.ts new file mode 100644 index 000000000..14de85f05 --- /dev/null +++ b/apps/backend/src/rpcs/agent-session-rpcs.test.ts @@ -0,0 +1,111 @@ +import { + AgentAttachmentService, + AgentSessionIndexService, + PaywallEditSessionService, +} from "@voidhash/core/services"; +import { AgentSessionRpcsDef, AuthMiddleware, AuthSession } from "@voidhash/rpc"; +import { Effect, Layer } from "effect"; +import { RpcTest } from "effect/unstable/rpc"; +import { describe, expect, it } from "vite-plus/test"; + +import { AgentSessionRpcsLive } from "./agent-session-rpcs.ts"; + +const summary = { + id: "agent_1", + organizationId: "org_1", + projectId: "project_1", + surface: "designer", + paywallId: "paywall_1", + userId: "user_1", + title: "Improve onboarding", + createdAt: new Date(0), + updatedAt: new Date(1), +}; + +const reverted: Array<{ editSessionId: string; sessionId: string }> = []; +const handlers = Layer.mergeAll( + AgentSessionRpcsLive.pipe( + Layer.provide( + Layer.succeed(AgentSessionIndexService, { + list: () => Effect.succeed([summary]), + get: () => Effect.succeed(summary), + delete: () => Effect.void, + } as unknown as AgentSessionIndexService["Service"]), + ), + Layer.provide( + Layer.succeed(AgentAttachmentService, { + upload: ({ name, contentType }: { name: string; contentType: string }) => + Effect.succeed({ + url: "https://files.example.com/reference.png", + name, + contentType, + sizeBytes: 10, + }), + } as AgentAttachmentService["Service"]), + ), + Layer.provide( + Layer.succeed(PaywallEditSessionService, { + revertForAgentSession: (_projectId: string, editSessionId: string, sessionId: string) => + Effect.sync(() => { + reverted.push({ editSessionId, sessionId }); + return { version: 2, commandCount: 1, paywallSlug: "trial" }; + }), + } as unknown as PaywallEditSessionService["Service"]), + ), + ), + Layer.succeed( + AuthMiddleware, + AuthMiddleware.of((effect) => + Effect.provideService(effect, AuthSession, { method: "user" } as never), + ), + ), +); + +describe("AgentSessionRpcs", () => { + it("lists indexed durable sessions", async () => { + const sessions = await Effect.runPromise( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(AgentSessionRpcsDef); + return yield* client.ListAgentSessions({ + organizationId: "org_1", + projectId: "project_1", + surface: "designer", + }); + }).pipe(Effect.provide(handlers), Effect.scoped), + ); + expect(sessions).toEqual([summary]); + }); + + it("uploads a prompt attachment", async () => { + const attachment = await Effect.runPromise( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(AgentSessionRpcsDef); + return yield* client.UploadAgentAttachment({ + sessionId: "agent_1", + organizationId: "org_1", + name: "reference.png", + contentType: "image/png", + dataBase64: "data:image/png;base64,AA==", + }); + }).pipe(Effect.provide(handlers), Effect.scoped), + ); + expect(attachment).toMatchObject({ + name: "reference.png", + contentType: "image/png", + }); + }); + + it("reverts an edit session through its owning agent-session scope", async () => { + reverted.length = 0; + await Effect.runPromise( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(AgentSessionRpcsDef); + yield* client.RevertAgentEditSession({ + sessionId: "agent_1", + editSessionId: "change_1", + }); + }).pipe(Effect.provide(handlers), Effect.scoped), + ); + expect(reverted).toEqual([{ editSessionId: "change_1", sessionId: "agent_1" }]); + }); +}); diff --git a/apps/backend/src/rpcs/agent-session-rpcs.ts b/apps/backend/src/rpcs/agent-session-rpcs.ts new file mode 100644 index 000000000..9ad7afd0f --- /dev/null +++ b/apps/backend/src/rpcs/agent-session-rpcs.ts @@ -0,0 +1,96 @@ +import { + AgentAttachmentService, + AgentSessionIndexService, + PaywallEditSessionService, +} from "@voidhash/core/services"; +import { + AgentSessionRpcsDef, + RpcActionForbiddenError, + RpcAgentAttachmentValidationError, + RpcAgentSessionNotFoundError, + RpcAgentSessionServiceError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +type RevertRpcError = + | RpcActionForbiddenError + | RpcAgentSessionNotFoundError + | RpcAgentSessionServiceError; + +const mapRevertError = (error: unknown): Effect.Effect => { + const record = + typeof error === "object" && error !== null + ? (error as { + readonly _tag?: unknown; + readonly message?: unknown; + readonly sessionId?: unknown; + }) + : undefined; + if (record?._tag === "AgentSessionForbiddenError" || record?._tag === "ActionForbiddenError") { + return Effect.fail(new RpcActionForbiddenError({ message: String(record.message) })); + } + if (record?._tag === "AgentSessionNotFoundError") { + return Effect.fail(new RpcAgentSessionNotFoundError({ sessionId: String(record.sessionId) })); + } + return Effect.fail( + new RpcAgentSessionServiceError({ + message: record?.message === undefined ? "Could not revert changes." : String(record.message), + }), + ); +}; + +/** RPC handlers for durable session history and prompt attachments. */ +export const AgentSessionRpcsLive = AgentSessionRpcsDef.toLayer( + Effect.gen(function* AgentSessionRpcsLive() { + const sessions = yield* AgentSessionIndexService; + const attachments = yield* AgentAttachmentService; + const editSessions = yield* PaywallEditSessionService; + const mapSessionErrors = { + AgentSessionForbiddenError: (error: { readonly message: string }) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AgentSessionIndexServiceError: (error: { readonly message: string }) => + Effect.fail(new RpcAgentSessionServiceError({ message: error.message })), + } as const; + return { + ListAgentSessions: ({ organizationId, projectId, surface, paywallId }) => + sessions + .list({ organizationId, projectId, surface, paywallId }) + .pipe(Effect.catchTags(mapSessionErrors)), + GetAgentSession: ({ sessionId }) => + sessions.get({ sessionId }).pipe( + Effect.catchTags({ + ...mapSessionErrors, + AgentSessionNotFoundError: (error) => + Effect.fail(new RpcAgentSessionNotFoundError({ sessionId: error.sessionId })), + }), + ), + DeleteAgentSession: ({ sessionId }) => + sessions.delete({ sessionId }).pipe( + Effect.catchTags({ + ...mapSessionErrors, + AgentSessionNotFoundError: (error) => + Effect.fail(new RpcAgentSessionNotFoundError({ sessionId: error.sessionId })), + }), + ), + RevertAgentEditSession: ({ sessionId, editSessionId }) => + sessions.get({ sessionId }).pipe( + Effect.flatMap((session) => + editSessions.revertForAgentSession(session.projectId, editSessionId, session.id), + ), + Effect.catch(mapRevertError), + Effect.asVoid, + ), + UploadAgentAttachment: ({ sessionId, organizationId, name, contentType, dataBase64 }) => + attachments.upload({ sessionId, organizationId, name, contentType, dataBase64 }).pipe( + Effect.catchTags({ + AgentAttachmentForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AgentAttachmentValidationError: (error) => + Effect.fail(new RpcAgentAttachmentValidationError({ message: error.message })), + AgentAttachmentServiceError: (error) => + Effect.fail(new RpcAgentSessionServiceError({ message: error.message })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/analytics-rpcs.ts b/apps/backend/src/rpcs/analytics-rpcs.ts new file mode 100644 index 000000000..e254b7960 --- /dev/null +++ b/apps/backend/src/rpcs/analytics-rpcs.ts @@ -0,0 +1,151 @@ +import { AnalyticsService, CustomAnalyticsService } from "@voidhash/core/services"; +import { + AnalyticsRpcsDef, + RpcActionForbiddenError, + RpcAnalyticsServiceError, + RpcInvalidAnalyticsQueryError, + RpcInvalidTimeRangeError, + RpcUnknownInsightError, + RpcUnsupportedAnalyticsBreakdownError, + RpcUnsupportedAnalyticsFilterError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const AnalyticsRpcsLive = AnalyticsRpcsDef.toLayer( + Effect.gen(function* AnalyticsRpcsLive() { + const analyticsService = yield* AnalyticsService; + const customAnalyticsService = yield* CustomAnalyticsService; + + const commonErrors = { + ActionForbiddenError: (error: { readonly message: string }) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AnalyticsServiceError: (error: { readonly cause: string; readonly message: string }) => + Effect.fail( + new RpcAnalyticsServiceError({ + cause: error.cause, + message: error.message, + }), + ), + }; + + return { + ListRecentAnalyticsEvents: ({ projectId, limit }) => + analyticsService + .listRecentEvents({ + limit, + projectId, + }) + .pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AnalyticsServiceError: (error) => + Effect.fail( + new RpcAnalyticsServiceError({ + cause: error.cause, + message: error.message, + }), + ), + }), + ), + QueryAnalyticsInsights: ({ queries }) => + analyticsService + .queryAnalyticsInsights({ + queries: queries.map((query) => ({ + ...query, + breakdowns: query.breakdowns ? [...query.breakdowns] : undefined, + })), + }) + .pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AnalyticsServiceError: (error) => + Effect.fail( + new RpcAnalyticsServiceError({ + cause: error.cause, + message: error.message, + }), + ), + InvalidAnalyticsQueryError: (error) => + Effect.fail(new RpcInvalidAnalyticsQueryError({ message: error.message })), + InvalidTimeRangeError: (error) => + Effect.fail(new RpcInvalidTimeRangeError({ message: error.message })), + UnknownInsightError: (error) => + Effect.fail( + new RpcUnknownInsightError({ + insightId: error.insightId, + message: error.message, + }), + ), + UnsupportedAnalyticsBreakdownError: (error) => + Effect.fail( + new RpcUnsupportedAnalyticsBreakdownError({ + field: error.field, + message: error.message, + }), + ), + UnsupportedAnalyticsFilterError: (error) => + Effect.fail( + new RpcUnsupportedAnalyticsFilterError({ + field: error.field, + message: error.message, + }), + ), + }), + ), + QueryCustomAnalyticsInsight: (input) => + customAnalyticsService.queryInsight(input).pipe( + Effect.catchTags({ + ...commonErrors, + InvalidAnalyticsQueryError: (error) => + Effect.fail(new RpcInvalidAnalyticsQueryError({ message: error.message })), + InvalidTimeRangeError: (error) => + Effect.fail(new RpcInvalidTimeRangeError({ message: error.message })), + }), + ), + QueryCustomAnalyticsPersons: (input) => + customAnalyticsService.queryPersons(input).pipe( + Effect.catchTags({ + ...commonErrors, + InvalidAnalyticsQueryError: (error) => + Effect.fail(new RpcInvalidAnalyticsQueryError({ message: error.message })), + InvalidTimeRangeError: (error) => + Effect.fail(new RpcInvalidTimeRangeError({ message: error.message })), + }), + ), + ListAnalyticsInsights: (input) => + customAnalyticsService.listInsights(input).pipe(Effect.catchTags(commonErrors)), + CreateAnalyticsInsight: (input) => + customAnalyticsService.createInsight(input).pipe(Effect.catchTags(commonErrors)), + UpdateAnalyticsInsight: (input) => + customAnalyticsService.updateInsight(input).pipe(Effect.catchTags(commonErrors)), + DeleteAnalyticsInsight: (input) => + customAnalyticsService.deleteInsight(input).pipe(Effect.catchTags(commonErrors)), + ListAnalyticsCohorts: (input) => + customAnalyticsService.listCohorts(input).pipe(Effect.catchTags(commonErrors)), + CreateAnalyticsCohort: (input) => + customAnalyticsService.createCohort(input).pipe(Effect.catchTags(commonErrors)), + UpdateAnalyticsCohort: (input) => + customAnalyticsService.updateCohort(input).pipe(Effect.catchTags(commonErrors)), + DeleteAnalyticsCohort: (input) => + customAnalyticsService.deleteCohort(input).pipe(Effect.catchTags(commonErrors)), + ListAnalyticsDashboards: (input) => + customAnalyticsService.listDashboards(input).pipe(Effect.catchTags(commonErrors)), + CreateAnalyticsDashboard: (input) => + customAnalyticsService.createDashboard(input).pipe(Effect.catchTags(commonErrors)), + DuplicateAnalyticsDashboard: (input) => + customAnalyticsService.duplicateDashboard(input).pipe(Effect.catchTags(commonErrors)), + UpdateAnalyticsDashboard: (input) => + customAnalyticsService.updateDashboard(input).pipe(Effect.catchTags(commonErrors)), + DeleteAnalyticsDashboard: (input) => + customAnalyticsService.deleteDashboard(input).pipe(Effect.catchTags(commonErrors)), + PutAnalyticsDashboardItem: (input) => + customAnalyticsService.putDashboardItem(input).pipe(Effect.catchTags(commonErrors)), + ReorderAnalyticsDashboardItems: (input) => + customAnalyticsService.reorderDashboardItems(input).pipe(Effect.catchTags(commonErrors)), + RemoveAnalyticsDashboardItem: (input) => + customAnalyticsService.removeDashboardItem(input).pipe(Effect.catchTags(commonErrors)), + }; + }), +); diff --git a/apps/backend/src/rpcs/api-key-rpcs.ts b/apps/backend/src/rpcs/api-key-rpcs.ts new file mode 100644 index 000000000..2851e6669 --- /dev/null +++ b/apps/backend/src/rpcs/api-key-rpcs.ts @@ -0,0 +1,101 @@ +import { ApiKeyService } from "@voidhash/core/services"; +import { + ApiKeyRpcsDef, + RpcActionForbiddenError, + RpcApiKeyNotFoundError, + RpcApiKeyServiceError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const ApiKeyRpcsLive = ApiKeyRpcsDef.toLayer( + Effect.gen(function* ApiKeyRpcsLive() { + const apiKeyService = yield* ApiKeyService; + return { + CreateSecretKey: ({ projectId, name }) => + apiKeyService.createSecretKey({ name, projectId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ApiKeyServiceError: (error) => + Effect.fail(new RpcApiKeyServiceError({ cause: error.cause })), + }), + ), + DeleteApiKey: ({ apiKeyId }) => + apiKeyService.deleteSecretKey({ secretKeyId: apiKeyId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ApiKeyNotFoundError: (error) => + Effect.fail(new RpcApiKeyNotFoundError({ message: error.message })), + ApiKeyServiceError: (error) => + Effect.fail(new RpcApiKeyServiceError({ cause: error.cause })), + }), + ), + GetApiKeyById: ({ apiKeyId }) => + apiKeyService.getApiKeyById(apiKeyId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ApiKeyNotFoundError: (error) => + Effect.fail(new RpcApiKeyNotFoundError({ message: error.message })), + ApiKeyServiceError: (error) => + Effect.fail(new RpcApiKeyServiceError({ cause: error.cause })), + }), + ), + ListApiKeys: ({ projectId }) => + apiKeyService.getApiKeys(projectId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ApiKeyServiceError: (error) => + Effect.fail(new RpcApiKeyServiceError({ cause: error.cause })), + }), + ), + RotateSecretKey: ({ apiKeyId }) => + apiKeyService.rotateSecretKey({ secretKeyId: apiKeyId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ApiKeyNotFoundError: (error) => + Effect.fail(new RpcApiKeyNotFoundError({ message: error.message })), + ApiKeyServiceError: (error) => + Effect.fail(new RpcApiKeyServiceError({ cause: error.cause })), + }), + ), + CreateUserApiKey: ({ name, prefix }) => + apiKeyService.createUserApiKey({ name, prefix }).pipe( + Effect.catchTags({ + ApiKeyServiceError: (error) => + Effect.fail(new RpcApiKeyServiceError({ cause: error.cause })), + }), + ), + ListUserApiKeys: () => + apiKeyService.listUserApiKeys().pipe( + Effect.map((keys) => + keys.map((key) => ({ + createdAt: key.createdAt, + enabled: key.enabled, + end: key.end, + expiresAt: key.expiresAt, + id: key.id, + name: key.name, + prefix: key.prefix, + })), + ), + Effect.catchTags({ + ApiKeyServiceError: (error) => + Effect.fail(new RpcApiKeyServiceError({ cause: error.cause })), + }), + ), + RevokeUserApiKey: ({ userApiKeyId }) => + apiKeyService.revokeUserApiKey({ userApiKeyId }).pipe( + Effect.catchTags({ + ApiKeyNotFoundError: (error) => + Effect.fail(new RpcApiKeyNotFoundError({ message: error.message })), + ApiKeyServiceError: (error) => + Effect.fail(new RpcApiKeyServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/experiment-rpcs.ts b/apps/backend/src/rpcs/experiment-rpcs.ts new file mode 100644 index 000000000..9b46c2253 --- /dev/null +++ b/apps/backend/src/rpcs/experiment-rpcs.ts @@ -0,0 +1,203 @@ +import { AnalyticsService, ExperimentService } from "@voidhash/core/services"; +// Imported so the inferred `ExperimentRpcsLive` layer type (whose requirements +// include AnalyticsService's `ClickhouseWebClient`) is nameable in the emitted +// declarations — the client is re-exported as a namespace, which TS cannot +// otherwise reference portably (TS2883). +import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { + ExperimentRpcsDef, + RpcActionForbiddenError, + RpcExperimentNotFoundError, + RpcExperimentServiceError, + RpcExperimentValidationError, + RpcExperimentVariantNotFoundError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +/** Map the service's experiment-with-relations to the RPC wire shape. */ +const toRpcExperiment = (e: { + readonly archivedAt: Date | null; + readonly createdAt: Date | null; + readonly createdByUserId: string | null; + readonly description: string | null; + readonly endedAt: Date | null; + readonly featureFlagId: string; + readonly hypothesis: string | null; + readonly id: string; + readonly name: string; + readonly primaryMetricEventName: string | null; + readonly projectId: string; + readonly secondaryMetricEventNames: readonly string[] | null; + readonly startedAt: Date | null; + readonly status: number; + readonly updatedAt: Date | null; + readonly updatedByUserId: string | null; + readonly version: number; + readonly winningVariantId: string | null; + readonly variants: ReadonlyArray<{ + readonly archivedAt: Date | null; + readonly createdAt: Date | null; + readonly experimentId: string; + readonly id: string; + readonly isControl: boolean; + readonly name: string; + readonly updatedAt: Date | null; + readonly weightBps: number; + }>; + readonly treatments: ReadonlyArray<{ + readonly archivedAt: Date | null; + readonly config: unknown; + readonly createdAt: Date | null; + readonly experimentId: string; + readonly id: string; + readonly treatmentType: string; + readonly updatedAt: Date | null; + readonly variantId: string; + }>; + readonly featureFlag: { + readonly id: string; + readonly key: string; + readonly enabled: boolean; + readonly rolloutBps: number; + } | null; +}) => ({ + archivedAt: e.archivedAt, + backingFlag: e.featureFlag + ? { + enabled: e.featureFlag.enabled, + id: e.featureFlag.id, + key: e.featureFlag.key, + rolloutBps: e.featureFlag.rolloutBps, + } + : null, + createdAt: e.createdAt, + createdByUserId: e.createdByUserId, + description: e.description, + endedAt: e.endedAt, + featureFlagId: e.featureFlagId, + hypothesis: e.hypothesis, + id: e.id, + name: e.name, + primaryMetricEventName: e.primaryMetricEventName, + projectId: e.projectId, + secondaryMetricEventNames: e.secondaryMetricEventNames, + startedAt: e.startedAt, + status: e.status, + treatments: e.treatments, + updatedAt: e.updatedAt, + updatedByUserId: e.updatedByUserId, + variants: e.variants, + version: e.version, + winningVariantId: e.winningVariantId, +}); + +export const ExperimentRpcsLive = ExperimentRpcsDef.toLayer( + Effect.gen(function* ExperimentRpcsLive() { + const service = yield* ExperimentService; + const analyticsService = yield* AnalyticsService; + + // Shared error mappers keep every handler's catchTags terse. + const serviceError = (error: { readonly cause: string }) => + Effect.fail(new RpcExperimentServiceError({ cause: error.cause })); + const forbidden = (error: { readonly message: string }) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })); + const notFound = (error: { readonly experimentId: string }) => + Effect.fail( + new RpcExperimentNotFoundError({ message: `Experiment not found: ${error.experimentId}` }), + ); + const validation = (error: { readonly message: string }) => + Effect.fail(new RpcExperimentValidationError({ message: error.message })); + const variantNotFound = (error: { readonly variantId: string }) => + Effect.fail( + new RpcExperimentVariantNotFoundError({ message: `Variant not found: ${error.variantId}` }), + ); + + return { + ArchiveExperiment: (input) => + service.archiveExperiment(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentNotFoundError: notFound, + ExperimentServiceError: serviceError, + }), + ), + ConcludeExperiment: (input) => + service.concludeExperiment(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentNotFoundError: notFound, + ExperimentServiceError: serviceError, + ExperimentVariantNotFoundError: variantNotFound, + }), + ), + CreateExperiment: (input) => + service.createExperiment(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentServiceError: serviceError, + }), + ), + GetExperiment: (input) => + service.getExperiment(input).pipe( + Effect.map(toRpcExperiment), + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentNotFoundError: notFound, + ExperimentServiceError: serviceError, + }), + ), + GetExperimentResults: (input) => + analyticsService.getExperimentResults(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + AnalyticsServiceError: (error) => + Effect.fail(new RpcExperimentServiceError({ cause: error.cause })), + }), + ), + ListExperiments: (input) => + service.listExperiments(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentServiceError: serviceError, + }), + ), + PauseExperiment: (input) => + service.pauseExperiment(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentNotFoundError: notFound, + ExperimentServiceError: serviceError, + ExperimentValidationError: validation, + }), + ), + RestoreExperiment: (input) => + service.restoreExperiment(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentNotFoundError: notFound, + ExperimentServiceError: serviceError, + }), + ), + StartExperiment: (input) => + service.startExperiment(input).pipe( + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentNotFoundError: notFound, + ExperimentServiceError: serviceError, + ExperimentValidationError: validation, + }), + ), + SaveExperimentSetup: (input) => + service.saveSetup(input).pipe( + Effect.map(toRpcExperiment), + Effect.catchTags({ + ActionForbiddenError: forbidden, + ExperimentNotFoundError: notFound, + ExperimentServiceError: serviceError, + ExperimentValidationError: validation, + ExperimentVariantNotFoundError: variantNotFound, + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/feature-flag-rpcs.ts b/apps/backend/src/rpcs/feature-flag-rpcs.ts new file mode 100644 index 000000000..37f66c4e4 --- /dev/null +++ b/apps/backend/src/rpcs/feature-flag-rpcs.ts @@ -0,0 +1,228 @@ +import { FeatureFlagService } from "@voidhash/core/services"; +import type { + FeatureFlag, + FeatureFlagOverride, + FeatureFlagTarget, + FeatureFlagVariant, +} from "@voidhash/db"; +import { + FeatureFlagRpcsDef, + RpcActionForbiddenError, + RpcAuditLogServiceError, + RpcFeatureFlagKeyAlreadyExistsError, + RpcFeatureFlagNotFoundError, + RpcFeatureFlagOverrideNotFoundError, + RpcFeatureFlagServiceError, + RpcFeatureFlagTargetNotFoundError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +const toRpcFeatureFlagVariant = (variant: FeatureFlagVariant) => ({ + archivedAt: variant.archivedAt, + createdAt: variant.createdAt, + featureFlagId: variant.featureFlagId, + id: variant.id, + label: variant.name || null, + updatedAt: variant.updatedAt, + value: variant.payload, +}); + +const toRpcFeatureFlag = ( + flag: FeatureFlag & { + readonly overrides: ReadonlyArray; + readonly targets: ReadonlyArray; + readonly variants: ReadonlyArray; + }, +) => { + const { key, name: _name, variants, ...rest } = flag; + return { + ...rest, + slug: key, + variants: variants.map(toRpcFeatureFlagVariant), + }; +}; + +const toRpcFeatureFlagListItem = ( + flag: FeatureFlag & { readonly variantCount: number; readonly variants?: undefined }, +) => { + const { key, name: _name, variants: _variants, ...rest } = flag; + return { + ...rest, + slug: key, + }; +}; + +export const FeatureFlagRpcsLive = FeatureFlagRpcsDef.toLayer( + Effect.gen(function* FeatureFlagRpcsLive() { + const service = yield* FeatureFlagService; + return { + ArchiveFeatureFlag: (input) => + service.archiveFlag(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + ArchiveFeatureFlagOverride: (input) => + service.archiveOverride(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagOverrideNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagOverrideNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + ArchiveFeatureFlagTarget: (input) => + service.archiveTarget(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + FeatureFlagTargetNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagTargetNotFoundError({ message: error.message })), + }), + ), + CreateFeatureFlag: ({ slug, ...input }) => + service.createFlag({ ...input, key: slug }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagKeyAlreadyExistsError: (error) => + Effect.fail(new RpcFeatureFlagKeyAlreadyExistsError({ key: error.key })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + GetFeatureFlag: ({ id }) => + service.getFlagById({ id }).pipe( + Effect.map(toRpcFeatureFlag), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + FeatureFlagNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + ListFeatureFlagOverridesByPerson: (input) => + service.listOverridesByPerson(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + ListFeatureFlagOverridesByFlag: (input) => + service.listOverridesByFlag(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + FeatureFlagNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + ListFeatureFlags: (input) => + service.listFlags(input).pipe( + Effect.map((flags) => flags.map(toRpcFeatureFlagListItem)), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + RestoreFeatureFlag: (input) => + service.restoreFlag(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + UpdateFeatureFlag: ({ slug, ...input }) => + service.updateFlag({ ...input, key: slug }).pipe( + Effect.map(toRpcFeatureFlag), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagKeyAlreadyExistsError: (error) => + Effect.fail(new RpcFeatureFlagKeyAlreadyExistsError({ key: error.key })), + FeatureFlagNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + UpdateFeatureFlagVariants: (input) => + service + .updateCustomerFlagVariants({ + ...input, + variants: [...input.variants], + }) + .pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + UpsertFeatureFlagOverride: (input) => + service.upsertOverride(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + UpsertFeatureFlagTarget: (input) => + service.upsertTarget(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcAuditLogServiceError({ cause: error.cause })), + FeatureFlagNotFoundError: (error) => + Effect.fail(new RpcFeatureFlagNotFoundError({ message: error.message })), + FeatureFlagServiceError: (error) => + Effect.fail(new RpcFeatureFlagServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/feedback-rpcs.ts b/apps/backend/src/rpcs/feedback-rpcs.ts new file mode 100644 index 000000000..91416d5f5 --- /dev/null +++ b/apps/backend/src/rpcs/feedback-rpcs.ts @@ -0,0 +1,28 @@ +import { FeedbackService } from "@voidhash/core/services"; +import { FeedbackRpcsDef, RpcFeedbackServiceError } from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const FeedbackRpcsLive = FeedbackRpcsDef.toLayer( + Effect.gen(function* FeedbackRpcsLive() { + const feedbackService = yield* FeedbackService; + return { + SubmitFeedback: (payload) => + feedbackService + .submit({ + topic: payload.topic, + sentiment: payload.sentiment ?? null, + message: payload.message, + organizationId: payload.organizationId ?? null, + projectId: payload.projectId ?? null, + pathname: payload.pathname ?? null, + userAgent: payload.userAgent ?? null, + }) + .pipe( + Effect.catchTags({ + FeedbackServiceError: (error) => + Effect.fail(new RpcFeedbackServiceError({ message: error.message })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/organization-rpcs.ts b/apps/backend/src/rpcs/organization-rpcs.ts new file mode 100644 index 000000000..c5fd4ee16 --- /dev/null +++ b/apps/backend/src/rpcs/organization-rpcs.ts @@ -0,0 +1,68 @@ +import { OrganizationService } from "@voidhash/core/services"; +import { + OrganizationRpcsDef, + RpcActionForbiddenError, + RpcAvatarValidationError, + RpcOrganizationNotFoundError, + RpcOrganizationServiceError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const OrganizationRpcsLive = OrganizationRpcsDef.toLayer( + Effect.gen(function* OrganizationRpcsLive() { + const organizationService = yield* OrganizationService; + return { + CreateOrganization: ({ name }) => + organizationService.createOrganization({ name }).pipe( + Effect.catchTags({ + OrganizationServiceError: (error) => + Effect.fail(new RpcOrganizationServiceError({ cause: error.cause })), + }), + ), + DeleteOrganization: ({ organizationId }) => + organizationService.deleteOrganization({ organizationId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + OrganizationServiceError: (error) => + Effect.fail(new RpcOrganizationServiceError({ cause: error.cause })), + }), + ), + RemoveOrganizationAvatar: ({ organizationId }) => + organizationService.removeAvatar({ organizationId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + OrganizationNotFoundError: (error) => + Effect.fail(new RpcOrganizationNotFoundError({ message: error.message })), + OrganizationServiceError: (error) => + Effect.fail(new RpcOrganizationServiceError({ cause: error.cause })), + }), + ), + SetOrganizationAvatar: ({ contentType, imageBase64, organizationId }) => + organizationService.setAvatar({ contentType, imageBase64, organizationId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AvatarValidationError: (error) => + Effect.fail(new RpcAvatarValidationError({ message: error.message })), + OrganizationNotFoundError: (error) => + Effect.fail(new RpcOrganizationNotFoundError({ message: error.message })), + OrganizationServiceError: (error) => + Effect.fail(new RpcOrganizationServiceError({ cause: error.cause })), + }), + ), + UpdateOrganization: ({ name, organizationId }) => + organizationService.updateOrganization({ name, organizationId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + OrganizationNotFoundError: (error) => + Effect.fail(new RpcOrganizationNotFoundError({ message: error.message })), + OrganizationServiceError: (error) => + Effect.fail(new RpcOrganizationServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/payment-provider-configuration-rpcs.ts b/apps/backend/src/rpcs/payment-provider-configuration-rpcs.ts new file mode 100644 index 000000000..e6d88dd0d --- /dev/null +++ b/apps/backend/src/rpcs/payment-provider-configuration-rpcs.ts @@ -0,0 +1,101 @@ +import { PaymentProviderConfigurationService } from "@voidhash/core/services"; +import { + PaymentProviderConfigurationRpcsDef, + RpcActionForbiddenError, + RpcPaymentProviderAlreadyExistsError, + RpcPaymentProviderConfigurationKeyUnavailableError, + RpcPaymentProviderConfigurationNotFoundError, + RpcPaymentProviderConfigurationServiceError, + RpcPaymentProviderConfigurationValidationError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const PaymentProviderConfigurationRpcsLive = PaymentProviderConfigurationRpcsDef.toLayer( + Effect.gen(function* PaymentProviderConfigurationRpcsLive() { + const paymentProviderConfigurationService = yield* PaymentProviderConfigurationService; + const mapUpdateError = (error: unknown) => { + const tagged = error as { + readonly _tag?: string; + readonly cause?: unknown; + readonly message?: string; + }; + switch (tagged._tag) { + case "ActionForbiddenError": + return new RpcActionForbiddenError({ message: tagged.message ?? "" }); + case "PaymentProviderConfigurationKeyUnavailableError": + return new RpcPaymentProviderConfigurationKeyUnavailableError({ + message: tagged.message ?? "", + }); + case "PaymentProviderConfigurationNotFoundError": + return new RpcPaymentProviderConfigurationNotFoundError({ + message: tagged.message ?? "", + }); + case "PaymentProviderConfigurationValidationError": + return new RpcPaymentProviderConfigurationValidationError({ + cause: String(tagged.cause ?? error), + }); + default: + return new RpcPaymentProviderConfigurationServiceError({ + cause: String(tagged.cause ?? error), + }); + } + }; + return { + CreatePaymentProviderConfiguration: (input) => + paymentProviderConfigurationService.createPaymentProviderConfiguration(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaymentProviderAlreadyExistsError: (error) => + Effect.fail(new RpcPaymentProviderAlreadyExistsError({ message: error.message })), + PaymentProviderConfigurationServiceError: (error) => + Effect.fail(new RpcPaymentProviderConfigurationServiceError({ cause: error.cause })), + PaymentProviderConfigurationValidationError: (error) => + Effect.fail( + new RpcPaymentProviderConfigurationValidationError({ cause: error.cause }), + ), + }), + ), + DeletePaymentProviderConfiguration: (input) => + paymentProviderConfigurationService.deletePaymentProviderConfiguration(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaymentProviderConfigurationNotFoundError: (error) => + Effect.fail( + new RpcPaymentProviderConfigurationNotFoundError({ message: error.message }), + ), + PaymentProviderConfigurationServiceError: (error) => + Effect.fail(new RpcPaymentProviderConfigurationServiceError({ cause: error.cause })), + }), + ), + GetPaymentProviderConfiguration: ({ id }) => + paymentProviderConfigurationService.getPaymentProviderConfigurationById(id).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaymentProviderConfigurationNotFoundError: (error) => + Effect.fail( + new RpcPaymentProviderConfigurationNotFoundError({ message: error.message }), + ), + PaymentProviderConfigurationServiceError: (error) => + Effect.fail(new RpcPaymentProviderConfigurationServiceError({ cause: error.cause })), + }), + ), + ListPaymentProviderConfigurations: ({ projectId }) => + paymentProviderConfigurationService.getPaymentProviderConfigurations(projectId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaymentProviderConfigurationServiceError: (error) => + Effect.fail(new RpcPaymentProviderConfigurationServiceError({ cause: error.cause })), + }), + ), + UpdatePaymentProviderConfiguration: (input) => + paymentProviderConfigurationService.updatePaymentProviderConfiguration(input).pipe( + Effect.map((result) => ({ id: (result as { readonly id: string }).id }) as const), + Effect.mapError(mapUpdateError), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/payment-provider-product-rpcs.ts b/apps/backend/src/rpcs/payment-provider-product-rpcs.ts new file mode 100644 index 000000000..8f6f480d3 --- /dev/null +++ b/apps/backend/src/rpcs/payment-provider-product-rpcs.ts @@ -0,0 +1,95 @@ +import { PaymentProviderProductService } from "@voidhash/core/services"; +import { + PaymentProviderProductRpcsDef, + RpcActionForbiddenError, + RpcPaymentProviderProductNotFoundError, + RpcPaymentProviderProductServiceError, + RpcPaymentProviderProductValidationError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const PaymentProviderProductRpcsLive = PaymentProviderProductRpcsDef.toLayer( + Effect.gen(function* PaymentProviderProductRpcsLive() { + const paymentProviderProductService = yield* PaymentProviderProductService; + const mapCreateError = (error: unknown) => { + const tagged = error as { + readonly _tag?: string; + readonly cause?: unknown; + readonly message?: string; + }; + switch (tagged._tag) { + case "ActionForbiddenError": + return new RpcActionForbiddenError({ message: tagged.message ?? "" }); + case "PaymentProviderProductValidationError": + return new RpcPaymentProviderProductValidationError({ message: tagged.message ?? "" }); + default: + return new RpcPaymentProviderProductServiceError({ + cause: String(tagged.cause ?? error), + }); + } + }; + const mapUpdateError = (error: unknown) => { + const tagged = error as { + readonly _tag?: string; + readonly cause?: unknown; + readonly message?: string; + }; + switch (tagged._tag) { + case "ActionForbiddenError": + return new RpcActionForbiddenError({ message: tagged.message ?? "" }); + case "PaymentProviderProductNotFoundError": + return new RpcPaymentProviderProductNotFoundError({ message: tagged.message ?? "" }); + case "PaymentProviderProductValidationError": + return new RpcPaymentProviderProductValidationError({ message: tagged.message ?? "" }); + default: + return new RpcPaymentProviderProductServiceError({ + cause: String(tagged.cause ?? error), + }); + } + }; + return { + CreatePaymentProviderProduct: (input) => + paymentProviderProductService.createPaymentProviderProduct(input).pipe( + Effect.map((result) => ({ id: (result as { readonly id: string }).id }) as const), + Effect.mapError(mapCreateError), + ), + DeletePaymentProviderProduct: (input) => + paymentProviderProductService.deletePaymentProviderProduct(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaymentProviderProductServiceError: (error) => + Effect.fail(new RpcPaymentProviderProductServiceError({ cause: error.cause })), + PaymentProviderProductValidationError: (error) => + Effect.fail(new RpcPaymentProviderProductValidationError({ message: error.message })), + }), + ), + ListProviderProductsByProductId: ({ productId }) => + paymentProviderProductService.getProviderProductsByProductId(productId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaymentProviderProductServiceError: (error) => + Effect.fail(new RpcPaymentProviderProductServiceError({ cause: error.cause })), + PaymentProviderProductValidationError: (error) => + Effect.fail(new RpcPaymentProviderProductValidationError({ message: error.message })), + }), + ), + SetActivePaymentProviderProduct: (input) => + paymentProviderProductService.setActivePaymentProviderProduct(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaymentProviderProductServiceError: (error) => + Effect.fail(new RpcPaymentProviderProductServiceError({ cause: error.cause })), + PaymentProviderProductValidationError: (error) => + Effect.fail(new RpcPaymentProviderProductValidationError({ message: error.message })), + }), + ), + UpdatePaymentProviderProduct: (input) => + paymentProviderProductService + .updatePaymentProviderProduct(input) + .pipe(Effect.mapError(mapUpdateError), Effect.asVoid), + }; + }), +); diff --git a/apps/backend/src/rpcs/paywall-asset-rpcs.ts b/apps/backend/src/rpcs/paywall-asset-rpcs.ts new file mode 100644 index 000000000..7b3fee588 --- /dev/null +++ b/apps/backend/src/rpcs/paywall-asset-rpcs.ts @@ -0,0 +1,61 @@ +import { PaywallAssetService } from "@voidhash/core/services"; +import { + PaywallAssetRpcsDef, + RpcActionForbiddenError, + RpcPaywallAssetNotFoundError, + RpcPaywallAssetServiceError, + RpcPaywallAssetValidationError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const PaywallAssetRpcsLive = PaywallAssetRpcsDef.toLayer( + Effect.gen(function* PaywallAssetRpcsLive() { + const paywallAssetService = yield* PaywallAssetService; + return { + UploadPaywallAsset: ({ organizationId, name, contentType, imageBase64, width, height }) => + paywallAssetService + .upload({ organizationId, name, contentType, imageBase64, width, height }) + .pipe( + Effect.catchTags({ + PaywallAssetForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallAssetValidationError: (error) => + Effect.fail(new RpcPaywallAssetValidationError({ message: error.message })), + PaywallAssetServiceError: (error) => + Effect.fail(new RpcPaywallAssetServiceError({ message: error.message })), + }), + ), + ListPaywallAssets: ({ organizationId }) => + paywallAssetService.list({ organizationId }).pipe( + Effect.catchTags({ + PaywallAssetForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallAssetServiceError: (error) => + Effect.fail(new RpcPaywallAssetServiceError({ message: error.message })), + }), + ), + RenamePaywallAsset: ({ assetId, name }) => + paywallAssetService.rename({ assetId, name }).pipe( + Effect.catchTags({ + PaywallAssetForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallAssetNotFoundError: (error) => + Effect.fail(new RpcPaywallAssetNotFoundError({ assetId: error.assetId })), + PaywallAssetServiceError: (error) => + Effect.fail(new RpcPaywallAssetServiceError({ message: error.message })), + }), + ), + DeletePaywallAsset: ({ assetId }) => + paywallAssetService.delete({ assetId }).pipe( + Effect.catchTags({ + PaywallAssetForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallAssetNotFoundError: (error) => + Effect.fail(new RpcPaywallAssetNotFoundError({ assetId: error.assetId })), + PaywallAssetServiceError: (error) => + Effect.fail(new RpcPaywallAssetServiceError({ message: error.message })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/paywall-component-rpcs.ts b/apps/backend/src/rpcs/paywall-component-rpcs.ts new file mode 100644 index 000000000..285dec8db --- /dev/null +++ b/apps/backend/src/rpcs/paywall-component-rpcs.ts @@ -0,0 +1,34 @@ +import { PaywallDeployService } from "@voidhash/core/services"; +import { + PaywallComponentRpcsDef, + RpcActionForbiddenError, + RpcPaywallDeployServiceError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const PaywallComponentRpcsLive = PaywallComponentRpcsDef.toLayer( + Effect.gen(function* () { + const deployService = yield* PaywallDeployService; + + return { + GetPaywallComponentVersions: ({ projectId, refs }) => + deployService.getComponentVersions({ projectId, refs }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallDeployServiceError: (error) => + Effect.fail(new RpcPaywallDeployServiceError({ cause: error.cause })), + }), + ), + ListPaywallComponents: ({ projectId }) => + deployService.listComponents({ projectId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallDeployServiceError: (error) => + Effect.fail(new RpcPaywallDeployServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/paywall-deploy-rpcs.ts b/apps/backend/src/rpcs/paywall-deploy-rpcs.ts new file mode 100644 index 000000000..74688cd43 --- /dev/null +++ b/apps/backend/src/rpcs/paywall-deploy-rpcs.ts @@ -0,0 +1,48 @@ +import { PaywallDeployService } from "@voidhash/core/services"; +import { + PaywallDeployRpcsDef, + RpcActionForbiddenError, + RpcPaywallDeployServiceError, + RpcPaywallDeployValidationError, + RpcReleaseNotFoundError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const PaywallDeployRpcsLive = PaywallDeployRpcsDef.toLayer( + Effect.gen(function* () { + const deployService = yield* PaywallDeployService; + + return { + ListPaywallDeploys: ({ projectId }) => + deployService.listDeploys({ projectId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallDeployServiceError: (error) => + Effect.fail(new RpcPaywallDeployServiceError({ cause: error.cause })), + }), + ), + SetActivePaywallRelease: ({ releaseId }) => + deployService.setActivePaywallRelease({ releaseId }).pipe( + Effect.map((result) => ({ releaseId: result.id })), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcPaywallDeployServiceError({ cause: error.cause })), + PaywallDeployServiceError: (error) => + Effect.fail(new RpcPaywallDeployServiceError({ cause: error.cause })), + PaywallDeployValidationError: (error) => + Effect.fail( + new RpcPaywallDeployValidationError({ + message: error.message, + violations: error.violations, + }), + ), + PaywallReleaseNotFoundError: () => + Effect.fail(new RpcReleaseNotFoundError({ releaseId })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/paywall-location-rpcs.ts b/apps/backend/src/rpcs/paywall-location-rpcs.ts new file mode 100644 index 000000000..18cb58590 --- /dev/null +++ b/apps/backend/src/rpcs/paywall-location-rpcs.ts @@ -0,0 +1,99 @@ +import { PaywallLocationService } from "@voidhash/core/services"; +import { + PaywallLocationRpcsDef, + RpcActionForbiddenError, + RpcPaywallLocationNotFoundError, + RpcPaywallLocationServiceError, + RpcPaywallLocationShowingValidationError, + RpcPaywallLocationSlugAlreadyExistsError, + RpcPaywallNotFoundError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const PaywallLocationRpcsLive = PaywallLocationRpcsDef.toLayer( + Effect.gen(function* PaywallLocationRpcsLive() { + const service = yield* PaywallLocationService; + + return { + ArchivePaywallLocation: ({ locationId }) => + service.archiveLocation({ locationId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallLocationNotFoundError: (error) => + Effect.fail(new RpcPaywallLocationNotFoundError({ message: error.message })), + PaywallLocationServiceError: (error) => + Effect.fail(new RpcPaywallLocationServiceError({ cause: error.cause })), + }), + ), + AssignPaywallLocationShowing: (input) => + service.assignLocationShowing(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallLocationNotFoundError: (error) => + Effect.fail(new RpcPaywallLocationNotFoundError({ message: error.message })), + PaywallLocationServiceError: (error) => + Effect.fail(new RpcPaywallLocationServiceError({ cause: error.cause })), + PaywallLocationShowingValidationError: (error) => + Effect.fail(new RpcPaywallLocationShowingValidationError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + }), + ), + ClearPaywallLocationShowing: ({ locationId }) => + service.clearLocationShowing({ locationId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallLocationNotFoundError: (error) => + Effect.fail(new RpcPaywallLocationNotFoundError({ message: error.message })), + PaywallLocationServiceError: (error) => + Effect.fail(new RpcPaywallLocationServiceError({ cause: error.cause })), + }), + ), + CreatePaywallLocation: (input) => + service.createLocation(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallLocationServiceError: (error) => + Effect.fail(new RpcPaywallLocationServiceError({ cause: error.cause })), + PaywallLocationSlugAlreadyExistsError: (error) => + Effect.fail(new RpcPaywallLocationSlugAlreadyExistsError({ slug: error.slug })), + }), + ), + ListPaywallLocations: (input) => + service.listLocations(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallLocationServiceError: (error) => + Effect.fail(new RpcPaywallLocationServiceError({ cause: error.cause })), + }), + ), + ListPaywallLocationShowings: ({ locationId }) => + service.listLocationShowings({ locationId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallLocationNotFoundError: (error) => + Effect.fail(new RpcPaywallLocationNotFoundError({ message: error.message })), + PaywallLocationServiceError: (error) => + Effect.fail(new RpcPaywallLocationServiceError({ cause: error.cause })), + }), + ), + UpdatePaywallLocation: (input) => + service.updateLocation(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallLocationNotFoundError: (error) => + Effect.fail(new RpcPaywallLocationNotFoundError({ message: error.message })), + PaywallLocationServiceError: (error) => + Effect.fail(new RpcPaywallLocationServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/paywall-rpcs.ts b/apps/backend/src/rpcs/paywall-rpcs.ts new file mode 100644 index 000000000..8b5dc8e17 --- /dev/null +++ b/apps/backend/src/rpcs/paywall-rpcs.ts @@ -0,0 +1,161 @@ +import { MimicHost, PaywallReleaseService, PaywallService } from "@voidhash/core/services"; +import { + PaywallRpcsDef, + RpcActionForbiddenError, + RpcPaywallNotFoundError, + RpcPaywallReleaseError, + RpcPaywallServiceError, + RpcPaywallSlugAlreadyExistsError, + RpcReleaseNotFoundError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const PaywallRpcsLive = PaywallRpcsDef.toLayer( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const releaseService = yield* PaywallReleaseService; + const mimicHost = yield* MimicHost; + + return { + CreatePaywall: (input) => + paywallService.createPaywall(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + PaywallSlugAlreadyExistsError: (error) => + Effect.fail(new RpcPaywallSlugAlreadyExistsError({ slug: error.slug })), + AuditLogPortError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + }), + ), + CreatePaywallRelease: ({ paywallId }) => + releaseService.createRelease(paywallId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + PaywallReleaseError: (error) => + Effect.fail( + new RpcPaywallReleaseError({ cause: error.cause, message: error.message }), + ), + }), + ), + ArchivePaywall: (input) => + paywallService.archivePaywall(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + AuditLogPortError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + }), + ), + DeletePaywall: (input) => + paywallService.deletePaywall(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + AuditLogPortError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + }), + ), + GetPaywallDraftRelease: ({ paywallId }) => + releaseService.getDraftRelease(paywallId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + PaywallReleaseError: (error) => + Effect.fail( + new RpcPaywallReleaseError({ cause: error.cause, message: error.message }), + ), + }), + ), + ListPaywalls: ({ includeArchived, projectId }) => + paywallService.getPaywalls(projectId, includeArchived).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + }), + ), + PublishPaywallRelease: ({ releaseId }) => + releaseService.publishRelease(releaseId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallReleaseError({ message: error.message })), + PaywallReleaseError: (error) => + Effect.fail( + new RpcPaywallReleaseError({ cause: error.cause, message: error.message }), + ), + ReleaseNotFoundError: (error) => + Effect.fail(new RpcReleaseNotFoundError({ releaseId: error.releaseId })), + }), + ), + RenamePaywall: (input) => + paywallService.renamePaywall(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + AuditLogPortError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + }), + ), + RestorePaywall: (input) => + paywallService.restorePaywall(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + AuditLogPortError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + }), + ), + RequestPaywallEditToken: ({ paywallId }) => + paywallService.getPaywallById(paywallId).pipe( + Effect.flatMap(() => mimicHost.ensurePaywallDocument(paywallId)), + Effect.flatMap(() => mimicHost.createPaywallEditToken({ paywallId })), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + // MimicHostError details carry internals (binding names, host + // URLs, SDK errors) — log them server-side, return a generic + // cause to clients. + MimicHostError: (error) => + Effect.logError(`Paywall edit token mimic host error: ${error.message}`, error).pipe( + Effect.flatMap(() => + Effect.fail( + new RpcPaywallServiceError({ cause: "paywall editing backend unavailable" }), + ), + ), + ), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/paywall-workspace-rpcs.test.ts b/apps/backend/src/rpcs/paywall-workspace-rpcs.test.ts new file mode 100644 index 000000000..22abf27ed --- /dev/null +++ b/apps/backend/src/rpcs/paywall-workspace-rpcs.test.ts @@ -0,0 +1,204 @@ +/** + * Unit tests for the surviving document-first paywall workspace RPC handlers. + * They dispatch through `RpcTest.makeClient` against the REAL + * {@link PaywallWorkspaceRpcsLive} handler graph fed by the REAL + * {@link PaywallWorkspaceService} over mocked infrastructure (MimicHost / + * PaywallService / manifest cache) plus a pass-through AuthMiddleware — so the + * `ReadPaywallDocument` read seam (slug resolution + whole-document clean) is + * exercised end to end without an HTTP transport or a live document. + */ +import { ComponentManifestCacheService, PaywallWorkspaceService } from "@voidhash/core/services"; +import { MimicHost } from "@voidhash/core/services/paywalls/MimicHost"; +import { PaywallService } from "@voidhash/core/services/paywalls/PaywallService"; +import { ScreenNode, TextNode } from "@voidhash/mimic-schema"; +import { encodePaywallDocument } from "@voidhash/paywall-workspace"; +import { AuthMiddleware, AuthSession, PaywallWorkspaceRpcsDef } from "@voidhash/rpc"; +import { Effect, Exit, Layer } from "effect"; +import { RpcTest } from "effect/unstable/rpc"; +import { describe, expect, it } from "vite-plus/test"; + +import { PaywallWorkspaceRpcsLive } from "./paywall-workspace-rpcs.ts"; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const paywallRow = () => ({ + id: "pw_1", + slug: "trial", + projectId: "proj_1", + name: "Trial", + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + source: 1, + thumbnailUrl: null, + thumbnailSeq: null, + designFileMetadata: null, +}); + +/** A raw document tree with no components (a single screen). */ +const emptyDoc = () => + encodePaywallDocument([ + { type: "root", name: "Paywall", children: [{ type: "screen", name: "Main" }] }, + ]); + +interface Fakes { + /** The session the pass-through AuthMiddleware injects (default: a signed-in user). */ + readonly session?: unknown; + /** Decoded document root `getPaywallDocument` returns (the `ReadPaywallDocument` read path). */ + readonly root?: unknown; +} + +/** A cookie-authenticated user session. */ +const USER_SESSION = { method: "user", user: { id: "user_1" } }; + +/** The mocked infrastructure + REAL workspace service + RPC handlers. */ +const buildHandlerLayer = (fakes: Fakes) => { + const paywallLayer = Layer.succeed(PaywallService, { + getPaywalls: () => Effect.succeed([paywallRow()]), + getPaywallById: () => Effect.succeed(paywallRow()), + } as unknown as PaywallService["Service"]); + + const mimicLayer = Layer.succeed(MimicHost, { + ensurePaywallDocument: () => Effect.void, + getPaywallSnapshot: () => Effect.succeed(null), + getPaywallDocument: () => + Effect.sync(() => ({ tree: emptyDoc(), version: 1, root: fakes.root ?? null })), + submitPaywallTransaction: (_id: string, input: { baseVersion: number }) => + Effect.succeed({ accepted: true, version: input.baseVersion + 1 }), + createPaywallEditToken: () => Effect.succeed({ token: "", url: "", expiresAt: new Date() }), + } as unknown as MimicHost["Service"]); + + const cacheLayer = Layer.succeed(ComponentManifestCacheService, { + record: () => Effect.void, + getMany: () => Effect.succeed(new Map()), + } as unknown as ComponentManifestCacheService["Service"]); + + const infra = Layer.mergeAll(paywallLayer, mimicLayer, cacheLayer); + const workspaceLayer = PaywallWorkspaceService.layer.pipe(Layer.provide(infra)); + + // A pass-through AuthMiddleware that injects the request session. + const session = fakes.session ?? USER_SESSION; + const authMiddlewareLayer = Layer.succeed( + AuthMiddleware, + AuthMiddleware.of((effect) => Effect.provideService(effect, AuthSession, session as never)), + ); + + return Layer.mergeAll( + PaywallWorkspaceRpcsLive.pipe(Layer.provide(Layer.mergeAll(workspaceLayer, cacheLayer))), + authMiddlewareLayer, + ); +}; + +/** + * A local view of the RPC client. The generated `RpcClient` types resolve + * `Effect` from the rpc package's own effect instance, which trips a dual- + * instance clash; a hand-written interface keeps every effect on one instance. + */ +interface ReadDocumentResult { + slug: string; + name: string; + paywallId: string; + document: unknown; +} + +interface WorkspaceClient { + ReadPaywallDocument: (payload: { + projectId: string; + slug: string; + }) => Effect.Effect; +} + +const dispatch = (fakes: Fakes, f: (client: WorkspaceClient) => Effect.Effect) => + Effect.runPromiseExit( + Effect.gen(function* () { + const client = (yield* RpcTest.makeClient( + PaywallWorkspaceRpcsDef, + )) as unknown as WorkspaceClient; + return yield* f(client); + }).pipe(Effect.provide(buildHandlerLayer(fakes)), Effect.scoped), + ); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const decodeNodeData = (prim: any, input: unknown): Record => + prim.data.decode(prim.data.encode(input)) as Record; + +/** + * A decoded document root (`root` → `screen` → `text`) with the CRDT envelopes + * the engine's decode produces — the exact shape `getPaywallDocument.root` carries + * and `serializeDocument` cleans. One authored deviation (`paddingTop`) + literal + * text so the cleaned JSON is non-trivial to assert on. + */ +const decodedRoot = () => ({ + id: "root_1", + type: "root", + parentId: null, + pos: 0, + data: {}, + children: [ + { + id: "scr_1", + type: "screen", + parentId: "root_1", + pos: 0, + data: decodeNodeData(ScreenNode, { name: "Main", style: { paddingTop: 24 } }), + children: [ + { + id: "txt_1", + type: "text", + parentId: "scr_1", + pos: 0, + data: decodeNodeData(TextNode, { text: "Hello" }), + children: [], + }, + ], + }, + ], +}); + +describe("PaywallWorkspaceRpcs — ReadPaywallDocument (read_paywall)", () => { + it("returns the slug/name/id + the cleaned document JSON", async () => { + const exit = await dispatch({ root: decodedRoot() }, (client) => + client.ReadPaywallDocument({ projectId: "proj_1", slug: "trial" }), + ); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value.slug).toBe("trial"); + expect(exit.value.name).toBe("Trial"); + expect(exit.value.paywallId).toBe("pw_1"); + const doc = exit.value.document as { + id: string; + type: string; + children: ReadonlyArray<{ type: string; style?: unknown; children?: unknown[] }>; + }; + expect(doc.id).toBe("root_1"); + expect(doc.type).toBe("root"); + // Defaults are stripped: the screen shows only the authored paddingTop. + const screen = doc.children[0]!; + expect(screen.type).toBe("screen"); + expect(screen.style).toEqual({ paddingTop: 24 }); + const text = (screen.children as ReadonlyArray<{ type: string; text?: string }>)[0]!; + expect(text.type).toBe("text"); + expect(text.text).toBe("Hello"); + } + }); + + it("returns an empty document object when the live document has no root", async () => { + const exit = await dispatch({ root: null }, (client) => + client.ReadPaywallDocument({ projectId: "proj_1", slug: "trial" }), + ); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value.document).toEqual({}); + } + }); + + it("maps an unknown slug to Rpc/PaywallNotFoundError", async () => { + const exit = await dispatch({ root: decodedRoot() }, (client) => + client.ReadPaywallDocument({ projectId: "proj_1", slug: "missing" }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("Rpc/PaywallNotFoundError"); + } + }); +}); diff --git a/apps/backend/src/rpcs/paywall-workspace-rpcs.ts b/apps/backend/src/rpcs/paywall-workspace-rpcs.ts new file mode 100644 index 000000000..6bf401977 --- /dev/null +++ b/apps/backend/src/rpcs/paywall-workspace-rpcs.ts @@ -0,0 +1,87 @@ +import { + ComponentManifestCacheService, + PaywallWorkspaceService, +} from "@voidhash/core/services"; +import { + serializeDocument, + type SnapshotDocumentNode, +} from "@voidhash/ai-shared"; +import { + PaywallWorkspaceRpcsDef, + RpcActionForbiddenError, + RpcComponentManifestInvalidError, + RpcPaywallNotFoundError, + RpcPaywallWorkspaceError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +/** + * Read-only paywall workspace RPC handlers plus the content-addressed manifest + * upload. List a project's paywall directories, read a paywall's live document as + * cleaned JSON (the AI `read_paywall` tool), and accept the browser's manifest + * uploads. Component edits go through the document-first AI/MCP tools; there is + * no whole-fork apply. Service errors are mapped to the workspace RPC error + * union; the underlying mimic/DB details stay server-side. + */ +export const PaywallWorkspaceRpcsLive = PaywallWorkspaceRpcsDef.toLayer( + Effect.gen(function* () { + const workspace = yield* PaywallWorkspaceService; + const manifestCache = yield* ComponentManifestCacheService; + + return { + ListWorkspacePaywalls: ({ projectId }) => + workspace.listPaywalls(projectId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallWorkspaceError({ message: error.cause })), + }), + ), + /** + * Read another paywall's LIVE document as cleaned JSON for the AI + * `read_paywall` tool. `readDocument` resolves the slug within `projectId` + * (the slug MUST belong to the project) and reads the decoded document root, + * which {@link serializeDocument} cleans into the schema-loose tree the model + * reads (nested `{ id, type, name?, ...data, children }`, defaults stripped). + * Read-only. + */ + ReadPaywallDocument: ({ projectId, slug }) => + workspace.readDocument(projectId, slug).pipe( + Effect.map((resolved) => { + // The decoded renderer root is the single `SnapshotDocumentNode` the + // serializer cleans; `serializeDocument` unwraps CRDT envelopes and + // strips schema-default fields. A `null`/missing root (empty document) + // serializes to `{}` so the wire shape stays a document object. + const roots = + resolved.root != null ? [resolved.root as SnapshotDocumentNode] : []; + return { + slug: resolved.slug, + name: resolved.name, + paywallId: resolved.paywallId, + document: serializeDocument(roots) ?? {}, + }; + }), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PaywallNotFoundError: (error) => + Effect.fail(new RpcPaywallNotFoundError({ message: error.message })), + PaywallServiceError: (error) => + Effect.fail(new RpcPaywallWorkspaceError({ message: error.cause })), + PaywallWorkspaceServiceError: (error) => + Effect.fail(new RpcPaywallWorkspaceError({ message: error.message })), + }), + ), + RecordComponentManifest: (input) => + manifestCache.record(input).pipe( + Effect.catchTags({ + ComponentManifestInvalidError: (error) => + Effect.fail(new RpcComponentManifestInvalidError({ message: error.message })), + ComponentManifestCacheError: (error) => + Effect.fail(new RpcPaywallWorkspaceError({ message: error.message })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/perk-rpcs.ts b/apps/backend/src/rpcs/perk-rpcs.ts new file mode 100644 index 000000000..e5ae68766 --- /dev/null +++ b/apps/backend/src/rpcs/perk-rpcs.ts @@ -0,0 +1,48 @@ +import { PerkService } from "@voidhash/core/services"; +import { + PerkRpcsDef, + RpcActionForbiddenError, + RpcPerkNotFoundError, + RpcPerkServiceError, + RpcPerkSlugAlreadyExistsError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const PerkRpcsLive = PerkRpcsDef.toLayer( + Effect.gen(function* PerkRpcsLive() { + const perkService = yield* PerkService; + return { + CreatePerk: (input) => + perkService.createPerk(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PerkServiceError: (error) => + Effect.fail(new RpcPerkServiceError({ cause: error.cause })), + PerkSlugAlreadyExistsError: (error) => + Effect.fail(new RpcPerkSlugAlreadyExistsError({ slug: error.slug })), + }), + ), + DeletePerk: (input) => + perkService.deletePerk(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PerkNotFoundError: (error) => + Effect.fail(new RpcPerkNotFoundError({ message: error.message })), + PerkServiceError: (error) => + Effect.fail(new RpcPerkServiceError({ cause: error.cause })), + }), + ), + ListPerks: ({ projectId }) => + perkService.getPerks(projectId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PerkServiceError: (error) => + Effect.fail(new RpcPerkServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/person-rpcs.ts b/apps/backend/src/rpcs/person-rpcs.ts new file mode 100644 index 000000000..8d92a817a --- /dev/null +++ b/apps/backend/src/rpcs/person-rpcs.ts @@ -0,0 +1,87 @@ +import { PersonService } from "@voidhash/core/services"; +import { PersonOrigin } from "@voidhash/db"; +import { + Person, + PersonRpcsDef, + RpcActionForbiddenError, + RpcPersonNotFoundError, + RpcPersonServiceError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +const toRpcPerson = (person: { + createdAt: Date | null; + personId: string; + distinctId: string; + email: string | null; + kind: number; + name: string | null; +}) => + ({ + createdAt: person.createdAt, + personId: person.personId, + distinctId: person.distinctId, + email: person.email, + name: person.name, + type: person.kind, + }) satisfies typeof Person.Type; + +export const PersonRpcsLive = PersonRpcsDef.toLayer( + Effect.gen(function* PersonRpcsLive() { + const personService = yield* PersonService; + return { + CreatePerson: ({ distinctId, name, email, projectId }) => + personService + .createPerson({ + distinctId, + email: email ?? null, + name: name ?? null, + origin: PersonOrigin.API, + projectId, + }) + .pipe( + Effect.map(toRpcPerson), + Effect.catchTags({ + PersonServiceError: (error) => + Effect.fail(new RpcPersonServiceError({ cause: error.cause })), + }), + ), + GetPersonByDistinctId: ({ distinctId, projectId }) => + personService.getPersonByDistinctId(distinctId, projectId).pipe( + Effect.map(toRpcPerson), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PersonNotFoundError: (error) => + Effect.fail(new RpcPersonNotFoundError({ id: error.id })), + PersonServiceError: (error) => + Effect.fail(new RpcPersonServiceError({ cause: error.cause })), + }), + ), + GetPersonById: ({ personId }) => + personService.getPersonById(personId).pipe( + Effect.map(toRpcPerson), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PersonNotFoundError: (error) => + Effect.fail(new RpcPersonNotFoundError({ id: error.id })), + PersonServiceError: (error) => + Effect.fail(new RpcPersonServiceError({ cause: error.cause })), + }), + ), + ListPersons: ({ projectId }) => + personService.getPersons({ projectId }).pipe( + Effect.map((persons) => + persons.flatMap((person) => (person ? [toRpcPerson(person)] : [])), + ), + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PersonServiceError: (error) => + Effect.fail(new RpcPersonServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/product-perk-rpcs.ts b/apps/backend/src/rpcs/product-perk-rpcs.ts new file mode 100644 index 000000000..7dcd1ad09 --- /dev/null +++ b/apps/backend/src/rpcs/product-perk-rpcs.ts @@ -0,0 +1,50 @@ +import { ProductPerkService } from "@voidhash/core/services"; +import { + ProductPerkRpcsDef, + RpcActionForbiddenError, + RpcProductPerkServiceError, + RpcProductPerkValidationError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const ProductPerkRpcsLive = ProductPerkRpcsDef.toLayer( + Effect.gen(function* ProductPerkRpcsLive() { + const productPerkService = yield* ProductPerkService; + return { + CreateProductPerk: (input) => + productPerkService.createProductPerk(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProductPerkServiceError: (error) => + Effect.fail(new RpcProductPerkServiceError({ cause: error.cause })), + ProductPerkValidationError: (error) => + Effect.fail(new RpcProductPerkValidationError({ message: error.message })), + }), + Effect.asVoid, + ), + DeleteProductPerk: (input) => + productPerkService.deleteProductPerk(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProductPerkServiceError: (error) => + Effect.fail(new RpcProductPerkServiceError({ cause: error.cause })), + ProductPerkValidationError: (error) => + Effect.fail(new RpcProductPerkValidationError({ message: error.message })), + }), + ), + ListProductPerksByProductId: ({ productId }) => + productPerkService.getProductPerksByProductId(productId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProductPerkServiceError: (error) => + Effect.fail(new RpcProductPerkServiceError({ cause: error.cause })), + ProductPerkValidationError: (error) => + Effect.fail(new RpcProductPerkValidationError({ message: error.message })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/product-rpcs.ts b/apps/backend/src/rpcs/product-rpcs.ts new file mode 100644 index 000000000..c906b9ccb --- /dev/null +++ b/apps/backend/src/rpcs/product-rpcs.ts @@ -0,0 +1,70 @@ +import { ProductService } from "@voidhash/core/services"; +import { + ProductRpcsDef, + RpcActionForbiddenError, + RpcProductNotFoundError, + RpcProductServiceError, + RpcProductSlugAlreadyExistsError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const ProductRpcsLive = ProductRpcsDef.toLayer( + Effect.gen(function* ProductRpcsLive() { + const productService = yield* ProductService; + return { + CreateProduct: (input) => + productService.createProduct(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProductServiceError: (error) => + Effect.fail(new RpcProductServiceError({ cause: error.cause })), + ProductSlugAlreadyExistsError: (error) => + Effect.fail(new RpcProductSlugAlreadyExistsError({ slug: error.slug })), + }), + ), + DeleteProduct: (input) => + productService.deleteProduct(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProductNotFoundError: (error) => + Effect.fail(new RpcProductNotFoundError({ message: error.message })), + ProductServiceError: (error) => + Effect.fail(new RpcProductServiceError({ cause: error.cause })), + }), + ), + GetProduct: ({ id }) => + productService.getProductById(id).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProductNotFoundError: (error) => + Effect.fail(new RpcProductNotFoundError({ message: error.message })), + ProductServiceError: (error) => + Effect.fail(new RpcProductServiceError({ cause: error.cause })), + }), + ), + ListProducts: ({ projectId }) => + productService.getProducts(projectId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProductServiceError: (error) => + Effect.fail(new RpcProductServiceError({ cause: error.cause })), + }), + ), + UpdateProduct: (input) => + productService.updateProduct(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProductNotFoundError: (error) => + Effect.fail(new RpcProductNotFoundError({ message: error.message })), + ProductServiceError: (error) => + Effect.fail(new RpcProductServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/project-rpcs.ts b/apps/backend/src/rpcs/project-rpcs.ts new file mode 100644 index 000000000..41f45377d --- /dev/null +++ b/apps/backend/src/rpcs/project-rpcs.ts @@ -0,0 +1,94 @@ +import { ProjectService } from "@voidhash/core/services"; +import { + ProjectRpcsDef, + RpcActionForbiddenError, + RpcAuthenticationError, + RpcAvatarValidationError, + RpcProjectNotFoundError, + RpcProjectServiceError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const ProjectRpcsLive = ProjectRpcsDef.toLayer( + Effect.gen(function* ProjectRpcsLive() { + const projectService = yield* ProjectService; + return { + CreateProject: ({ name, organizationId }) => + projectService.createProject({ name, organizationId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuthenticationError: (error) => + Effect.fail( + new RpcAuthenticationError({ cause: error.cause, message: error.message }), + ), + ProjectServiceError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + }), + ), + DeleteProject: ({ id }) => + projectService.deleteProject({ id }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProjectNotFoundError: (error) => + Effect.fail(new RpcProjectNotFoundError({ projectId: error.projectId })), + ProjectServiceError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + AuditLogPortError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + }), + ), + ListProjects: ({ organizationId }) => + projectService.getProjects(organizationId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProjectServiceError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + }), + ), + RemoveProjectAvatar: ({ id }) => + projectService.removeAvatar({ id }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + ProjectNotFoundError: (error) => + Effect.fail(new RpcProjectNotFoundError({ projectId: error.projectId })), + ProjectServiceError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + }), + ), + SetProjectAvatar: ({ contentType, id, imageBase64 }) => + projectService.setAvatar({ contentType, id, imageBase64 }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + AuditLogPortError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + AvatarValidationError: (error) => + Effect.fail(new RpcAvatarValidationError({ message: error.message })), + ProjectNotFoundError: (error) => + Effect.fail(new RpcProjectNotFoundError({ projectId: error.projectId })), + ProjectServiceError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + }), + ), + UpdateProject: ({ id, name }) => + projectService.updateProject({ id, name }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + ProjectNotFoundError: (error) => + Effect.fail(new RpcProjectNotFoundError({ projectId: error.projectId })), + ProjectServiceError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + AuditLogPortError: (error) => + Effect.fail(new RpcProjectServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/push-notification-configuration-rpcs.ts b/apps/backend/src/rpcs/push-notification-configuration-rpcs.ts new file mode 100644 index 000000000..f26883999 --- /dev/null +++ b/apps/backend/src/rpcs/push-notification-configuration-rpcs.ts @@ -0,0 +1,113 @@ +import { NotificationsConfigurationService } from "@voidhash/core/services"; +import { + PushNotificationConfigurationRpcsDef, + RpcActionForbiddenError, + RpcPushNotificationConfigurationKeyUnavailableError, + RpcPushNotificationConfigurationNotFoundError, + RpcPushNotificationConfigurationServiceError, + RpcPushNotificationConfigurationValidationError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +/** + * Studio RPC surface for per-(project, provider) push credentials — the + * deferred config-CRUD slice from Phase 1, now wired. Delegates to + * {@link NotificationsConfigurationService} and translates its domain errors + * (plus the `ActionForbiddenError` from the project-permission check) into the + * `Rpc/Push…` wire errors. Handlers return the service's **secret-omitting** + * read DTO directly — the DTO is a plain `Schema.Struct`, so no `new Class(...)` + * encode is needed, and no secret ever reaches the browser. + */ +export const PushNotificationConfigurationRpcsLive = PushNotificationConfigurationRpcsDef.toLayer( + Effect.gen(function* PushNotificationConfigurationRpcsLive() { + const service = yield* NotificationsConfigurationService; + const mapUpdateError = (error: unknown) => { + const tagged = error as { + readonly _tag?: string; + readonly cause?: unknown; + readonly message?: string; + }; + switch (tagged._tag) { + case "ActionForbiddenError": + return new RpcActionForbiddenError({ message: tagged.message ?? "" }); + case "NotificationConfigNotFoundError": + return new RpcPushNotificationConfigurationNotFoundError({ + message: tagged.message ?? "", + }); + case "NotificationConfigKeyUnavailableError": + return new RpcPushNotificationConfigurationKeyUnavailableError({ + message: tagged.message ?? "", + }); + case "NotificationConfigValidationError": + return new RpcPushNotificationConfigurationValidationError({ + cause: String(tagged.cause ?? error), + }); + default: + return new RpcPushNotificationConfigurationServiceError({ + cause: String(tagged.cause ?? error), + }); + } + }; + return { + ListPushNotificationConfigurations: ({ projectId }) => + service.getPushNotificationConfigurations(projectId).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + NotificationsConfigurationServiceError: (error) => + Effect.fail(new RpcPushNotificationConfigurationServiceError({ cause: error.cause })), + }), + ), + GetPushNotificationConfiguration: ({ id }) => + service.getPushNotificationConfigurationById(id).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + NotificationConfigNotFoundError: (error) => + Effect.fail( + new RpcPushNotificationConfigurationNotFoundError({ message: error.message }), + ), + NotificationsConfigurationServiceError: (error) => + Effect.fail(new RpcPushNotificationConfigurationServiceError({ cause: error.cause })), + }), + ), + CreatePushNotificationConfiguration: (input) => + service.createPushNotificationConfiguration(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + NotificationConfigNotFoundError: (error) => + Effect.fail( + new RpcPushNotificationConfigurationNotFoundError({ message: error.message }), + ), + NotificationConfigKeyUnavailableError: (error) => + Effect.fail( + new RpcPushNotificationConfigurationKeyUnavailableError({ + message: error.message, + }), + ), + NotificationsConfigurationServiceError: (error) => + Effect.fail(new RpcPushNotificationConfigurationServiceError({ cause: error.cause })), + }), + ), + UpdatePushNotificationConfiguration: (input) => + service.updatePushNotificationConfiguration(input).pipe( + Effect.map((result) => ({ id: (result as { readonly id: string }).id }) as const), + Effect.mapError(mapUpdateError), + ), + DeletePushNotificationConfiguration: (input) => + service.deletePushNotificationConfiguration(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + NotificationConfigNotFoundError: (error) => + Effect.fail( + new RpcPushNotificationConfigurationNotFoundError({ message: error.message }), + ), + NotificationsConfigurationServiceError: (error) => + Effect.fail(new RpcPushNotificationConfigurationServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/push-notification-send-rpcs.ts b/apps/backend/src/rpcs/push-notification-send-rpcs.ts new file mode 100644 index 000000000..4bc5f64c8 --- /dev/null +++ b/apps/backend/src/rpcs/push-notification-send-rpcs.ts @@ -0,0 +1,44 @@ +import { PushNotificationSendService } from "@voidhash/core/services"; +import { + PushNotificationSendRpcsDef, + RpcActionForbiddenError, + RpcPushNotificationSendNotFoundError, + RpcPushNotificationSendServiceError, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +/** + * Read-only studio RPC surface for push-notification send history — the + * "sent notifications" activity page. Delegates to + * {@link PushNotificationSendService} and translates its domain errors (plus the + * `ActionForbiddenError` from the project-permission check) into the `Rpc/Push…` + * wire errors. Handlers return the service's plain read DTOs directly (each is a + * `Schema.Struct`, so no `new Class(...)` encode is needed). + */ +export const PushNotificationSendRpcsLive = PushNotificationSendRpcsDef.toLayer( + Effect.gen(function* PushNotificationSendRpcsLive() { + const service = yield* PushNotificationSendService; + return { + ListPushNotificationSends: ({ limit, projectId }) => + service.listSends({ limit, projectId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PushNotificationSendServiceError: (error) => + Effect.fail(new RpcPushNotificationSendServiceError({ cause: error.cause })), + }), + ), + GetPushNotificationSendDeliveries: ({ projectId, sendId }) => + service.getSendDeliveries({ projectId, sendId }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + PushNotificationSendNotFoundError: (error) => + Effect.fail(new RpcPushNotificationSendNotFoundError({ message: error.message })), + PushNotificationSendServiceError: (error) => + Effect.fail(new RpcPushNotificationSendServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/user-rpcs.ts b/apps/backend/src/rpcs/user-rpcs.ts new file mode 100644 index 000000000..d33c61819 --- /dev/null +++ b/apps/backend/src/rpcs/user-rpcs.ts @@ -0,0 +1,49 @@ +import { UserService } from "@voidhash/core/services"; +import { + RpcAuthenticationError, + RpcAvatarValidationError, + RpcUserServiceError, + UserRpcsDef, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const UserRpcsLive = UserRpcsDef.toLayer( + Effect.gen(function* UserRpcsLive() { + const userService = yield* UserService; + return { + CurrentUser: () => + userService.getUser().pipe( + Effect.catchTags({ + AuthenticationError: (error) => + Effect.fail( + new RpcAuthenticationError({ cause: error.cause, message: error.message }), + ), + }), + ), + RemoveUserAvatar: () => + userService.removeAvatar().pipe( + Effect.catchTags({ + AuthenticationError: (error) => + Effect.fail( + new RpcAuthenticationError({ cause: error.cause, message: error.message }), + ), + UserServiceError: (error) => + Effect.fail(new RpcUserServiceError({ cause: error.cause })), + }), + ), + SetUserAvatar: ({ contentType, imageBase64 }) => + userService.setAvatar({ contentType, imageBase64 }).pipe( + Effect.catchTags({ + AuthenticationError: (error) => + Effect.fail( + new RpcAuthenticationError({ cause: error.cause, message: error.message }), + ), + AvatarValidationError: (error) => + Effect.fail(new RpcAvatarValidationError({ message: error.message })), + UserServiceError: (error) => + Effect.fail(new RpcUserServiceError({ cause: error.cause })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/voidql-rpcs.ts b/apps/backend/src/rpcs/voidql-rpcs.ts new file mode 100644 index 000000000..95f9fd6d8 --- /dev/null +++ b/apps/backend/src/rpcs/voidql-rpcs.ts @@ -0,0 +1,209 @@ +/** + * Backend handlers for the VoidQL RPC group. Each handler calls + * {@link VoidQlService} and translates the core `VoidQl*` domain errors into + * their `Rpc/`-prefixed counterparts. The internal `VoidQlIsolationError` (a + * compiler defect) is mapped to the opaque {@link RpcVoidQlExecutionError} so it + * never leaks a reason to the client. + * + * NOTE: VoidQL must execute under the locked-down `analytics_query` ClickHouse + * user; {@link VoidQlService} reads the ambient `ClickhouseWebClient`, so the + * worker provides this layer the `analyticsQuery` client (see BackendWorker). + */ +import { VoidQlService } from "@voidhash/core/services"; +// Imported so the inferred layer type (whose requirements include the +// ClickhouseWebClient VoidQlService binds) is nameable in the emitted +// declarations — the client is re-exported as a namespace (TS2883). +import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { + AuthSession, + RpcActionForbiddenError, + RpcVoidQlComplexityError, + RpcVoidQlExecutionError, + RpcVoidQlPiiError, + RpcVoidQlSchemaError, + RpcVoidQlSyntaxError, + RpcVoidQlUnknownFieldError, + RpcVoidQlUnsupportedError, + VoidQlRpcsDef, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const VoidQlRpcsLive = VoidQlRpcsDef.toLayer( + Effect.gen(function* VoidQlRpcsLive() { + const voidql = yield* VoidQlService; + + return { + RunVoidQlQuery: ({ organizationId, text }) => + Effect.gen(function* () { + const session = yield* AuthSession; + return yield* voidql.runQuery({ + organizationId, + text, + principal: { kind: "user", id: session?.user?.id ?? "api-key" }, + }); + }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + VoidQlSyntaxError: (error) => + Effect.fail(new RpcVoidQlSyntaxError({ message: error.message, hint: error.hint })), + VoidQlUnsupportedError: (error) => + Effect.fail( + new RpcVoidQlUnsupportedError({ message: error.message, hint: error.hint }), + ), + VoidQlSchemaError: (error) => + Effect.fail(new RpcVoidQlSchemaError({ message: error.message })), + VoidQlUnknownFieldError: (error) => + Effect.fail( + new RpcVoidQlUnknownFieldError({ + field: error.field, + message: error.message, + suggestion: error.suggestion, + }), + ), + VoidQlPiiError: (error) => + Effect.fail(new RpcVoidQlPiiError({ message: error.message })), + VoidQlComplexityError: (error) => + Effect.fail(new RpcVoidQlComplexityError({ message: error.message })), + VoidQlIsolationError: () => + Effect.fail( + new RpcVoidQlExecutionError({ + cause: "internal", + message: "The query could not be executed.", + }), + ), + VoidQlExecutionError: (error) => + Effect.fail( + new RpcVoidQlExecutionError({ cause: error.cause, message: error.message }), + ), + }), + ), + + ValidateVoidQlQuery: ({ organizationId, text }) => + Effect.gen(function* () { + const session = yield* AuthSession; + return yield* voidql.validateQuery({ + organizationId, + text, + principal: { kind: "user", id: session?.user?.id ?? "api-key" }, + }); + }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + VoidQlExecutionError: (error) => + Effect.fail( + new RpcVoidQlExecutionError({ cause: error.cause, message: error.message }), + ), + }), + ), + + GetVoidQlSchema: () => voidql.getSchema(), + + SaveVoidQlInsight: ({ organizationId, name, text }) => + voidql.saveInsight({ organizationId, name, text }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + VoidQlSyntaxError: (error) => + Effect.fail(new RpcVoidQlSyntaxError({ message: error.message, hint: error.hint })), + VoidQlUnsupportedError: (error) => + Effect.fail( + new RpcVoidQlUnsupportedError({ message: error.message, hint: error.hint }), + ), + VoidQlSchemaError: (error) => + Effect.fail(new RpcVoidQlSchemaError({ message: error.message })), + VoidQlUnknownFieldError: (error) => + Effect.fail( + new RpcVoidQlUnknownFieldError({ + field: error.field, + message: error.message, + suggestion: error.suggestion, + }), + ), + VoidQlPiiError: (error) => + Effect.fail(new RpcVoidQlPiiError({ message: error.message })), + VoidQlComplexityError: (error) => + Effect.fail(new RpcVoidQlComplexityError({ message: error.message })), + VoidQlIsolationError: () => + Effect.fail( + new RpcVoidQlExecutionError({ + cause: "internal", + message: "The insight could not be saved.", + }), + ), + VoidQlExecutionError: (error) => + Effect.fail( + new RpcVoidQlExecutionError({ cause: error.cause, message: error.message }), + ), + }), + ), + ListVoidQlInsights: (input) => + voidql.listInsights(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + VoidQlExecutionError: (error) => + Effect.fail( + new RpcVoidQlExecutionError({ cause: error.cause, message: error.message }), + ), + }), + ), + RunSavedVoidQlInsight: ({ id }) => + Effect.gen(function* () { + const session = yield* AuthSession; + return yield* voidql.runSavedInsight({ + id, + principal: { kind: "user", id: session?.user?.id ?? "api-key" }, + }); + }).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + VoidQlSyntaxError: (error) => + Effect.fail(new RpcVoidQlSyntaxError({ message: error.message, hint: error.hint })), + VoidQlUnsupportedError: (error) => + Effect.fail( + new RpcVoidQlUnsupportedError({ message: error.message, hint: error.hint }), + ), + VoidQlSchemaError: (error) => + Effect.fail(new RpcVoidQlSchemaError({ message: error.message })), + VoidQlUnknownFieldError: (error) => + Effect.fail( + new RpcVoidQlUnknownFieldError({ + field: error.field, + message: error.message, + suggestion: error.suggestion, + }), + ), + VoidQlPiiError: (error) => + Effect.fail(new RpcVoidQlPiiError({ message: error.message })), + VoidQlComplexityError: (error) => + Effect.fail(new RpcVoidQlComplexityError({ message: error.message })), + VoidQlIsolationError: () => + Effect.fail( + new RpcVoidQlExecutionError({ + cause: "internal", + message: "The query could not be executed.", + }), + ), + VoidQlExecutionError: (error) => + Effect.fail( + new RpcVoidQlExecutionError({ cause: error.cause, message: error.message }), + ), + }), + ), + DeleteVoidQlInsight: (input) => + voidql.deleteInsight(input).pipe( + Effect.catchTags({ + ActionForbiddenError: (error) => + Effect.fail(new RpcActionForbiddenError({ message: error.message })), + VoidQlExecutionError: (error) => + Effect.fail( + new RpcVoidQlExecutionError({ cause: error.cause, message: error.message }), + ), + }), + ), + }; + }), +); diff --git a/apps/backend/src/rpcs/webhook-rpcs.ts b/apps/backend/src/rpcs/webhook-rpcs.ts new file mode 100644 index 000000000..db2041a86 --- /dev/null +++ b/apps/backend/src/rpcs/webhook-rpcs.ts @@ -0,0 +1,160 @@ +import { WebhookManagerService } from "@voidhash/core/services"; +import { + RpcWebhookDeliveryNotFoundError, + RpcWebhookEndpointNotFoundError, + RpcWebhookServiceError, + RpcWebhookValidationError, + WebhookRpcsDef, +} from "@voidhash/rpc"; +import { Effect } from "effect"; + +export const WebhookRpcsLive = WebhookRpcsDef.toLayer( + Effect.gen(function* WebhookRpcsLive() { + const webhookManagerService = yield* WebhookManagerService; + return { + CreateWebhookEndpoint: ({ projectId, name, url, events, description }) => + webhookManagerService + .createEndpoint({ + description, + events, + name, + projectId, + url, + }) + .pipe( + Effect.catchTags({ + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + WebhookValidationError: (error) => + Effect.fail(new RpcWebhookValidationError({ message: error.message })), + }), + ), + DeleteWebhookEndpoint: ({ endpointId }) => + webhookManagerService + .deleteEndpoint({ + endpointId, + projectId: "", // projectId will be extracted from the auth context in the service + }) + .pipe( + Effect.catchTags({ + WebhookEndpointNotFoundError: (error) => + Effect.fail(new RpcWebhookEndpointNotFoundError({ endpointId: error.endpointId })), + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + }), + ), + GetWebhookDelivery: ({ deliveryId }) => + webhookManagerService + .getDeliveryById({ + deliveryId, + projectId: "", // projectId will be extracted from auth + }) + .pipe( + Effect.catchTags({ + WebhookDeliveryNotFoundError: (error) => + Effect.fail(new RpcWebhookDeliveryNotFoundError({ deliveryId: error.deliveryId })), + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + }), + ), + GetWebhookEndpoint: ({ endpointId }) => + webhookManagerService + .getEndpointById({ + endpointId, + projectId: "", // projectId will be extracted from auth + }) + .pipe( + Effect.catchTags({ + WebhookEndpointNotFoundError: (error) => + Effect.fail(new RpcWebhookEndpointNotFoundError({ endpointId: error.endpointId })), + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + }), + ), + ListWebhookDeliveries: ({ projectId, endpointId }) => + webhookManagerService + .getDeliveries({ + endpointId, + projectId, + }) + .pipe( + Effect.catchTags({ + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + }), + ), + ListWebhookEndpoints: ({ projectId }) => + webhookManagerService.getEndpoints({ projectId }).pipe( + Effect.catchTags({ + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + }), + ), + RetryWebhookDelivery: ({ deliveryId }) => + webhookManagerService + .retryDelivery({ + deliveryId, + projectId: "", // projectId will be extracted from auth + }) + .pipe( + Effect.catchTags({ + WebhookDeliveryNotFoundError: (error) => + Effect.fail(new RpcWebhookDeliveryNotFoundError({ deliveryId: error.deliveryId })), + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + WebhookValidationError: (error) => + Effect.fail(new RpcWebhookValidationError({ message: error.message })), + }), + ), + RotateWebhookSecret: ({ endpointId }) => + webhookManagerService + .rotateSecret({ + endpointId, + projectId: "", // projectId will be extracted from auth + }) + .pipe( + Effect.catchTags({ + WebhookEndpointNotFoundError: (error) => + Effect.fail(new RpcWebhookEndpointNotFoundError({ endpointId: error.endpointId })), + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + }), + ), + TestWebhookEndpoint: ({ endpointId }) => + webhookManagerService + .testEndpoint({ + endpointId, + projectId: "", // projectId will be extracted from auth + }) + .pipe( + Effect.catchTags({ + WebhookEndpointNotFoundError: (error) => + Effect.fail(new RpcWebhookEndpointNotFoundError({ endpointId: error.endpointId })), + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + }), + ), + UpdateWebhookEndpoint: ({ endpointId, name, url, events, status, description }) => + webhookManagerService + .updateEndpoint({ + description, + endpointId, + events, + name, + projectId: "", // projectId will be extracted from auth + status, + url, + }) + .pipe( + Effect.catchTags({ + WebhookEndpointNotFoundError: (error) => + Effect.fail(new RpcWebhookEndpointNotFoundError({ endpointId: error.endpointId })), + WebhookServiceError: (error) => + Effect.fail(new RpcWebhookServiceError({ cause: error.cause })), + WebhookValidationError: (error) => + Effect.fail(new RpcWebhookValidationError({ message: error.message })), + }), + ), + }; + }), +); diff --git a/apps/backend/src/security/authorization-matrix.test.ts b/apps/backend/src/security/authorization-matrix.test.ts new file mode 100644 index 000000000..5e15a0a0a --- /dev/null +++ b/apps/backend/src/security/authorization-matrix.test.ts @@ -0,0 +1,39 @@ +import { VoidhashV1Api } from "@voidhash/api-contracts"; +import { RpcGroups } from "@voidhash/rpc"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vite-plus/test"; + +const matrix = readFileSync( + new URL("../../../../docs/security/endpoint-authorization-matrix.md", import.meta.url), + "utf8", +); + +const markedBlock = (name: "HTTP" | "RPC") => { + const start = ``; + const end = ``; + const startIndex = matrix.indexOf(start); + const endIndex = matrix.indexOf(end); + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + throw new Error(`Missing ${name} operation markers in authorization matrix`); + } + return matrix.slice(startIndex + start.length, endIndex); +}; + +const codeSpans = (input: string) => + [...input.matchAll(/`([^`]+)`/g)].map((match) => match[1]!).sort(); + +describe("endpoint authorization matrix", () => { + it("lists every HTTP API contract operation exactly once", () => { + const contractOperations = Object.entries(VoidhashV1Api.groups) + .flatMap(([groupName, group]) => + Object.keys(group.endpoints).map((endpointName) => `${groupName}.${endpointName}`), + ) + .sort(); + expect(codeSpans(markedBlock("HTTP"))).toEqual(contractOperations); + }); + + it("lists every RPC contract operation exactly once", () => { + const contractOperations = [...RpcGroups.requests.keys()].sort(); + expect(codeSpans(markedBlock("RPC"))).toEqual(contractOperations); + }); +}); diff --git a/apps/backend/src/testing/BackendTestConnections.ts b/apps/backend/src/testing/BackendTestConnections.ts new file mode 100644 index 000000000..bf8743e46 --- /dev/null +++ b/apps/backend/src/testing/BackendTestConnections.ts @@ -0,0 +1,23 @@ +/** Ephemeral infrastructure credentials injected into the backend integration smoke. */ +export interface BackendTestConnections { + readonly db: { + readonly host: string; + readonly port: number; + readonly username: string; + readonly password: string; + readonly databaseName: string; + }; + readonly clickhouse: { + readonly url: string; + readonly username: string; + readonly password: string; + readonly database: string; + }; + readonly workos: { + readonly apiKey: string; + readonly clientId: string; + readonly cookieName: string; + readonly cookiePassword: string; + readonly webhookSecret: string; + }; +} diff --git a/apps/backend/src/testing/PurchaseSdkHttpHarness.ts b/apps/backend/src/testing/PurchaseSdkHttpHarness.ts new file mode 100644 index 000000000..da65abcec --- /dev/null +++ b/apps/backend/src/testing/PurchaseSdkHttpHarness.ts @@ -0,0 +1,61 @@ +import { + ApiAuthSession, + AuthMiddleware, + VoidhashV1Api, + type ApiPublishableKeySession, +} from "@voidhash/api-contracts"; +import { + FeatureFlagService, + InternalFeatureFlagService, + NotificationTokenService, + PaywallLocationService, + PersonIdentityService, + SchemaService, + SdkService, +} from "@voidhash/core/services"; +import { Db } from "@voidhash/db"; +import { Context, Effect, Layer } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { SdkGroupLive } from "../routes/v1/sdk.ts"; + +const unusedApiGroups: Layer.Layer = Layer.effectContext( + Effect.sync(() => + Context.makeUnsafe( + new Map( + Object.values(VoidhashV1Api.groups) + .filter((group) => group.identifier !== "sdk") + .map((group) => [group.key, { handlers: new Map(), routes: [] }]), + ), + ), + ), +); + +const unusedSdkRouteServices = Layer.mergeAll( + Layer.succeed(FeatureFlagService, {} as never), + Layer.succeed(InternalFeatureFlagService, {} as never), + Layer.succeed(NotificationTokenService, {} as never), + Layer.succeed(PaywallLocationService, {} as never), + Layer.succeed(SchemaService, {} as never), +); + +/** Builds an in-process Web handler for the real SDK `HttpApi` group. */ +export const makePurchaseSdkHttpHandler = ( + sdkLayer: Layer.Layer, + session: ApiPublishableKeySession, +) => { + const authentication = Layer.succeed( + AuthMiddleware, + AuthMiddleware.of((effect) => Effect.provideService(effect, ApiAuthSession, session)), + ); + const routes = HttpApiBuilder.layer(VoidhashV1Api).pipe( + Layer.provide(Layer.mergeAll(SdkGroupLive, unusedApiGroups)), + Layer.provide(authentication), + Layer.provide(unusedSdkRouteServices), + Layer.provide(sdkLayer), + Layer.provide(HttpServer.layerServices), + ); + + return HttpRouter.toWebHandler(routes, { disableLogger: true }); +}; diff --git a/apps/backend/src/testing/TestLayers.ts b/apps/backend/src/testing/TestLayers.ts new file mode 100644 index 000000000..ee398b577 --- /dev/null +++ b/apps/backend/src/testing/TestLayers.ts @@ -0,0 +1,547 @@ +import { + WebhookDeliveryNotFoundError, + WebhookEndpointNotFoundError, + WebhookValidationError, +} from "@voidhash/core/domain/webhook/Webhook"; +import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { AppStoreReconcileOriginalTransactionWorkflow } from "@voidhash/core/services/paymentProviders/AppStoreReconcileOriginalTransactionWorkflow"; +import { AppStoreReplayParkedNotificationsWorkflow } from "@voidhash/core/services/paymentProviders/AppStoreReplayParkedNotificationsWorkflow"; +import { AppStoreReplayParkedSdkNotificationsWorkflow } from "@voidhash/core/services/paymentProviders/AppStoreReplayParkedSdkNotificationsWorkflow"; +import { GooglePlayReplayParkedNotificationsWorkflow } from "@voidhash/core/services/paymentProviders/GooglePlayReplayParkedNotificationsWorkflow"; +import { StripeReplayParkedNotificationsWorkflow } from "@voidhash/core/services/paymentProviders/StripeReplayParkedNotificationsWorkflow"; +import { IdentifyDistinctIdCompletionWorkflow } from "@voidhash/core/services/personIdentity/IdentifyDistinctIdCompletionWorkflow"; +import { WebhookDeliveryWorkflow } from "@voidhash/core/services/webhookDispatch/WebhookDeliveryWorkflow"; +import { Workos, WorkosAuthError } from "@voidhash/core/services/auth/Workos"; +import { + Db, + eq, + WebhookDeliveryStatus, + WebhookEndpointStatus, + webhookDeliveries, + webhookEndpoints, +} from "@voidhash/db"; +import { + ProjectSchemaCache, + WebhookManagerService, + WebhookServiceError, + WorkosOrgPort, +} from "@voidhash/core/services"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + BackendComponentCompilerStubLive, + BackendMimicHostStubLive, + BackendNoopIdentityProjectionPublisherLive, + BackendPaymentProviderStubsLive, + BackendPaywallArtifactStoreStubLive, + BackendPaywallAssetConfigLive, + BackendPublicFileStoreStubLive, + BackendSnapshotImageRendererStubLive, +} from "../BackendApp.ts"; +import { smokeIdsFromEmail } from "./smoke-ids.ts"; + +// The webhook path that consumes this stub never calls ClickHouse, so a Proxy +// that throws on any access is a safe placeholder that satisfies the +// requirement without standing up a real client. +const clickhouseStub = new Proxy( + {}, + { + get() { + throw new Error("ClickhouseWebClient must not be used in this test"); + }, + }, +) as unknown as ClickhouseWebClient.ClickhouseWebClient; + +export const TestClickhouseLive = Layer.succeed( + ClickhouseWebClient.ClickhouseWebClient, + clickhouseStub, +); + +// The asynchronous workflow ports the route graph dispatches to +// (`PersonIdentityService` → identity completion, webhook dispatch → delivery, +// App Store reconciliation → replay). Every one is a fire-and-forget `dispatch`, +// so the smoke stubs are no-ops. Unlike the other infrastructure services these +// surface as request-scoped requirements on the built handler, so they are +// provided to the worker's `fetch` directly rather than via the infra layer. +export const TestWorkflowPortsLive = Layer.mergeAll( + Layer.succeed(IdentifyDistinctIdCompletionWorkflow, { dispatch: () => Effect.void }), + Layer.succeed(WebhookDeliveryWorkflow, { dispatch: () => Effect.void }), + Layer.succeed(AppStoreReplayParkedNotificationsWorkflow, { dispatch: () => Effect.void }), + Layer.succeed(AppStoreReplayParkedSdkNotificationsWorkflow, { dispatch: () => Effect.void }), + Layer.succeed(AppStoreReconcileOriginalTransactionWorkflow, { dispatch: () => Effect.void }), + Layer.succeed(GooglePlayReplayParkedNotificationsWorkflow, { dispatch: () => Effect.void }), + Layer.succeed(StripeReplayParkedNotificationsWorkflow, { dispatch: () => Effect.void }), +); + +// In-memory no-op project schema cache. The smoke path never relies on a cache +// hit, so every read misses and writes are dropped. +export const TestProjectSchemaCacheLive = Layer.succeed(ProjectSchemaCache, { + getByName: () => ({ + get: () => Effect.succeed(undefined), + invalidate: () => Effect.void, + set: () => Effect.void, + }), +}); + +/** + * Stub `Workos` service for the integration smoke. The HTTP-level + * `AuthMiddleware` requires it; the RPC smoke deliberately authenticates via + * `TestRpcAuthLive` instead, so every WorkOS-shaped method here fails fast + * with `WorkosAuthError`. If a future smoke exercises an HTTP v1 endpoint + * that needs a real session it must swap in a richer stub. + */ +const workosNotConfigured = (operation: string) => + Effect.fail( + new WorkosAuthError({ + cause: "Workos is not configured in the test backend", + message: `Workos.${operation} is not available in tests`, + }), + ); + +export const TestWorkosLive = Layer.succeed(Workos, { + authenticateSessionCookie: () => Effect.succeed(null), + clientId: "test-client-id", + cookieName: "wos-session", + createOrganization: () => workosNotConfigured("createOrganization"), + createOrganizationMembership: () => workosNotConfigured("createOrganizationMembership"), + deleteOrganization: () => workosNotConfigured("deleteOrganization"), + deleteOrganizationMembership: () => workosNotConfigured("deleteOrganizationMembership"), + findUserByEmail: () => Effect.succeed(null), + getJwksUrl: () => "https://test-workos.invalid/jwks", + getOrganization: () => workosNotConfigured("getOrganization"), + getOrganizationByExternalId: () => Effect.succeed(null), + getUser: () => workosNotConfigured("getUser"), + listOrganizationMembershipsForUser: () => Effect.succeed([]), + setUserExternalId: () => workosNotConfigured("setUserExternalId"), + updateOrganization: () => workosNotConfigured("updateOrganization"), + updateOrganizationMembership: () => workosNotConfigured("updateOrganizationMembership"), + verifyWebhook: () => workosNotConfigured("verifyWebhook"), +}); + +/** + * Webhook signing secret the WorkOS webhook smoke signs payloads with. Shared so + * the test signs with the same key {@link TestRealWorkosLive} verifies against. + */ +export const TEST_WORKOS_WEBHOOK_SECRET = "whsec_integration_test_secret_000000000000000000000000"; + +// The real Workos service so the `/api/webhooks/workos` route exercises the +// actual SDK signature verification (SubtleCrypto) rather than the fast-failing +// stub. apiKey/clientId are unused by `verifyWebhook` (no network), so dummy +// values are fine; only `webhookSecret` matters. The wrapper below stubs the +// networked user calls that membership webhooks need. +const TestRealWorkosBaseLive = Workos.layer({ + apiKey: Effect.succeed("sk_test_unused"), + clientId: Effect.succeed("client_test_unused"), + cookieName: Effect.succeed("wos-session"), + cookiePassword: Effect.succeed("integration-test-cookie-password-0000000000"), + webhookSecret: Effect.succeed(TEST_WORKOS_WEBHOOK_SECRET), +}); + +/** + * Real WorkOS SDK for webhook signature verification, with the networked user + * lookups (`getUser` / `setUserExternalId`) faked so membership webhooks resolve + * a synthetic user instead of hitting the WorkOS API. + */ +export const TestRealWorkosLive = Layer.effect( + Workos, + Effect.gen(function* () { + const real = yield* Workos; + const makeUser = (workosUserId: string, externalId: string | null) => { + const localUserId = workosUserId.startsWith("user_smk_") + ? workosUserId.slice("user_".length) + : externalId; + const suffix = localUserId?.replace(/^smk_(?:admin|user|invite)_/, ""); + const email = + localUserId?.startsWith("smk_admin_") && suffix + ? `smoke-admin-${suffix}@example.test` + : localUserId?.startsWith("smk_user_") && suffix + ? `smoke-user-${suffix}@example.test` + : localUserId?.startsWith("smk_invite_") && suffix + ? `smoke-invite-${suffix}@example.test` + : `webhook-${workosUserId}@example.test`; + + return { + createdAt: new Date().toISOString(), + email, + emailVerified: true, + externalId: localUserId, + firstName: "Webhook", + id: workosUserId, + lastName: "User", + lastSignInAt: null, + locale: null, + metadata: {}, + object: "user" as const, + profilePictureUrl: null, + updatedAt: new Date().toISOString(), + }; + }; + + return { + ...real, + getUser: (workosUserId: string) => Effect.succeed(makeUser(workosUserId, null)), + setUserExternalId: (workosUserId: string, externalId: string) => + Effect.succeed(makeUser(workosUserId, externalId)), + }; + }), +).pipe(Layer.provide(TestRealWorkosBaseLive)); + +export const TestWorkosOrgPortLive = Layer.succeed(WorkosOrgPort, { + createMembership: (input) => + Effect.succeed({ + id: `workos_mem_${crypto.randomUUID()}`, + organizationId: input.workosOrganizationId, + role: input.roleSlug ?? "member", + userId: input.workosUserId, + }), + createOrganization: (input) => + Effect.succeed({ + externalId: input.externalId, + id: `workos_org_${input.externalId.slice(0, 24)}`, + name: input.name, + }), + deleteMembership: () => Effect.void, + deleteOrganization: () => Effect.void, + findUserByEmail: (email) => + Effect.succeed( + (() => { + const ids = smokeIdsFromEmail(email); + if (!ids) return null; + const isAdmin = email === ids.adminEmail; + const isInvited = email === ids.invitedEmail; + return { + email, + emailVerified: true, + externalId: null, + firstName: null, + // The WorkOS user id (`user_xxx`), which the resolver matches against + // our `workos_user_id` column — not the local primary key. + id: isAdmin + ? ids.workosAdminUserId + : isInvited + ? ids.workosInvitedUserId + : ids.workosNormalUserId, + lastName: null, + profilePictureUrl: null, + }; + })(), + ), + getOrganization: (workosOrganizationId) => + Effect.succeed({ + externalId: null, + id: workosOrganizationId, + name: `WorkOS ${workosOrganizationId}`, + }), + getOrganizationByExternalId: (externalId) => + Effect.succeed({ + externalId, + id: `workos_org_${externalId.slice(0, 24)}`, + name: `WorkOS ${externalId}`, + }), + listMembershipsForUser: () => Effect.succeed([]), + updateMembershipRole: (workosMembershipId, input) => + Effect.succeed({ + id: workosMembershipId, + organizationId: "unknown", + role: input.roleSlug, + userId: "unknown", + }), + updateOrganization: (input) => + Effect.succeed({ + externalId: null, + id: input.workosOrganizationId, + name: input.name ?? input.workosOrganizationId, + }), +}); + +const webhookSecret = () => `whsec_${crypto.randomUUID().replaceAll("-", "").padEnd(64, "0")}`; + +const webhookEndpointStatus = (status: number) => + status === WebhookEndpointStatus.Active + ? "active" + : status === WebhookEndpointStatus.Failed + ? "failed" + : "disabled"; + +const webhookDeliveryStatus = (status: number) => + status === WebhookDeliveryStatus.InProgress + ? "in_progress" + : status === WebhookDeliveryStatus.Succeeded + ? "succeeded" + : status === WebhookDeliveryStatus.Failed + ? "failed" + : status === WebhookDeliveryStatus.Exhausted + ? "exhausted" + : "pending"; + +const mapEndpoint = (endpoint: typeof webhookEndpoints.$inferSelect) => ({ + consecutiveFailures: endpoint.consecutiveFailures, + createdAt: endpoint.createdAt, + description: endpoint.description, + events: endpoint.events, + id: endpoint.id, + lastSuccessAt: endpoint.lastSuccessAt, + name: endpoint.name, + projectId: endpoint.projectId, + secret: endpoint.secret, + status: webhookEndpointStatus(endpoint.status), + url: endpoint.url, +}); + +const mapDelivery = (delivery: typeof webhookDeliveries.$inferSelect) => ({ + attemptCount: delivery.attemptCount, + completedAt: delivery.completedAt, + createdAt: delivery.createdAt, + eventOccurredAt: delivery.eventOccurredAt, + eventType: delivery.eventType, + id: delivery.id, + maxAttempts: delivery.maxAttempts, + nextAttemptAt: delivery.nextAttemptAt, + payload: delivery.payload, + projectId: delivery.projectId, + status: webhookDeliveryStatus(delivery.status), + webhookEndpointId: delivery.webhookEndpointId, +}); + +const wrapWebhookDb = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error) => + error && + typeof error === "object" && + "_tag" in error && + error._tag === "EffectDrizzleQueryError" + ? new WebhookServiceError({ cause: String((error as { cause?: unknown }).cause) }) + : error, + ), + ); + +export const TestWebhookManagerServiceLive = Layer.effect( + WebhookManagerService, + Effect.gen(function* () { + const db = yield* Db; + + const findEndpoint = (input: { readonly endpointId: string; readonly projectId?: string }) => + db.query.webhookEndpoints.findFirst({ + where: input.projectId + ? { id: input.endpointId, projectId: input.projectId } + : { id: input.endpointId }, + }); + + const findDelivery = (input: { readonly deliveryId: string; readonly projectId?: string }) => + db.query.webhookDeliveries.findFirst({ + where: input.projectId + ? { id: input.deliveryId, projectId: input.projectId } + : { id: input.deliveryId }, + }); + + return { + createEndpoint: (input: any) => + wrapWebhookDb( + Effect.gen(function* () { + const parsedUrl = URL.canParse(input.url) ? new URL(input.url) : null; + if (!parsedUrl || !["http:", "https:"].includes(parsedUrl.protocol)) { + return yield* Effect.fail(new WebhookValidationError({ message: "Invalid URL" })); + } + if (input.events.length === 0) { + return yield* Effect.fail( + new WebhookValidationError({ message: "At least one event is required" }), + ); + } + + const now = new Date(); + const endpoint = { + consecutiveFailures: 0, + createdAt: now, + description: input.description ?? null, + events: [...input.events], + id: `webhookEndpoint_${crypto.randomUUID()}`, + lastSuccessAt: null, + name: input.name, + projectId: input.projectId, + secret: webhookSecret(), + status: WebhookEndpointStatus.Active, + updatedAt: now, + url: input.url, + }; + yield* db.insert(webhookEndpoints).values(endpoint); + return mapEndpoint(endpoint); + }), + ), + deleteEndpoint: (input: any) => + wrapWebhookDb( + Effect.gen(function* () { + const endpoint = yield* findEndpoint({ endpointId: input.endpointId }); + if (!endpoint) { + return yield* Effect.fail( + new WebhookEndpointNotFoundError({ endpointId: input.endpointId }), + ); + } + yield* db.delete(webhookEndpoints).where(eq(webhookEndpoints.id, input.endpointId)); + }), + ), + getDeliveries: (input: any) => + wrapWebhookDb( + db.query.webhookDeliveries + .findMany({ + orderBy: { createdAt: "desc" }, + where: input.endpointId + ? { webhookEndpointId: input.endpointId } + : { projectId: input.projectId }, + }) + .pipe(Effect.map((deliveries) => deliveries.map(mapDelivery))), + ), + getDeliveryById: (input: any) => + wrapWebhookDb( + Effect.gen(function* () { + const delivery = yield* findDelivery({ deliveryId: input.deliveryId }); + if (!delivery) { + return yield* Effect.fail( + new WebhookDeliveryNotFoundError({ deliveryId: input.deliveryId }), + ); + } + const attempts = yield* db.query.webhookDeliveryAttempts.findMany({ + orderBy: { attemptNumber: "asc" }, + where: { webhookDeliveryId: input.deliveryId }, + }); + return { + ...mapDelivery(delivery), + attempts: attempts.map((attempt) => ({ + attemptNumber: attempt.attemptNumber, + createdAt: attempt.createdAt, + durationMs: attempt.durationMs, + errorMessage: attempt.errorMessage, + id: attempt.id, + responseBody: attempt.responseBody, + statusCode: attempt.statusCode, + succeeded: attempt.succeeded, + })), + }; + }), + ), + getEndpointById: (input: any) => + wrapWebhookDb( + Effect.gen(function* () { + const endpoint = yield* findEndpoint({ endpointId: input.endpointId }); + if (!endpoint) { + return yield* Effect.fail( + new WebhookEndpointNotFoundError({ endpointId: input.endpointId }), + ); + } + return mapEndpoint(endpoint); + }), + ), + getEndpoints: (input: any) => + wrapWebhookDb( + db.query.webhookEndpoints + .findMany({ + where: { projectId: input.projectId }, + }) + .pipe(Effect.map((endpoints) => endpoints.map(mapEndpoint))), + ), + retryDelivery: (input: any) => + wrapWebhookDb( + Effect.gen(function* () { + const delivery = yield* findDelivery({ deliveryId: input.deliveryId }); + if (!delivery) { + return yield* Effect.fail( + new WebhookDeliveryNotFoundError({ deliveryId: input.deliveryId }), + ); + } + return yield* Effect.fail( + new WebhookValidationError({ + message: "Can only retry failed or exhausted deliveries", + }), + ); + }), + ), + rotateSecret: (input: any) => + wrapWebhookDb( + Effect.gen(function* () { + const endpoint = yield* findEndpoint({ endpointId: input.endpointId }); + if (!endpoint) { + return yield* Effect.fail( + new WebhookEndpointNotFoundError({ endpointId: input.endpointId }), + ); + } + const secret = webhookSecret(); + yield* db + .update(webhookEndpoints) + .set({ secret }) + .where(eq(webhookEndpoints.id, input.endpointId)); + return mapEndpoint({ ...endpoint, secret }); + }), + ), + testEndpoint: (input: any) => + wrapWebhookDb( + Effect.gen(function* () { + const endpoint = yield* findEndpoint({ endpointId: input.endpointId }); + if (!endpoint) { + return yield* Effect.fail( + new WebhookEndpointNotFoundError({ endpointId: input.endpointId }), + ); + } + const now = new Date(); + const delivery = { + attemptCount: 0, + completedAt: null, + createdAt: now, + eventOccurredAt: now, + eventType: "test.ping", + id: `webhookDelivery_${crypto.randomUUID()}`, + maxAttempts: 5, + nextAttemptAt: null, + payload: { message: "RPC smoke webhook delivery" }, + projectId: endpoint.projectId, + status: WebhookDeliveryStatus.Pending, + webhookEndpointId: endpoint.id, + }; + yield* db.insert(webhookDeliveries).values(delivery); + return mapDelivery(delivery); + }), + ), + updateEndpoint: (input: any) => + wrapWebhookDb( + Effect.gen(function* () { + const endpoint = yield* findEndpoint({ endpointId: input.endpointId }); + if (!endpoint) { + return yield* Effect.fail( + new WebhookEndpointNotFoundError({ endpointId: input.endpointId }), + ); + } + const updates: Partial = {}; + if (input.description !== undefined) updates.description = input.description; + if (input.events !== undefined) updates.events = [...input.events]; + if (input.name !== undefined) updates.name = input.name; + if (input.status !== undefined) { + updates.status = + input.status === "active" + ? WebhookEndpointStatus.Active + : WebhookEndpointStatus.Disabled; + } + if (input.url !== undefined) updates.url = input.url; + yield* db + .update(webhookEndpoints) + .set(updates) + .where(eq(webhookEndpoints.id, input.endpointId)); + return mapEndpoint({ ...endpoint, ...updates }); + }), + ), + } as any; + }), +); + +export const TestBackendStubInfrastructureLive = Layer.mergeAll( + TestClickhouseLive, + TestProjectSchemaCacheLive, + TestWorkosLive, + TestWorkosOrgPortLive, + BackendMimicHostStubLive, + BackendComponentCompilerStubLive, + BackendSnapshotImageRendererStubLive, + BackendPaywallAssetConfigLive, + BackendPaywallArtifactStoreStubLive, + BackendPublicFileStoreStubLive, + BackendPaymentProviderStubsLive, + BackendNoopIdentityProjectionPublisherLive, +); diff --git a/apps/backend/src/testing/TestRpcAuth.ts b/apps/backend/src/testing/TestRpcAuth.ts new file mode 100644 index 000000000..ec4f3a960 --- /dev/null +++ b/apps/backend/src/testing/TestRpcAuth.ts @@ -0,0 +1,95 @@ +import { LocalUserSessionService } from "@voidhash/core/services"; +import { Db } from "@voidhash/db"; +import { + AuthMiddleware, + AuthSession, + RpcAuthenticationError, + RpcNotAuthenticatedError, + type UserSession, +} from "@voidhash/rpc"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as HttpHeaders from "effect/unstable/http/Headers"; + +import { makeSmokeIds, SMOKE_ROLE_HEADER, SMOKE_RUN_ID_HEADER } from "./smoke-ids.ts"; + +type SmokeRole = "admin" | "user"; + +const headerValue = (headers: HttpHeaders.Headers, name: string) => + Option.getOrUndefined(HttpHeaders.get(headers, name)); + +const readSmokeHeaders = ( + headers: HttpHeaders.Headers, +): Effect.Effect<{ readonly role: SmokeRole; readonly runId: string }, RpcNotAuthenticatedError> => + Effect.gen(function* () { + const runId = headerValue(headers, SMOKE_RUN_ID_HEADER); + const role = headerValue(headers, SMOKE_ROLE_HEADER); + + if (!runId) { + return yield* Effect.fail( + new RpcNotAuthenticatedError({ message: `Missing ${SMOKE_RUN_ID_HEADER}` }), + ); + } + + if (role !== "admin" && role !== "user") { + return yield* Effect.fail( + new RpcNotAuthenticatedError({ message: `Missing or invalid ${SMOKE_ROLE_HEADER}` }), + ); + } + + return { role, runId }; + }); + +const loadSession = ( + localUserSessions: typeof LocalUserSessionService.Service, + headers: HttpHeaders.Headers, +): Effect.Effect => + Effect.gen(function* () { + const { role, runId } = yield* readSmokeHeaders(headers); + const ids = makeSmokeIds(runId); + const userId = role === "admin" ? ids.adminUserId : ids.normalUserId; + + const dbUser = yield* localUserSessions.getLocalUser(userId).pipe( + Effect.catchTag("EffectDrizzleQueryError", (error) => + Effect.fail( + new RpcAuthenticationError({ + cause: String(error.cause), + message: "Failed to authenticate due to a database error", + }), + ), + ), + ); + + if (!dbUser) { + return yield* Effect.fail( + new RpcNotAuthenticatedError({ message: `Seeded test user not found: ${userId}` }), + ); + } + + const access = yield* localUserSessions.loadUserAccess(userId).pipe( + Effect.catchTag("EffectDrizzleQueryError", (error) => + Effect.fail( + new RpcAuthenticationError({ + cause: String(error.cause), + message: "Failed to authenticate due to a database error", + }), + ), + ), + ); + + return localUserSessions.toUserSession(dbUser, access, null, `workos_${userId}`); + }); + +export const TestRpcAuthLive = Layer.effect( + AuthMiddleware, + Effect.gen(function* () { + const localUserSessions = yield* LocalUserSessionService; + const db = yield* Db; + return AuthMiddleware.of((effect, { headers }) => + Effect.provideService(loadSession(localUserSessions, headers), Db, db).pipe( + Effect.flatMap((session) => Effect.provideService(effect, AuthSession, session)), + ), + ); + }), +); diff --git a/apps/backend/src/testing/provided-context.d.ts b/apps/backend/src/testing/provided-context.d.ts new file mode 100644 index 000000000..8f815e934 --- /dev/null +++ b/apps/backend/src/testing/provided-context.d.ts @@ -0,0 +1,12 @@ +import type { BackendTestConnections } from "./BackendTestConnections.ts"; + +// Typed channel for the connection slice that the shared integration setup +// provides through vitest. Keeping the local declaration narrow prevents the +// Community backend test graph from importing the Cloud stack package. +declare module "vitest" { + interface ProvidedContext { + coreStackOutput: { + readonly testConnections: BackendTestConnections | null; + }; + } +} diff --git a/apps/backend/src/testing/rpc-smoke-cases.ts b/apps/backend/src/testing/rpc-smoke-cases.ts new file mode 100644 index 000000000..238b24146 --- /dev/null +++ b/apps/backend/src/testing/rpc-smoke-cases.ts @@ -0,0 +1,873 @@ +import { BackendRpcGroups as RpcGroups } from "../BackendRpcGroups.ts"; + +import type { SmokeIds } from "./smoke-ids.ts"; + +export type RpcSmokeRole = "admin" | "user"; + +export interface RpcSmokeContext { + readonly ids: SmokeIds; + readonly runId: string; + readonly webhookTargetUrl?: string; + apiKeyId?: string; + extraOrganizationId?: string; + extraProjectId?: string; + featureFlagId?: string; + featureFlagOverrideId?: string; + featureFlagTargetId?: string; + paymentProviderConfigurationId?: string; + paywallId?: string; + paywallLocationId?: string; + paywallReleaseId?: string; + perkId?: string; + personDistinctId?: string; + personId?: string; + productId?: string; + productPerkId?: string; + userApiKeyId?: string; + webhookDeliveryId?: string; + webhookEndpointId?: string; +} + +export interface RpcSmokeCase { + readonly tag: string; + readonly role: RpcSmokeRole; + readonly payload?: (context: RpcSmokeContext) => unknown; + readonly expected?: { readonly errorTag: string } | { readonly success: true }; + readonly afterSuccess?: (context: RpcSmokeContext, result: unknown) => void; +} + +const success = { success: true } as const; +const error = (errorTag: string) => ({ errorTag }) as const; + +const expectObject = (result: unknown, tag: string): Record => { + if (typeof result !== "object" || result === null || Array.isArray(result)) { + throw new Error(`${tag} returned a non-object result`); + } + return result as Record; +}; + +const expectStringField = (result: unknown, tag: string, field: string): string => { + const value = expectObject(result, tag)[field]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${tag} did not return a string ${field}`); + } + return value; +}; + +const expectArray = (result: unknown, tag: string): ReadonlyArray => { + if (!Array.isArray(result)) { + throw new Error(`${tag} returned a non-array result`); + } + return result; +}; + +/** + * Builds the mutable smoke-test context shared by sequential RPC cases. + */ +export const makeRpcSmokeContext = ( + runId: string, + ids: SmokeIds, + webhookTargetUrl?: string, +): RpcSmokeContext => ({ + ids, + apiKeyId: ids.apiKeyId, + personDistinctId: `person-${runId}`, + runId, + webhookTargetUrl, +}); + +export const rpcSmokeCases = [ + { + expected: success, + payload: ({ ids }) => ({ limit: 5, projectId: ids.projectId }), + role: "admin", + tag: "ListRecentAnalyticsEvents", + }, + { + expected: success, + payload: ({ ids }) => ({ + queries: [ + { + context: { organizationId: ids.organizationId }, + insightId: "builtin/person_count", + key: "person-count", + timeRange: { preset: "last_7d" }, + }, + ], + }), + role: "admin", + tag: "QueryAnalyticsInsights", + }, + { + expected: success, + role: "user", + tag: "CurrentUser", + }, + { + afterSuccess: (context, result) => { + context.extraOrganizationId = expectStringField(result, "CreateOrganization", "id"); + }, + expected: success, + payload: ({ runId }) => ({ name: `Smoke Extra Org ${runId}` }), + role: "admin", + tag: "CreateOrganization", + }, + { + expected: success, + payload: ({ extraOrganizationId, runId }) => ({ + name: `Smoke Extra Org Updated ${runId}`, + organizationId: extraOrganizationId, + }), + role: "admin", + tag: "UpdateOrganization", + }, + { + expected: success, + payload: ({ extraOrganizationId }) => ({ organizationId: extraOrganizationId }), + role: "admin", + tag: "DeleteOrganization", + }, + { + expected: success, + payload: ({ ids, runId }) => ({ + name: `Smoke Extra Project ${runId}`, + organizationId: ids.organizationId, + }), + role: "admin", + tag: "CreateProject", + }, + { + expected: success, + payload: ({ ids }) => ({ organizationId: ids.organizationId }), + role: "admin", + tag: "ListProjects", + }, + { + expected: success, + payload: ({ ids, runId }) => ({ + id: ids.projectId, + name: `Smoke Extra Project Updated ${runId}`, + }), + role: "admin", + tag: "UpdateProject", + }, + { + expected: error("Rpc/ProjectNotFoundError"), + payload: ({ runId }) => ({ id: `missing_project_${runId}` }), + role: "admin", + tag: "DeleteProject", + }, + { + afterSuccess: (context, result) => { + context.apiKeyId = expectStringField(result, "CreateSecretKey", "id"); + }, + expected: success, + payload: ({ ids, runId }) => ({ name: `Smoke secret ${runId}`, projectId: ids.projectId }), + role: "admin", + tag: "CreateSecretKey", + }, + { + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId }), + role: "admin", + tag: "ListApiKeys", + }, + { + expected: success, + payload: ({ apiKeyId }) => ({ apiKeyId }), + role: "admin", + tag: "GetApiKeyById", + }, + { + expected: success, + payload: ({ apiKeyId }) => ({ apiKeyId }), + role: "admin", + tag: "RotateSecretKey", + }, + { + expected: success, + payload: ({ apiKeyId }) => ({ apiKeyId }), + role: "admin", + tag: "DeleteApiKey", + }, + { + afterSuccess: (context, result) => { + context.userApiKeyId = expectStringField(result, "CreateUserApiKey", "id"); + }, + expected: success, + payload: ({ runId }) => ({ name: `Smoke user key ${runId}`, prefix: "smk" }), + role: "admin", + tag: "CreateUserApiKey", + }, + { + expected: success, + payload: () => ({}), + role: "admin", + tag: "ListUserApiKeys", + }, + { + expected: success, + payload: ({ userApiKeyId }) => ({ userApiKeyId }), + role: "admin", + tag: "RevokeUserApiKey", + }, + { + afterSuccess: (context, result) => { + context.personId = expectStringField(result, "CreatePerson", "personId"); + }, + expected: success, + payload: ({ ids, personDistinctId, runId }) => ({ + distinctId: personDistinctId, + email: `person-${runId}@example.test`, + name: "Smoke Person", + projectId: ids.projectId, + }), + role: "admin", + tag: "CreatePerson", + }, + { + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId }), + role: "admin", + tag: "ListPersons", + }, + { + expected: success, + payload: ({ personId }) => ({ personId }), + role: "admin", + tag: "GetPersonById", + }, + { + expected: success, + payload: ({ ids, personDistinctId }) => ({ + distinctId: personDistinctId, + projectId: ids.projectId, + }), + role: "admin", + tag: "GetPersonByDistinctId", + }, + { + expected: success, + payload: ({ ids }) => ({ includeArchived: true, projectId: ids.projectId }), + role: "admin", + tag: "ListFeatureFlags", + }, + { + afterSuccess: (context, result) => { + context.featureFlagId = expectStringField(result, "CreateFeatureFlag", "id"); + }, + expected: success, + payload: ({ ids, runId }) => ({ + description: "Smoke flag", + projectId: ids.projectId, + slug: `smoke-flag-${runId}`, + type: "json", + variants: [], + }), + role: "admin", + tag: "CreateFeatureFlag", + }, + { + expected: success, + payload: ({ featureFlagId }) => ({ id: featureFlagId }), + role: "admin", + tag: "GetFeatureFlag", + }, + { + expected: success, + payload: ({ featureFlagId, runId }) => ({ + enabled: true, + id: featureFlagId, + rolloutBps: 5000, + slug: `smoke-flag-updated-${runId}`, + }), + role: "admin", + tag: "UpdateFeatureFlag", + }, + { + expected: success, + payload: ({ featureFlagId }) => ({ + featureFlagId, + variants: [ + { + value: { kind: "control" }, + }, + ], + }), + role: "admin", + tag: "UpdateFeatureFlagVariants", + }, + { + afterSuccess: (context, result) => { + context.featureFlagOverrideId = expectStringField(result, "UpsertFeatureFlagOverride", "id"); + }, + expected: success, + payload: ({ featureFlagId, runId }) => ({ + featureFlagId, + forcedEnabled: true, + identityType: 2, + identityValue: `distinct-${runId}`, + note: "smoke", + }), + role: "admin", + tag: "UpsertFeatureFlagOverride", + }, + { + expected: success, + payload: ({ featureFlagId }) => ({ featureFlagId }), + role: "admin", + tag: "ListFeatureFlagOverridesByFlag", + }, + { + expected: success, + payload: ({ ids, runId }) => ({ + identityType: 2, + identityValue: `distinct-${runId}`, + projectId: ids.projectId, + }), + role: "admin", + tag: "ListFeatureFlagOverridesByPerson", + }, + { + expected: success, + payload: ({ featureFlagOverrideId }) => ({ id: featureFlagOverrideId }), + role: "admin", + tag: "ArchiveFeatureFlagOverride", + }, + { + afterSuccess: (context, result) => { + context.featureFlagTargetId = expectStringField(result, "UpsertFeatureFlagTarget", "id"); + }, + expected: success, + payload: ({ featureFlagId, runId }) => ({ + featureFlagId, + identityType: 2, + identityValue: `distinct-${runId}`, + listType: 1, + }), + role: "admin", + tag: "UpsertFeatureFlagTarget", + }, + { + expected: success, + payload: ({ featureFlagTargetId }) => ({ id: featureFlagTargetId }), + role: "admin", + tag: "ArchiveFeatureFlagTarget", + }, + { + expected: success, + payload: ({ featureFlagId }) => ({ id: featureFlagId }), + role: "admin", + tag: "ArchiveFeatureFlag", + }, + { + expected: success, + payload: ({ featureFlagId }) => ({ id: featureFlagId }), + role: "admin", + tag: "RestoreFeatureFlag", + }, + { + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId }), + role: "admin", + tag: "ListProducts", + }, + { + afterSuccess: (context, result) => { + context.productId = expectStringField(result, "CreateProduct", "id"); + }, + expected: success, + payload: ({ ids, runId }) => ({ + name: "Smoke Product", + projectId: ids.projectId, + slug: `smoke-product-${runId}`, + }), + role: "admin", + tag: "CreateProduct", + }, + { + expected: success, + payload: ({ productId }) => ({ id: productId }), + role: "admin", + tag: "GetProduct", + }, + { + expected: success, + payload: ({ productId, runId }) => ({ + id: productId, + name: `Smoke Product ${runId}`, + slug: `smoke-product-updated-${runId}`, + }), + role: "admin", + tag: "UpdateProduct", + }, + { + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId }), + role: "admin", + tag: "ListPerks", + }, + { + afterSuccess: (context, result) => { + context.perkId = expectStringField(result, "CreatePerk", "id"); + }, + expected: success, + payload: ({ ids, runId }) => ({ + name: "Smoke Perk", + projectId: ids.projectId, + slug: `smoke-perk-${runId}`, + }), + role: "admin", + tag: "CreatePerk", + }, + { + expected: success, + payload: ({ perkId, productId }) => ({ perkId, productId }), + role: "admin", + tag: "CreateProductPerk", + }, + { + afterSuccess: (context, result) => { + const productPerk = expectArray(result, "ListProductPerksByProductId")[0]; + context.productPerkId = expectStringField(productPerk, "ListProductPerksByProductId", "id"); + }, + expected: success, + payload: ({ productId }) => ({ productId }), + role: "admin", + tag: "ListProductPerksByProductId", + }, + { + expected: success, + payload: ({ productPerkId }) => ({ id: productPerkId }), + role: "admin", + tag: "DeleteProductPerk", + }, + { + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId }), + role: "admin", + tag: "ListPaymentProviderConfigurations", + }, + { + afterSuccess: (context, result) => { + context.paymentProviderConfigurationId = expectStringField( + result, + "CreatePaymentProviderConfiguration", + "id", + ); + }, + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId, providerId: "stripe" }), + role: "admin", + tag: "CreatePaymentProviderConfiguration", + }, + { + expected: success, + payload: ({ paymentProviderConfigurationId }) => ({ id: paymentProviderConfigurationId }), + role: "admin", + tag: "GetPaymentProviderConfiguration", + }, + { + expected: error("Rpc/PaymentProviderConfigurationValidationError"), + payload: ({ paymentProviderConfigurationId }) => ({ + configuration: {}, + enabled: true, + id: paymentProviderConfigurationId, + name: "Stripe Smoke", + }), + role: "admin", + tag: "UpdatePaymentProviderConfiguration", + }, + { + expected: success, + payload: ({ productId }) => ({ productId }), + role: "admin", + tag: "ListProviderProductsByProductId", + }, + { + expected: error("Rpc/PaymentProviderProductValidationError"), + payload: ({ paymentProviderConfigurationId, productId }) => ({ + configuration: {}, + paymentProviderConfigurationId, + productId, + }), + role: "admin", + tag: "CreatePaymentProviderProduct", + }, + { + expected: error("Rpc/PaymentProviderProductNotFoundError"), + payload: ({ runId }) => ({ configuration: {}, id: `missing-provider-product-${runId}` }), + role: "admin", + tag: "UpdatePaymentProviderProduct", + }, + { + expected: error("Rpc/PaymentProviderProductValidationError"), + payload: ({ runId }) => ({ id: `missing-provider-product-${runId}` }), + role: "admin", + tag: "DeletePaymentProviderProduct", + }, + { + expected: success, + payload: ({ paymentProviderConfigurationId, productId, runId }) => ({ + paymentProviderConfigurationId, + productId, + providerProductKey: `missing-provider-key-${runId}`, + }), + role: "admin", + tag: "SetActivePaymentProviderProduct", + }, + { + expected: success, + payload: ({ paymentProviderConfigurationId }) => ({ paymentProviderConfigurationId }), + role: "admin", + tag: "DeletePaymentProviderConfiguration", + }, + { + expected: success, + payload: ({ perkId }) => ({ perkId }), + role: "admin", + tag: "DeletePerk", + }, + { + expected: success, + payload: ({ productId }) => ({ id: productId }), + role: "admin", + tag: "DeleteProduct", + }, + { + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId }), + role: "admin", + tag: "ListPaywalls", + }, + { + afterSuccess: (context, result) => { + context.paywallId = expectStringField(result, "CreatePaywall", "id"); + }, + expected: success, + payload: ({ ids, runId }) => ({ + name: "Smoke Paywall", + projectId: ids.projectId, + slug: `smoke-paywall-${runId}`, + }), + role: "admin", + tag: "CreatePaywall", + }, + { + expected: success, + payload: ({ ids }) => ({ includeArchived: true, projectId: ids.projectId }), + role: "admin", + tag: "ListPaywallLocations", + }, + { + afterSuccess: (context, result) => { + context.paywallLocationId = expectStringField(result, "CreatePaywallLocation", "id"); + }, + expected: success, + payload: ({ ids, runId }) => ({ + description: "Smoke location", + name: "Smoke Location", + projectId: ids.projectId, + slug: `smoke-location-${runId}`, + }), + role: "admin", + tag: "CreatePaywallLocation", + }, + { + expected: success, + payload: ({ paywallLocationId, runId }) => ({ + description: "Smoke location updated", + locationId: paywallLocationId, + name: `Smoke Location ${runId}`, + }), + role: "admin", + tag: "UpdatePaywallLocation", + }, + { + expected: error("Rpc/PaywallLocationShowingValidationError"), + payload: ({ paywallId, paywallLocationId }) => ({ + locationId: paywallLocationId, + paywallId, + type: "paywall_release", + }), + role: "admin", + tag: "AssignPaywallLocationShowing", + }, + { + afterSuccess: (_context, result) => { + expectStringField(result, "RequestPaywallEditToken", "token"); + expectStringField(result, "RequestPaywallEditToken", "url"); + const expiresAt = expectObject(result, "RequestPaywallEditToken").expiresAt; + if (!(expiresAt instanceof Date)) { + throw new Error("RequestPaywallEditToken did not return a Date expiresAt"); + } + }, + expected: success, + payload: ({ paywallId }) => ({ paywallId }), + role: "admin", + tag: "RequestPaywallEditToken", + }, + { + afterSuccess: (context, result) => { + context.paywallReleaseId = expectStringField(result, "CreatePaywallRelease", "releaseId"); + expectStringField(result, "CreatePaywallRelease", "draftUrl"); + }, + expected: success, + payload: ({ paywallId }) => ({ paywallId }), + role: "admin", + tag: "CreatePaywallRelease", + }, + { + afterSuccess: (context, result) => { + const releaseId = expectStringField(result, "GetPaywallDraftRelease", "releaseId"); + if (releaseId !== context.paywallReleaseId) { + throw new Error("GetPaywallDraftRelease returned a different release"); + } + }, + expected: success, + payload: ({ paywallId }) => ({ paywallId }), + role: "admin", + tag: "GetPaywallDraftRelease", + }, + { + afterSuccess: (_context, result) => { + expectStringField(result, "PublishPaywallRelease", "releaseId"); + expectStringField(result, "PublishPaywallRelease", "htmlUrl"); + }, + expected: success, + payload: ({ paywallReleaseId }) => ({ releaseId: paywallReleaseId }), + role: "admin", + tag: "PublishPaywallRelease", + }, + { + expected: error("Rpc/ReleaseNotFoundError"), + payload: ({ runId }) => ({ releaseId: `missing-release-${runId}` }), + role: "admin", + tag: "PublishPaywallRelease", + }, + { + expected: success, + payload: ({ paywallId, paywallLocationId }) => ({ + locationId: paywallLocationId, + paywallId, + type: "paywall_release", + }), + role: "admin", + tag: "AssignPaywallLocationShowing", + }, + { + expected: success, + payload: ({ paywallLocationId }) => ({ locationId: paywallLocationId }), + role: "admin", + tag: "ClearPaywallLocationShowing", + }, + { + expected: success, + payload: ({ paywallLocationId }) => ({ locationId: paywallLocationId }), + role: "admin", + tag: "ListPaywallLocationShowings", + }, + { + expected: success, + payload: ({ paywallLocationId }) => ({ locationId: paywallLocationId }), + role: "admin", + tag: "ArchivePaywallLocation", + }, + { + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId }), + role: "admin", + tag: "ListPaywallDeploys", + }, + { + expected: error("Rpc/ReleaseNotFoundError"), + payload: ({ runId }) => ({ releaseId: `missing-release-${runId}` }), + role: "admin", + tag: "SetActivePaywallRelease", + }, + { + expected: success, + payload: ({ paywallId }) => ({ paywallId }), + role: "admin", + tag: "DeletePaywall", + }, + { + expected: success, + payload: ({ ids }) => ({ projectId: ids.projectId }), + role: "admin", + tag: "ListWebhookEndpoints", + }, + { + afterSuccess: (context, result) => { + context.webhookEndpointId = expectStringField(result, "CreateWebhookEndpoint", "id"); + }, + expected: success, + payload: ({ ids, runId, webhookTargetUrl }) => ({ + description: "Smoke webhook endpoint", + events: ["person.created"], + name: `Smoke Webhook ${runId}`, + projectId: ids.projectId, + url: webhookTargetUrl, + }), + role: "admin", + tag: "CreateWebhookEndpoint", + }, + { + expected: success, + payload: ({ webhookEndpointId }) => ({ endpointId: webhookEndpointId }), + role: "admin", + tag: "GetWebhookEndpoint", + }, + { + expected: success, + payload: ({ webhookEndpointId, runId }) => ({ + description: "Smoke webhook endpoint updated", + endpointId: webhookEndpointId, + events: ["person.created"], + name: `Smoke Webhook Updated ${runId}`, + status: "disabled", + }), + role: "admin", + tag: "UpdateWebhookEndpoint", + }, + { + expected: success, + payload: ({ webhookEndpointId }) => ({ endpointId: webhookEndpointId }), + role: "admin", + tag: "RotateWebhookSecret", + }, + { + afterSuccess: (context, result) => { + context.webhookDeliveryId = expectStringField(result, "TestWebhookEndpoint", "id"); + }, + expected: success, + payload: ({ webhookEndpointId }) => ({ endpointId: webhookEndpointId }), + role: "admin", + tag: "TestWebhookEndpoint", + }, + { + expected: success, + payload: ({ ids, webhookEndpointId }) => ({ + endpointId: webhookEndpointId, + projectId: ids.projectId, + }), + role: "admin", + tag: "ListWebhookDeliveries", + }, + { + expected: success, + payload: ({ webhookDeliveryId }) => ({ deliveryId: webhookDeliveryId }), + role: "admin", + tag: "GetWebhookDelivery", + }, + { + expected: error("Rpc/WebhookValidationError"), + payload: ({ webhookDeliveryId }) => ({ deliveryId: webhookDeliveryId }), + role: "admin", + tag: "RetryWebhookDelivery", + }, + { + expected: success, + payload: ({ webhookEndpointId }) => ({ endpointId: webhookEndpointId }), + role: "admin", + tag: "DeleteWebhookEndpoint", + }, +] satisfies ReadonlyArray; + +/** Existing RPC smoke coverage debt; entries may only be removed as cases are added. */ +const knownMissingRpcSmokeTags = new Set([ + "ListAgentSessions", + "GetAgentSession", + "DeleteAgentSession", + "RevertAgentEditSession", + "UploadAgentAttachment", + "ListExperiments", + "GetExperiment", + "CreateExperiment", + "SaveExperimentSetup", + "StartExperiment", + "PauseExperiment", + "ConcludeExperiment", + "ArchiveExperiment", + "RestoreExperiment", + "GetExperimentResults", + "SubmitFeedback", + "SetOrganizationAvatar", + "RemoveOrganizationAvatar", + "ListPushNotificationConfigurations", + "GetPushNotificationConfiguration", + "CreatePushNotificationConfiguration", + "UpdatePushNotificationConfiguration", + "DeletePushNotificationConfiguration", + "ListPushNotificationSends", + "GetPushNotificationSendDeliveries", + "UploadPaywallAsset", + "ListPaywallAssets", + "RenamePaywallAsset", + "DeletePaywallAsset", + "ListPaywallComponents", + "GetPaywallComponentVersions", + "SetProjectAvatar", + "RemoveProjectAvatar", + "RenamePaywall", + "ArchivePaywall", + "RestorePaywall", + "ListWorkspacePaywalls", + "ReadPaywallDocument", + "RecordComponentManifest", + "SetUserAvatar", + "RemoveUserAvatar", + "RunVoidQlQuery", + "ValidateVoidQlQuery", + "GetVoidQlSchema", + "SaveVoidQlInsight", + "ListVoidQlInsights", + "RunSavedVoidQlInsight", + "DeleteVoidQlInsight", + "QueryCustomAnalyticsInsight", + "QueryCustomAnalyticsPersons", + "ListAnalyticsInsights", + "CreateAnalyticsInsight", + "UpdateAnalyticsInsight", + "DeleteAnalyticsInsight", + "ListAnalyticsCohorts", + "CreateAnalyticsCohort", + "UpdateAnalyticsCohort", + "DeleteAnalyticsCohort", + "ListAnalyticsDashboards", + "CreateAnalyticsDashboard", + "UpdateAnalyticsDashboard", + "DeleteAnalyticsDashboard", + "DuplicateAnalyticsDashboard", + "PutAnalyticsDashboardItem", + "ReorderAnalyticsDashboardItems", + "RemoveAnalyticsDashboardItem", +]); + +/** + * Verifies the smoke manifest does not regress beyond its explicit debt baseline. + */ +export const assertRpcSmokeManifestCoverage = () => { + const rpcTags = new Set(RpcGroups.requests.keys()); + const manifestTags = new Set(rpcSmokeCases.map((rpcCase) => rpcCase.tag)); + const missing = [...rpcTags].filter((tag) => !manifestTags.has(tag)); + const unexpectedMissing = missing.filter((tag) => !knownMissingRpcSmokeTags.has(tag)); + const resolvedBaseline = [...knownMissingRpcSmokeTags].filter((tag) => !missing.includes(tag)); + const stale = [...manifestTags].filter((tag) => !rpcTags.has(tag)); + + if (unexpectedMissing.length || resolvedBaseline.length || stale.length) { + throw new Error( + [ + unexpectedMissing.length + ? `Unexpected missing smoke RPC cases: ${unexpectedMissing.join(", ")}` + : undefined, + resolvedBaseline.length + ? `Resolved smoke baseline entries must be removed: ${resolvedBaseline.join(", ")}` + : undefined, + stale.length ? `Stale smoke RPC cases: ${stale.join(", ")}` : undefined, + ] + .filter(Boolean) + .join("\n"), + ); + } +}; diff --git a/apps/backend/src/testing/smoke-ids.ts b/apps/backend/src/testing/smoke-ids.ts new file mode 100644 index 000000000..9e210ced4 --- /dev/null +++ b/apps/backend/src/testing/smoke-ids.ts @@ -0,0 +1,47 @@ +export const SMOKE_RUN_ID_HEADER = "x-voidhash-rpc-smoke-run-id"; +export const SMOKE_ROLE_HEADER = "x-voidhash-rpc-smoke-role"; + +const normalizeRunId = (runId: string): string => { + const normalized = runId + .toLowerCase() + .replaceAll(/[^a-z0-9]/g, "") + .slice(0, 10); + + return normalized.length > 0 ? normalized : "default"; +}; + +export const makeSmokeIds = (runId: string) => { + const suffix = normalizeRunId(runId); + + return { + adminEmail: `smoke-admin-${suffix}@example.test`, + adminMemberId: `smk_mem_admin_${suffix}`, + adminUserId: `smk_admin_${suffix}`, + apiKeyId: `smk_api_key_${suffix}`, + billingId: `smk_billing_${suffix}`, + invitedEmail: `smoke-invite-${suffix}@example.test`, + invitedUserId: `smk_invite_${suffix}`, + normalEmail: `smoke-user-${suffix}@example.test`, + normalMemberId: `smk_mem_user_${suffix}`, + normalUserId: `smk_user_${suffix}`, + organizationId: `smk_org_${suffix}`, + organizationSlug: `smoke-org-${suffix}`, + projectId: `smk_project_${suffix}`, + projectSlug: `smoke-project-${suffix}`, + workosAdminMembershipId: `workos_mem_admin_${suffix}`, + // WorkOS user ids are the local id prefixed with `user_`, matching the + // synthetic users `TestRealWorkosLive` (TestLayers.ts) derives from a WorkOS id. + workosAdminUserId: `user_smk_admin_${suffix}`, + workosInvitedUserId: `user_smk_invite_${suffix}`, + workosNormalMembershipId: `workos_mem_user_${suffix}`, + workosNormalUserId: `user_smk_user_${suffix}`, + workosOrganizationId: `workos_org_${suffix}`, + } as const; +}; + +export type SmokeIds = ReturnType; + +export const smokeIdsFromEmail = (email: string): SmokeIds | undefined => { + const match = /^smoke-(?:admin|user|invite)-([a-z0-9]+)@example\.test$/.exec(email); + return match ? makeSmokeIds(match[1]) : undefined; +}; diff --git a/apps/backend/src/testing/smoke-seed.ts b/apps/backend/src/testing/smoke-seed.ts new file mode 100644 index 000000000..7ab768d49 --- /dev/null +++ b/apps/backend/src/testing/smoke-seed.ts @@ -0,0 +1,310 @@ +import { + apiKeys, + apikey, + auditLogs, + BillingSubscriptionStatus, + BillingTier, + captureProjectPolicies, + Db, + eq, + featureFlagOverrides, + featureFlags, + featureFlagTargets, + featureFlagVariants, + inArray, + invitation, + member, + organization, + organizationBilling, + paymentProviderConfigurationProducts, + paymentProviderConfigurations, + paywallLocationShowings, + paywallLocations, + paywallReleases, + paywalls, + perks, + personDeletionRequests, + personExternalIdentifiers, + personIdentities, + personIdentityMigrationJobs, + personPersonlessIdentities, + persons, + personUnlockedPerks, + productPerks, + products, + projects, + usageAggregates, + usageRecords, + user, + webhookDeliveries, + webhookDeliveryAttempts, + webhookEndpoints, +} from "@voidhash/db"; +import * as Effect from "effect/Effect"; + +import { makeSmokeIds } from "./smoke-ids.ts"; + +/** + * Deterministic fixture for the backend RPC smoke. Seeds an `admin`-role user, a + * normal user, their organization/memberships, a project, a seeded API key, and a + * billing row — everything the {@link rpcSmokeCases} manifest reads or scopes + * against. Kept separate from the lean shared `CoreTestFixture` because the smoke + * needs a richer, smoke-specific tenant; it still rides the same once-deployed + * stack + `testConnections` as the service-level integration tests. + * + * Previously these ran inside the deployed test worker behind `/__test/seed` and + * `/__test/reset` HTTP routes; the smoke now runs in-process, so they are plain + * Effects executed against `Db.layer(testConnections.db)` in a `beforeAll`. + */ + +const deleteIfAny = ( + values: ReadonlyArray, + run: (values: [T, ...T[]]) => Effect.Effect, +): Effect.Effect => + values.length === 0 ? Effect.void : run(values as [T, ...T[]]); + +const selectIds = ( + rows: Effect.Effect, E, R>, +): Effect.Effect => + Effect.map(rows, (resolved) => resolved.flatMap((row) => (row.id ? [row.id] : []))); + +/** + * Delete every row the smoke fixture (and the cases that build on it) creates, + * deepest foreign-key dependents first, scoped to the run's namespaced ids. Safe + * to run before each seed so a crashed run never leaves colliding state. + */ +export const resetSmokeData = (runId: string) => + Effect.gen(function* () { + const db = yield* Db; + const ids = makeSmokeIds(runId); + + const productIds = yield* selectIds( + db.select({ id: products.id }).from(products).where(eq(products.projectId, ids.projectId)), + ); + const perkIds = yield* selectIds( + db.select({ id: perks.id }).from(perks).where(eq(perks.projectId, ids.projectId)), + ); + const paymentProviderConfigurationIds = yield* selectIds( + db + .select({ id: paymentProviderConfigurations.id }) + .from(paymentProviderConfigurations) + .where(eq(paymentProviderConfigurations.projectId, ids.projectId)), + ); + const paywallIds = yield* selectIds( + db.select({ id: paywalls.id }).from(paywalls).where(eq(paywalls.projectId, ids.projectId)), + ); + const featureFlagIds = yield* selectIds( + db + .select({ id: featureFlags.id }) + .from(featureFlags) + .where(eq(featureFlags.projectId, ids.projectId)), + ); + const personIds = yield* selectIds( + db.select({ id: persons.id }).from(persons).where(eq(persons.projectId, ids.projectId)), + ); + const webhookDeliveryIds = yield* selectIds( + db + .select({ id: webhookDeliveries.id }) + .from(webhookDeliveries) + .where(eq(webhookDeliveries.projectId, ids.projectId)), + ); + + yield* deleteIfAny(webhookDeliveryIds, (values) => + db + .delete(webhookDeliveryAttempts) + .where(inArray(webhookDeliveryAttempts.webhookDeliveryId, values)), + ); + yield* db.delete(webhookDeliveries).where(eq(webhookDeliveries.projectId, ids.projectId)); + yield* db.delete(webhookEndpoints).where(eq(webhookEndpoints.projectId, ids.projectId)); + + yield* db + .delete(paywallLocationShowings) + .where(eq(paywallLocationShowings.projectId, ids.projectId)); + yield* db.delete(paywallLocations).where(eq(paywallLocations.projectId, ids.projectId)); + yield* deleteIfAny(paywallIds, (values) => + db.delete(paywallReleases).where(inArray(paywallReleases.paywallId, values)), + ); + yield* db.delete(paywalls).where(eq(paywalls.projectId, ids.projectId)); + + yield* deleteIfAny(productIds, (values) => + db.delete(productPerks).where(inArray(productPerks.productId, values)), + ); + yield* deleteIfAny(perkIds, (values) => + db.delete(productPerks).where(inArray(productPerks.perkId, values)), + ); + yield* deleteIfAny(productIds, (values) => + db + .delete(paymentProviderConfigurationProducts) + .where(inArray(paymentProviderConfigurationProducts.productId, values)), + ); + yield* deleteIfAny(paymentProviderConfigurationIds, (values) => + db + .delete(paymentProviderConfigurationProducts) + .where( + inArray(paymentProviderConfigurationProducts.paymentProviderConfigurationId, values), + ), + ); + yield* db + .delete(paymentProviderConfigurations) + .where(eq(paymentProviderConfigurations.projectId, ids.projectId)); + yield* db.delete(products).where(eq(products.projectId, ids.projectId)); + yield* db.delete(perks).where(eq(perks.projectId, ids.projectId)); + + yield* deleteIfAny(featureFlagIds, (values) => + db.delete(featureFlagVariants).where(inArray(featureFlagVariants.featureFlagId, values)), + ); + yield* deleteIfAny(featureFlagIds, (values) => + db.delete(featureFlagTargets).where(inArray(featureFlagTargets.featureFlagId, values)), + ); + yield* deleteIfAny(featureFlagIds, (values) => + db.delete(featureFlagOverrides).where(inArray(featureFlagOverrides.featureFlagId, values)), + ); + yield* db.delete(featureFlags).where(eq(featureFlags.projectId, ids.projectId)); + + yield* db + .delete(personExternalIdentifiers) + .where(eq(personExternalIdentifiers.projectId, ids.projectId)); + yield* db + .delete(personIdentityMigrationJobs) + .where(eq(personIdentityMigrationJobs.projectId, ids.projectId)); + yield* db + .delete(personPersonlessIdentities) + .where(eq(personPersonlessIdentities.projectId, ids.projectId)); + yield* db + .delete(personDeletionRequests) + .where(eq(personDeletionRequests.projectId, ids.projectId)); + yield* deleteIfAny(personIds, (values) => + db.delete(personUnlockedPerks).where(inArray(personUnlockedPerks.personId, values)), + ); + yield* db.delete(personIdentities).where(eq(personIdentities.projectId, ids.projectId)); + yield* db.delete(persons).where(eq(persons.projectId, ids.projectId)); + + yield* db.delete(apiKeys).where(eq(apiKeys.projectId, ids.projectId)); + yield* db.delete(auditLogs).where(eq(auditLogs.projectId, ids.projectId)); + yield* db + .delete(captureProjectPolicies) + .where(eq(captureProjectPolicies.projectId, ids.projectId)); + yield* db.delete(projects).where(eq(projects.id, ids.projectId)); + + yield* db.delete(usageRecords).where(eq(usageRecords.organizationId, ids.organizationId)); + yield* db.delete(usageAggregates).where(eq(usageAggregates.organizationId, ids.organizationId)); + yield* db + .delete(organizationBilling) + .where(eq(organizationBilling.organizationId, ids.organizationId)); + yield* db.delete(invitation).where(eq(invitation.organizationId, ids.organizationId)); + yield* db.delete(member).where(eq(member.organizationId, ids.organizationId)); + yield* db.delete(organization).where(eq(organization.id, ids.organizationId)); + + yield* db + .delete(apikey) + .where(inArray(apikey.userId, [ids.adminUserId, ids.normalUserId, ids.invitedUserId])); + yield* db + .delete(user) + .where(inArray(user.id, [ids.adminUserId, ids.normalUserId, ids.invitedUserId])); + }); + +/** + * Reset, then seed the smoke fixture. Run once before the smoke cases over + * `Db.layer(testConnections.db)`. + */ +export const seedSmokeData = (runId: string) => + Effect.gen(function* () { + const db = yield* Db; + const ids = makeSmokeIds(runId); + const now = new Date(); + + yield* resetSmokeData(runId); + yield* db.insert(user).values([ + { + banned: false, + banExpires: null, + banReason: null, + createdAt: now, + email: ids.adminEmail, + emailVerified: true, + id: ids.adminUserId, + image: null, + name: "RPC Smoke Admin", + role: "admin", + updatedAt: now, + workosUserId: ids.workosAdminUserId, + }, + { + banned: false, + banExpires: null, + banReason: null, + createdAt: now, + email: ids.normalEmail, + emailVerified: true, + id: ids.normalUserId, + image: null, + name: "RPC Smoke User", + role: null, + updatedAt: now, + workosUserId: ids.workosNormalUserId, + }, + ]); + yield* db.insert(organization).values({ + createdAt: now, + id: ids.organizationId, + logo: null, + metadata: null, + name: "RPC Smoke Organization", + slug: ids.organizationSlug, + workosOrganizationId: ids.workosOrganizationId, + }); + yield* db.insert(member).values([ + { + createdAt: now, + id: ids.adminMemberId, + organizationId: ids.organizationId, + role: "owner", + userId: ids.adminUserId, + workosMembershipId: ids.workosAdminMembershipId, + }, + { + createdAt: now, + id: ids.normalMemberId, + organizationId: ids.organizationId, + role: "member", + userId: ids.normalUserId, + workosMembershipId: ids.workosNormalMembershipId, + }, + ]); + yield* db.insert(projects).values({ + createdAt: now, + createdByUserId: ids.adminUserId, + id: ids.projectId, + name: "RPC Smoke Project", + organizationId: ids.organizationId, + slug: ids.projectSlug, + updatedAt: now, + }); + yield* db.insert(captureProjectPolicies).values({ + createdAt: now, + projectId: ids.projectId, + updatedAt: now, + }); + yield* db.insert(apiKeys).values({ + createdAt: now, + end: "test", + id: ids.apiKeyId, + isPublic: false, + key: "seeded-smoke-key", + name: "Seeded smoke secret", + prefix: "smk", + projectId: ids.projectId, + updatedAt: now, + }); + yield* db.insert(organizationBilling).values({ + billingProviderId: "smoke", + currentPeriodEnd: null, + currentPeriodStart: null, + externalCustomerId: null, + externalSubscriptionId: null, + id: ids.billingId, + organizationId: ids.organizationId, + subscriptionStatus: BillingSubscriptionStatus.None, + tier: BillingTier.Free, + }); + }); diff --git a/apps/backend/sst-env.d.ts b/apps/backend/sst-env.d.ts new file mode 100644 index 000000000..eec65b9bd --- /dev/null +++ b/apps/backend/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst"; +export {}; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 000000000..b9da6a8e5 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@voidhash/tsconfig/alchemy-base.json", + "compilerOptions": { + "types": ["bun"], + "allowImportingTsExtensions": true, + "composite": true, + "stripInternal": true, + "noEmit": false, + "outDir": "./lib", + "rootDir": "./src", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["**/node_modules/**"] +} diff --git a/apps/backend/vitest.unit.mts b/apps/backend/vitest.unit.mts new file mode 100644 index 000000000..7f23222f6 --- /dev/null +++ b/apps/backend/vitest.unit.mts @@ -0,0 +1,9 @@ +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.dev.ts b/apps/cli/build.dev.ts index 0c4de7371..0a7ba3062 100644 --- a/apps/cli/build.dev.ts +++ b/apps/cli/build.dev.ts @@ -6,7 +6,7 @@ esbuild.buildSync({ }, bundle: true, entryPoints: ["./src/cli/index.ts"], - external: ["esbuild"], + external: ["esbuild", "@voidhash/studio", "@voidhash/paywalls", "vite", "typescript"], format: "cjs", outfile: "dist/index.cjs", platform: "node", diff --git a/apps/cli/build.ts b/apps/cli/build.ts index c548640c4..dc64c9037 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -12,7 +12,9 @@ esbuild.buildSync({ "process.env.VOIDHASH_CLI_VERSION": `"${pkg.version}"`, }, entryPoints: ["./src/cli/index.ts"], - external: ["esbuild"], + // These are resolved/launched at runtime (Studio's Vite app, the paywalls + // runtime, Vite itself) — keep them out of the bundle. + external: ["esbuild", "@voidhash/studio", "@voidhash/paywalls", "vite", "typescript"], format: "cjs", outfile: "dist/bin.cjs", platform: "node", @@ -30,7 +32,7 @@ const main = async () => { if (ctx.format === "cjs") { return { dts: ".d.ts", - js: ".js", + js: ".cjs", }; } return { diff --git a/apps/cli/package.json b/apps/cli/package.json index 3cee200de..e2d31cfb4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,7 @@ { "name": "voidhash-cli", "version": "0.0.1-alpha.1", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/voidhashcom/voidhash", @@ -18,7 +19,7 @@ }, "require": { "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "default": "./dist/index.cjs" } } }, @@ -26,31 +27,33 @@ "start": "dotenv -- tsx ./src/index.ts", "build": "rm -rf ./dist && tsx build.ts && cp package.json dist/ && chmod +x ./dist/bin.cjs", "build:dev": "rm -rf ./dist && tsx build.dev.ts && chmod +x ./dist/index.cjs", - "dev:set-local-urls": "npx voidhash-cli config set api_url http://localhost:5001 && npx voidhash-cli config set web_url https://voidhash.localhost:1355", + "dev:set-local-urls": "npx voidhash-cli config set api_url http://localhost:8787 && npx voidhash-cli config set web_url https://localhost:3000", "typecheck": "tsgo --noEmit", "test": "vitest run -c vitest.unit.mts", "test:watch": "vitest -c vitest.unit.mts" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.23", - "@voidhash/api-spec": "workspace:*", + "@better-auth/api-key": "catalog:", + "@effect/platform-node": "4.0.0-beta.84", + "@voidhash/generated-clients": "workspace:*", "@voidhash/shared": "workspace:*", + "@voidhash/studio": "workspace:*", "better-auth": "catalog:", - "effect": "4.0.0-beta.23", + "effect": "4.0.0-beta.84", + "esbuild": "^0.25.10", "esbuild-register": "^3.6.0", - "nanoid": "^5.1.5" + "nanoid": "^5.1.5", + "typescript": "5.6.3" }, "devDependencies": { "@effect/language-service": "catalog:", - "@effect/vitest": "4.0.0-beta.23", - "@voidhash/react-native": "workspace:*", + "@effect/vitest": "4.0.0-beta.84", "@voidhash/tsconfig": "workspace:*", "dotenv-cli": "^10.0.0", "esbuild": "^0.25.10", - "tsup": "^6.1.3", + "tsup": "^8.5.1", "tsx": "^4.19.3", - "typescript": "5.6.3", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.0.9" + "vitest": "^3.2.7" } } diff --git a/apps/cli/src/cli/commands/auth-login.ts b/apps/cli/src/cli/commands/auth-login.ts index abbbce10d..33c14ec9e 100644 --- a/apps/cli/src/cli/commands/auth-login.ts +++ b/apps/cli/src/cli/commands/auth-login.ts @@ -2,9 +2,8 @@ import { Command, Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; -import { debugOption } from "../shared-options"; -export const loginCommand = Command.make("login", { debug: debugOption }, () => +export const loginCommand = Command.make("login", {}, () => Effect.gen(function* loginCommand() { const auth = yield* Auth; const user = yield* auth.getSignedInSession.pipe( @@ -13,19 +12,19 @@ export const loginCommand = Command.make("login", { debug: debugOption }, () => Effect.succeed(null).pipe( Effect.tap(() => Console.log( - "Failed to get currect user session. Acting as if the user is not logged in." - ) - ) + "Failed to get currect user session. Acting as if the user is not logged in.", + ), + ), ), NoSignedInUserError: () => Effect.succeed(null), - }) + }), ); if (user) { const shouldContinue = yield* Prompt.run( Prompt.confirm({ message: `You are already logged in as ${user.name}. You will be logged out. Do you want to continue?`, - }) + }), ); if (!shouldContinue) { return yield* Console.log("Login cancelled."); @@ -33,24 +32,18 @@ export const loginCommand = Command.make("login", { debug: debugOption }, () => return yield* auth.logout.pipe( Effect.catchTags({ FailedToLogoutError: () => - Effect.succeed(null).pipe( - Effect.tap(() => Console.log("Failed to logout.")) - ), - }) + Effect.succeed(null).pipe(Effect.tap(() => Console.log("Failed to logout."))), + }), ); } return yield* auth.login.pipe( Effect.catchTags({ FailedToLoginError: () => - Effect.void.pipe( - Effect.tap(() => Console.log("Failed to login. Please try again.")) - ), + Effect.void.pipe(Effect.tap(() => Console.log("Failed to login. Please try again."))), LoginCancelledError: () => - Effect.void.pipe( - Effect.tap(() => Console.log("Login cancelled.")) - ), - }) + Effect.void.pipe(Effect.tap(() => Console.log("Login cancelled."))), + }), ); - }) + }), ).pipe(Command.withDescription("Login to the Voidhash CLI.")); diff --git a/apps/cli/src/cli/commands/auth-logout.ts b/apps/cli/src/cli/commands/auth-logout.ts index 61f2eda2f..25ddd18cd 100644 --- a/apps/cli/src/cli/commands/auth-logout.ts +++ b/apps/cli/src/cli/commands/auth-logout.ts @@ -2,47 +2,37 @@ import { Command, Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; -import { debugOption } from "../shared-options"; -export const logoutCommand = Command.make( - "logout", - { debug: debugOption }, - () => - Effect.gen(function* logoutCommand() { - const auth = yield* Auth; - const user = yield* auth.getSignedInSession.pipe( - Effect.catchTags({ - FailedToGetSessionError: () => - Effect.succeed(null).pipe( - Effect.tap(() => - Console.log( - "Failed to get current user session. We will still proceed with logout.", - ), - ), - ), - NoSignedInUserError: () => Effect.succeed(null), - }), - ); +export const logoutCommand = Command.make("logout", {}, () => + Effect.gen(function* logoutCommand() { + const auth = yield* Auth; + const user = yield* auth.getSignedInSession.pipe( + Effect.catchTags({ + FailedToGetSessionError: () => + Effect.succeed(null).pipe( + Effect.tap(() => + Console.log("Failed to get current user session. We will still proceed with logout."), + ), + ), + NoSignedInUserError: () => Effect.succeed(null), + }), + ); - if (user) { - const shouldContinue = yield* Prompt.run( - Prompt.confirm({ - message: `You are currently logged in as ${user.name}. You will be logged out. Do you want to continue?`, - }), - ); - if (!shouldContinue) { - return yield* Console.log("Login cancelled."); - } - } - return yield* auth.logout; - }).pipe( - Effect.catchTags({ - FailedToLogoutError: () => - Effect.void.pipe( - Effect.tap(() => - Console.log("Failed to logout. Please try again."), - ), - ), - }), - ), + if (user) { + const shouldContinue = yield* Prompt.run( + Prompt.confirm({ + message: `You are currently logged in as ${user.name}. You will be logged out. Do you want to continue?`, + }), + ); + if (!shouldContinue) { + return yield* Console.log("Login cancelled."); + } + } + return yield* auth.logout; + }).pipe( + Effect.catchTags({ + FailedToLogoutError: () => + Effect.void.pipe(Effect.tap(() => Console.log("Failed to logout. Please try again."))), + }), + ), ).pipe(Command.withDescription("Logout from the Voidhash CLI.")); diff --git a/apps/cli/src/cli/commands/auth-status.ts b/apps/cli/src/cli/commands/auth-status.ts index 1d582ea11..7bfb54df0 100644 --- a/apps/cli/src/cli/commands/auth-status.ts +++ b/apps/cli/src/cli/commands/auth-status.ts @@ -3,9 +3,8 @@ import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; -export const authStatusCommand = Command.make("status", { debug: debugOption }, () => +export const authStatusCommand = Command.make("status", {}, () => Effect.gen(function* authStatusCommand() { const auth = yield* Auth; const user = yield* auth.getSignedInSession.pipe( @@ -13,11 +12,11 @@ export const authStatusCommand = Command.make("status", { debug: debugOption }, FailedToGetSessionError: () => Effect.fail( userError( - "Failed to get user session. Please try again or run 'voidhash auth login'." - ) + "Failed to get user session. Please try again or run 'voidhash-cli auth login'.", + ), ), NoSignedInUserError: () => Effect.succeed(null), - }) + }), ); if (user) { @@ -25,5 +24,5 @@ export const authStatusCommand = Command.make("status", { debug: debugOption }, } else { yield* Console.log("Not logged in"); } - }) + }), ).pipe(Command.withDescription("Check the status of the Voidhash CLI.")); diff --git a/apps/cli/src/cli/commands/auth-token.ts b/apps/cli/src/cli/commands/auth-token.ts new file mode 100644 index 000000000..ba595f36a --- /dev/null +++ b/apps/cli/src/cli/commands/auth-token.ts @@ -0,0 +1,37 @@ +import { Console, Effect } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; + +import { CliConfig } from "../../domain/services/cli-config"; +import { userError } from "../../utils/error-formatter"; + +const projectFlag = Flag.string("project").pipe( + Flag.withDescription("Project id or slug for MCP requests"), + Flag.withDefault(""), +); + +/** Builds the JSON object expected from a Claude Code MCP headers helper. */ +export const buildMcpHeaders = ( + apiKey: string, + project: string | undefined, +): Record => ({ + Authorization: `Bearer ${apiKey}`, + ...(project === undefined || project.length === 0 ? {} : { "X-Voidhash-Project": project }), +}); + +/** Prints authenticated MCP request headers without exposing them as arguments. */ +export const authTokenCommand = Command.make("token", { project: projectFlag }, ({ project }) => + Effect.gen(function* authTokenCommand() { + const cliConfig = yield* CliConfig; + const config = yield* cliConfig.readConfig(); + if (config.api_key === null || config.api_key === undefined || config.api_key.length === 0) { + return yield* Effect.fail( + userError("You must be logged in. Run 'voidhash-cli auth login' first."), + ); + } + const selectedProject = + project.trim() || + process.env.CLAUDE_PLUGIN_OPTION_PROJECT?.trim() || + process.env.VOIDHASH_PROJECT?.trim(); + yield* Console.log(JSON.stringify(buildMcpHeaders(config.api_key, selectedProject))); + }), +).pipe(Command.withDescription("Print MCP connection headers from the current CLI login.")); diff --git a/apps/cli/src/cli/commands/auth.ts b/apps/cli/src/cli/commands/auth.ts index ed7c688f8..f1ad08c4d 100644 --- a/apps/cli/src/cli/commands/auth.ts +++ b/apps/cli/src/cli/commands/auth.ts @@ -1,16 +1,16 @@ import { Command } from "effect/unstable/cli"; import { Effect } from "effect"; -import { debugOption } from "../shared-options"; import { loginCommand } from "./auth-login"; import { logoutCommand } from "./auth-logout"; import { authStatusCommand } from "./auth-status"; +import { authTokenCommand } from "./auth-token"; -export const authCommand = Command.make("auth", { debug: debugOption }, () => +export const authCommand = Command.make("auth", {}, () => Effect.gen(function* authCommand() { // TODO: Show sucommands documentation - }) + }), ).pipe( Command.withDescription("Manage the Voidhash authentication."), - Command.withSubcommands([loginCommand, logoutCommand, authStatusCommand]) + Command.withSubcommands([loginCommand, logoutCommand, authStatusCommand, authTokenCommand]), ); diff --git a/apps/cli/src/cli/commands/config-reset.ts b/apps/cli/src/cli/commands/config-reset.ts index aeab15137..63c8364f1 100644 --- a/apps/cli/src/cli/commands/config-reset.ts +++ b/apps/cli/src/cli/commands/config-reset.ts @@ -3,25 +3,20 @@ import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; -export const configResetCommand = Command.make("reset", { debug: debugOption }, () => +export const configResetCommand = Command.make("reset", {}, () => Effect.gen(function* configResetCommand() { const cliConfig = yield* CliConfig; yield* cliConfig .resetConfig() .pipe( - Effect.catchTag("SchemaError", () => - Effect.fail( - userError("Failed to set configuration") - ) - ) + Effect.catchTag("SchemaError", () => Effect.fail(userError("Failed to set configuration"))), ); yield* Console.log("Configuration reset successfully."); - }) + }), ).pipe( Command.withDescription( - "Reset the Voidhash configuration to the default values. If authenticated, persists the authentication state. For logout, use the `auth logout` command." - ) + "Reset the Voidhash configuration to the default values. If authenticated, persists the authentication state. For logout, use the `auth logout` command.", + ), ); diff --git a/apps/cli/src/cli/commands/config-set.ts b/apps/cli/src/cli/commands/config-set.ts index f4a133792..9185ebc22 100644 --- a/apps/cli/src/cli/commands/config-set.ts +++ b/apps/cli/src/cli/commands/config-set.ts @@ -3,30 +3,25 @@ import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; const keyArg = Argument.string("key"); const valueArg = Argument.string("value"); export const configSetCommand = Command.make( "set", - { debug: debugOption, key: keyArg, value: valueArg }, + { key: keyArg, value: valueArg }, ({ key, value }) => Effect.gen(function* configSetCommand() { const cliConfig = yield* CliConfig; - const config = yield* cliConfig.readConfig(); yield* cliConfig .writeToConfig({ - ...config, [key]: value, }) .pipe( Effect.catchTag("SchemaError", () => - Effect.fail( - userError("Failed to set configuration") - ) - ) + Effect.fail(userError("Failed to set configuration")), + ), ); yield* Console.log("Configuration set successfully."); - }) + }), ).pipe(Command.withDescription("Set a Voidhash configuration value.")); diff --git a/apps/cli/src/cli/commands/config.ts b/apps/cli/src/cli/commands/config.ts index 7fcec09fc..cae62b97d 100644 --- a/apps/cli/src/cli/commands/config.ts +++ b/apps/cli/src/cli/commands/config.ts @@ -2,13 +2,18 @@ import { Command } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; -import { debugOption } from "../shared-options"; +import { getActiveProfile } from "../../utils/error-formatter"; import { configResetCommand } from "./config-reset"; import { configSetCommand } from "./config-set"; -export const configCommand = Command.make("config", { debug: debugOption }, () => +export const configCommand = Command.make("config", {}, () => Effect.gen(function* configCommand() { const cliConfig = yield* CliConfig; + const activeProfile = getActiveProfile(); + + if (activeProfile) { + yield* Console.log(`Active profile: ${activeProfile}`); + } yield* Console.log("Current configuration:"); const config = yield* cliConfig.readConfig(); @@ -20,8 +25,14 @@ export const configCommand = Command.make("config", { debug: debugOption }, () = yield* Console.log(`${key}: ${value}`); } } - }) + + const raw = yield* cliConfig.readRawConfig(); + const profileNames = Object.keys(raw.profiles ?? {}); + if (profileNames.length > 0) { + yield* Console.log(`\nProfiles: ${profileNames.join(", ")}`); + } + }), ).pipe( Command.withDescription("Manage the Voidhash authentication."), - Command.withSubcommands([configSetCommand, configResetCommand]) + Command.withSubcommands([configSetCommand, configResetCommand]), ); diff --git a/apps/cli/src/cli/commands/deploy.ts b/apps/cli/src/cli/commands/deploy.ts new file mode 100644 index 000000000..74a6faa99 --- /dev/null +++ b/apps/cli/src/cli/commands/deploy.ts @@ -0,0 +1,161 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { Console, Effect, Path } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import { type BuildPaywallsResult, buildPaywalls } from "../../domain/services/paywall-build"; +import { + type UploadPaywallDeployResult, + uploadPaywallDeploy, +} from "../../domain/services/paywall-deploy-upload"; +import { SourceCode } from "../../domain/services/source-code"; +import { userError } from "../../utils/error-formatter"; + +const formatBytes = (bytes: number): string => + bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; + +const reportBuild = ({ manifest, outDir, manifestPath }: BuildPaywallsResult) => + Effect.gen(function* reportBuild() { + yield* Console.log( + `\nBuilt ${manifest.paywalls.length} paywall(s) and ` + + `${manifest.components.length} component(s) for ` + + `${manifest.team}/${manifest.project}:\n`, + ); + for (const paywall of manifest.paywalls) { + const size = paywall.artifacts.html.bytes + paywall.artifacts.js.bytes; + yield* Console.log( + ` • ${paywall.title} (${paywall.id})\n` + + ` hash ${paywall.contentHash.slice(0, 12)}\n` + + ` bundle ${formatBytes(size)}` + + (paywall.assets.length ? `, ${paywall.assets.length} asset(s)` : ""), + ); + } + for (const component of manifest.components) { + yield* Console.log( + ` • ${component.title ?? component.id} (${component.id}, component)\n` + + ` hash ${component.contentHash.slice(0, 12)}\n` + + ` runtime ${formatBytes(component.artifacts.runtime.bytes)}, ` + + `${component.previews.length} preview(s)` + + (component.artifacts.panel ? ", custom panel" : ""), + ); + } + yield* Console.log(`\n ${manifest.assets.length} asset(s)`); + yield* Console.log(` Output: ${outDir}`); + yield* Console.log(` Manifest: ${manifestPath}`); + }); + +const reportDeploy = (result: UploadPaywallDeployResult) => + Effect.gen(function* reportDeploy() { + yield* Console.log( + `\nDeploy ${result.deployId} is ${result.finalize.status} ` + + `(${result.uploadedCount} blob(s) uploaded, ${result.cachedCount} reused).`, + ); + if (result.finalize.paywalls.length > 0) { + yield* Console.log("\nPaywalls:"); + for (const paywall of result.finalize.paywalls) { + yield* Console.log(` • ${paywall.id} v${paywall.version}\n ${paywall.url}`); + } + } + if (result.finalize.components.length > 0) { + yield* Console.log("\nComponents:"); + for (const component of result.finalize.components) { + yield* Console.log(` • ${component.id} v${component.version}`); + } + } + }); + +/** + * `voidhash-cli deploy [--dry-run]` + * + * Builds every paywall and component in `.voidhash` into the content-addressed + * schemaVersion-2 deploy payload, then runs the contract-§4 upload flow: + * create the deploy from the manifest, upload missing blobs, finalize, and + * print the released paywall URLs/versions. `--dry-run` stops after the build. + */ +export const deployCommand = Command.make( + "deploy", + { + dryRun: Flag.boolean("dry-run").pipe( + Flag.withDescription("Build the deploy payload without uploading it"), + Flag.withDefault(false), + ), + }, + ({ dryRun }) => + Effect.gen(function* deployCommand() { + const sourceCode = yield* SourceCode; + const path = yield* Path.Path; + + const config = yield* sourceCode + .loadVoidhashConfig() + .pipe( + Effect.catchTag("VoidhashConfigNotFoundError", () => + Effect.fail( + userError("voidhash.config.ts not found. Run 'voidhash-cli init' to create one."), + ), + ), + ); + + const projectRoot = path.resolve("."); + const cliVersion = process.env.VOIDHASH_CLI_VERSION ?? "0.0.0"; + + // Best-effort: stamp the @voidhash/paywalls version the bundle was built + // against, resolved from the user's project. The package's exports map + // does not expose ./package.json, so walk up from the resolved entry. + const runtimeVersion = yield* Effect.try({ + try: () => { + const entry = require.resolve("@voidhash/paywalls", { + paths: [projectRoot], + }); + for (let dir = dirname(entry); dir !== dirname(dir); dir = dirname(dir)) { + const pkgPath = join(dir, "package.json"); + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { + name?: string; + version?: string; + }; + if (pkg.name === "@voidhash/paywalls" && typeof pkg.version === "string") { + return pkg.version; + } + } + } + throw new Error("@voidhash/paywalls package.json not found"); + }, + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => "unknown")); + + yield* Console.log("Building paywalls…"); + + const result = yield* buildPaywalls({ + cliVersion, + onWarn: (message) => Console.log(` Warning: ${message}`), + project: config.project, + projectRoot, + runtimeVersion, + team: config.team, + }).pipe( + Effect.catchTag("PaywallBuildError", (e) => + Effect.fail(userError(e.message)).pipe(Effect.tapError(() => Effect.logDebug(e.cause))), + ), + ); + + yield* reportBuild(result); + + if (dryRun) { + yield* Console.log("\nDry run — nothing uploaded."); + return; + } + + yield* Console.log("\nDeploying…"); + + const deployed = yield* uploadPaywallDeploy({ + manifest: result.manifest, + onProgress: (message) => Console.log(` ${message}`), + projectRoot, + }).pipe( + Effect.catchTag("PaywallDeployUploadError", (e) => + Effect.fail(userError(e.message)).pipe(Effect.tapError(() => Effect.logDebug(e.cause))), + ), + ); + + yield* reportDeploy(deployed); + }), +).pipe(Command.withDescription("Build and deploy paywalls to Voidhash.")); diff --git a/apps/cli/src/cli/commands/init.ts b/apps/cli/src/cli/commands/init.ts index acae5624d..ede69f50c 100644 --- a/apps/cli/src/cli/commands/init.ts +++ b/apps/cli/src/cli/commands/init.ts @@ -1,170 +1,169 @@ import { Command, Prompt } from "effect/unstable/cli"; import { Console, Effect, Path } from "effect"; -import { createInitialNormalizedSchema } from "../../domain/schema/normalized-schema"; +import { DEFAULT_TYPES_OUTPUT } from "../../domain/schema/voidhash-config"; import { Auth } from "../../domain/services/auth"; import { Codegen } from "../../domain/services/codegen"; +import { SchemaService } from "../../domain/services/schema"; import { SourceCode } from "../../domain/services/source-code"; import { ApiClient } from "../../utils/api-client"; import { userError } from "../../utils/error-formatter"; import { assertFileCanBeCreated } from "../../utils/fs"; import { selectOrganization } from "../../utils/organizations/select-organization"; import { selectProject } from "../../utils/projects/select-project"; -import { debugOption } from "../shared-options"; - -export const initCommand = Command.make("init", { debug: debugOption }, () => - Effect.gen(function* initCommand() { - const auth = yield* Auth; - const apiClient = yield* ApiClient; - const sourceCode = yield* SourceCode; - const codegen = yield* Codegen; - const path = yield* Path.Path; - - const voidhashConfig = yield* sourceCode - .loadVoidhashConfig() - .pipe( - Effect.catchTag("VoidhashConfigNotFoundError", () => - Effect.succeed(null), - ), - ); - - if (voidhashConfig) { - const shouldContinue = yield* Prompt.run( - Prompt.confirm({ - message: - "Voidhash was already initialized in this project. This will overwrite the existing configuration. Do you want to continue?", - }), - ); - if (!shouldContinue) { - return yield* Console.log("Initialization cancelled."); - } - yield* sourceCode.deleteVoidhashConfig(); - } - - // Sign in - const session = yield* auth.getSignedInSession - .pipe( - Effect.catchTag("NoSignedInUserError", () => - Effect.gen(function* session() { - const shouldContinue = yield* Prompt.run( - Prompt.confirm({ - message: - "You are not logged in. In the next step, we will open a browser window to sign you in. Do you want to continue?", - }), - ); - if (!shouldContinue) { - return yield* Effect.fail(userError("Login cancelled.")); - } - return yield* auth.login.pipe( - Effect.andThen(auth.getSignedInSession), - ); - }), - ), - ) - .pipe( - Effect.catchTags({ - FailedToGetSessionError: (e) => - Effect.fail( - userError("Failed to get user session. Please try again."), - ).pipe(Effect.tapError(() => Effect.logDebug(e))), - NoSignedInUserError: () => - Effect.fail( - userError("We were unable to sign you in. Please try it again."), - ), - // TODO: handle other errors - }), - ); - - // Select team - const organization = yield* selectOrganization(session.organizations); - - // Select project - const project = yield* selectProject( - organization.id, - session.projects.filter((p) => p.organizationId === organization.id), - ); - const apiKeys = (yield* apiClient.api_keys.listApiKeys()) as readonly { - id: string; - isPublic: boolean; - projectId: string; - rawKey?: string; - }[]; - const publishableApiKey = apiKeys.find( - (apiKey) => apiKey.isPublic && apiKey.projectId === project.id, - ); - const publishableKey = publishableApiKey?.rawKey; - if (!publishableKey) { - return yield* Effect.fail( - userError( - "Could not retrieve raw publishable key from listApiKeys for the selected project.", - ), - ); - } - if (!publishableKey.startsWith("vh_pk_")) { - return yield* Effect.fail( - userError( - "Received an invalid publishable key from listApiKeys for the selected project.", - ), - ); - } - - // Select folder path - - const srcFolderPath = yield* sourceCode.retrieveSrcDir(); - const hasSrcDir = srcFolderPath.endsWith("src"); - - const voidhashFilesFolderPath = yield* Prompt.run( - Prompt.text({ - default: hasSrcDir ? "./src/utils/voidhash" : "./utils/voidhash", - message: - "Select the folder where you want to create the Voidhash schema and client", - }), - ); - - // File names - const language = yield* sourceCode.detectSrcLanguage(); - const schemaFileName = language === "ts" ? "schema.ts" : "schema.js"; - const clientFileName = language === "ts" ? "client.ts" : "client.js"; - const configFileName = - language === "ts" ? "voidhash.config.ts" : "voidhash.config.js"; - - // File paths - const schemaFilePath = path.resolve( - voidhashFilesFolderPath, - schemaFileName, - ); - const clientFilePath = path.resolve( - voidhashFilesFolderPath, - clientFileName, - ); - const configFilePath = path.resolve(configFileName); - - // Assert files can be created - yield* assertFileCanBeCreated(schemaFileName, schemaFilePath); - yield* assertFileCanBeCreated(clientFileName, clientFilePath); - yield* assertFileCanBeCreated(configFileName, configFilePath); - - // Generate files - yield* codegen.generateVoidhashConfigFile(configFilePath, { - project: project.slug, - schema: path.relative(path.resolve(), schemaFilePath), - team: organization.slug, - }); - - // Generate initial schema file - const initialSchema = createInitialNormalizedSchema(); - yield* codegen.generateSchemaFile(schemaFilePath, initialSchema); - yield* codegen.generateClientFile(clientFilePath, publishableKey); - - yield* Console.log("\nVoidhash initialized successfully!"); - yield* Console.log(` Config: ${configFilePath}`); - yield* Console.log(` Schema: ${schemaFilePath}`); - yield* Console.log(` Client: ${clientFilePath}`); - yield* Console.log(`\nNext steps:`); - yield* Console.log(` 1. Add your products and perks to the schema file.`); - yield* Console.log(` 2. Use the generated client in your app code.`); - yield* Console.log( - ` 3. Run \`voidhash-cli schema push\` to push your schema to the server.`, - ); - }), + +/** + * `voidhash-cli init` + * + * One-time setup: authenticate, select team/project, write `voidhash.config.ts` + * at the project root, and produce the initial `voidhash.gen.d.ts` so type + * autocomplete works immediately. + * + * Schema files are no longer generated — the dashboard is the source of truth + * after the server-first redesign. Likewise the client file is the user's to + * write (it's two lines now: import + `createVoidhashClient`). + */ +export const initCommand = Command.make("init", {}, () => + Effect.gen(function* initCommand() { + const auth = yield* Auth; + const apiClient = yield* ApiClient; + const sourceCode = yield* SourceCode; + const codegen = yield* Codegen; + const schemaService = yield* SchemaService; + const path = yield* Path.Path; + + const voidhashConfig = yield* sourceCode + .loadVoidhashConfig() + .pipe(Effect.catchTag("VoidhashConfigNotFoundError", () => Effect.succeed(null))); + + if (voidhashConfig) { + const shouldContinue = yield* Prompt.run( + Prompt.confirm({ + message: + "Voidhash was already initialized in this project. This will overwrite the existing configuration. Do you want to continue?", + }), + ); + if (!shouldContinue) { + return yield* Console.log("Initialization cancelled."); + } + yield* sourceCode.deleteVoidhashConfig(); + } + + // Sign in + const session = yield* auth.getSignedInSession + .pipe( + Effect.catchTag("NoSignedInUserError", () => + Effect.gen(function* session() { + const shouldContinue = yield* Prompt.run( + Prompt.confirm({ + message: + "You are not logged in. In the next step, we will open a browser window to sign you in. Do you want to continue?", + }), + ); + if (!shouldContinue) { + return yield* Effect.fail(userError("Login cancelled.")); + } + return yield* auth.login.pipe(Effect.andThen(auth.getSignedInSession)); + }), + ), + ) + .pipe( + Effect.catchTags({ + FailedToGetSessionError: (e) => + Effect.fail(userError("Failed to get user session. Please try again.")).pipe( + Effect.tapError(() => Effect.logDebug(e)), + ), + NoSignedInUserError: () => + Effect.fail(userError("We were unable to sign you in. Please try it again.")), + }), + ); + + // Select team + const organization = yield* selectOrganization(session.organizations); + + // Select project + const project = yield* selectProject( + organization.id, + session.projects.filter((p) => p.organizationId === organization.id), + ); + + // Sanity-check that a publishable key exists for this project; we don't + // need to write it anywhere (the user puts it in their app code), but a + // missing key is a configuration problem we should surface now. + const apiKeys = (yield* apiClient.apiKeysListApiKeys()) as readonly { + id: string; + isPublic: boolean; + projectId: string; + rawKey?: string; + }[]; + const publishableApiKey = apiKeys.find( + (apiKey) => apiKey.isPublic && apiKey.projectId === project.id, + ); + if (!publishableApiKey?.rawKey?.startsWith("vh_pk_")) { + return yield* Effect.fail( + userError("Could not retrieve a valid publishable key for the selected project."), + ); + } + const publishableKey = publishableApiKey.rawKey; + + // Decide where the generated `.d.ts` lives. We default to the project + // root since module augmentation works from anywhere in `tsconfig.include`. + const language = yield* sourceCode.detectSrcLanguage(); + const configFileName = language === "ts" ? "voidhash.config.ts" : "voidhash.config.js"; + + const configFilePath = path.resolve(configFileName); + const typesOutputPath = path.resolve(DEFAULT_TYPES_OUTPUT); + + // Scaffold the SDK client into `src/lib` (or `lib` when there's no `src`), + // matching the project's `src` layout and language. + const srcDir = yield* sourceCode.retrieveSrcDir(); + const clientFileName = language === "ts" ? "voidhash.ts" : "voidhash.js"; + const clientFilePath = path.join(srcDir, "lib", clientFileName); + + yield* assertFileCanBeCreated(configFileName, configFilePath); + yield* assertFileCanBeCreated(DEFAULT_TYPES_OUTPUT, typesOutputPath); + yield* assertFileCanBeCreated(clientFileName, clientFilePath); + + // Write the config. `typesOutput` is omitted from the generated file so + // it picks up the default (`voidhash.gen.d.ts`) — keeps the config terse. + yield* codegen.generateVoidhashConfigFile(configFilePath, { + project: project.slug, + team: organization.slug, + }); + + // Scaffold the SDK client with the publishable key pre-filled so the user + // has a working import target on the first run. + yield* codegen.generateClientFile(clientFilePath, { publishableKey }); + + // Produce the initial declaration file so the user has working + // autocomplete on the first run. Failures here are non-fatal — the user + // can re-run `voidhash-cli types generate` later. + const generatedVersion = yield* schemaService.fetchRemoteSchema().pipe( + Effect.flatMap(({ schema, version }) => + codegen.generateTypesDeclarationFile(typesOutputPath, schema, version), + ), + Effect.catch((e) => + Effect.logWarning( + `Failed to generate initial types: ${String(e)}. You can run 'voidhash-cli types generate' later.`, + ).pipe(Effect.as(null)), + ), + ); + + yield* Console.log("\nVoidhash initialized successfully!"); + yield* Console.log(` Config: ${configFilePath}`); + yield* Console.log(` Client: ${clientFilePath}`); + if (generatedVersion !== null) { + yield* Console.log(` Types: ${typesOutputPath}`); + } + yield* Console.log(`\nNext steps:`); + yield* Console.log( + ` 1. Wrap your app with from ${path.relative(".", clientFilePath)}.`, + ); + yield* Console.log( + ` 2. Create your products and paywall locations in the Voidhash dashboard.`, + ); + yield* Console.log( + ` 3. Re-run 'voidhash-cli types generate' whenever the dashboard schema changes.`, + ); + }), ).pipe(Command.withDescription("Initialize a new Voidhash project.")); diff --git a/apps/cli/src/cli/commands/schema-check.ts b/apps/cli/src/cli/commands/schema-check.ts deleted file mode 100644 index 717da7683..000000000 --- a/apps/cli/src/cli/commands/schema-check.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Command } from "effect/unstable/cli"; -import { Console, Effect } from "effect"; - -import { - MissingProviderConfigurationError, - SchemaCheckFailedError, -} from "../../domain/errors/schema"; -import { Auth } from "../../domain/services/auth"; -import { SchemaService } from "../../domain/services/schema"; -import { SourceCode } from "../../domain/services/source-code"; -import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; - -export const schemaCheckCommand = Command.make("check", { debug: debugOption }, () => - Effect.gen(function* schemaCheckCommand() { - const auth = yield* Auth; - const sourceCode = yield* SourceCode; - const schemaService = yield* SchemaService; - - // Authenticate - yield* auth.getSignedInSession.pipe( - Effect.catchTag("NoSignedInUserError", () => - Effect.fail( - userError( - "You must be logged in to check schema. Run 'voidhash auth login' first." - ) - ) - ) - ); - - // Load voidhash.config - const config = yield* sourceCode.loadVoidhashConfig().pipe( - Effect.catchTag("VoidhashConfigNotFoundError", () => - Effect.fail( - userError("voidhash.config.ts not found. Run 'voidhash init' to create one.") - ) - ) - ); - - // Load local schema - yield* Console.log("Loading local schema..."); - const localSchema = yield* schemaService.loadLocalSchema(config.schema).pipe( - Effect.catchTag("LocalSchemaNotFoundError", (e) => - Effect.fail(userError(`Schema file not found: ${e.path}`)) - ), - Effect.catchTag("LocalSchemaParseError", (e) => - Effect.fail(userError(`Failed to parse schema: ${e.message}`)) - ) - ); - - yield* Console.log(` Found ${localSchema.locations.size} paywall locations`); - yield* Console.log(` Found ${localSchema.perks.size} perks`); - yield* Console.log(` Found ${localSchema.products.size} products`); - yield* Console.log( - ` Providers: ${[...localSchema.enabledProviders].join(", ") || "none"}` - ); - - // Fetch remote schema - yield* Console.log("\nFetching remote schema..."); - const remoteSchema = yield* schemaService.fetchRemoteSchema().pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail(userError(`Failed to fetch remote schema: ${String(e.cause)}`)) - ) - ); - - yield* Console.log(` Found ${remoteSchema.locations.size} paywall locations`); - yield* Console.log(` Found ${remoteSchema.perks.size} perks`); - yield* Console.log(` Found ${remoteSchema.products.size} products`); - - // Check provider configurations - const providerConfigs = yield* schemaService.fetchProviderConfigurations().pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError(`Failed to fetch provider configurations: ${String(e.cause)}`) - ) - ) - ); - - const missingProviders = schemaService.checkProviderConfigurations( - localSchema.enabledProviders, - providerConfigs - ); - - if (missingProviders.length > 0) { - yield* Console.log("\nMissing payment provider configurations:"); - for (const provider of missingProviders) { - yield* Console.log(` - ${provider}`); - } - yield* Console.log( - "\nPlease configure these providers in the dashboard before pushing." - ); - return yield* Effect.fail( - new MissingProviderConfigurationError({ providers: missingProviders }) - ); - } - - // Compute diff - const diff = schemaService.computeDiff(localSchema, remoteSchema); - const summary = schemaService.summarizeDiff(diff); - - // Check results - const issues: string[] = []; - - if (diff.locations.toCreate.length > 0) { - issues.push(`${diff.locations.toCreate.length} paywall locations missing from server`); - yield* Console.log("\nMissing paywall locations:"); - for (const location of diff.locations.toCreate) { - yield* Console.log(` + ${location.slug} ("${location.name}")`); - } - } - - if (diff.locations.toUpdate.length > 0) { - issues.push(`${diff.locations.toUpdate.length} paywall locations need updating`); - yield* Console.log("\nOutdated paywall locations:"); - for (const { local, remote } of diff.locations.toUpdate) { - yield* Console.log(` ~ ${local.slug}: "${remote.name}" -> "${local.name}"`); - } - } - - if (diff.locations.toArchive.length > 0) { - issues.push(`${diff.locations.toArchive.length} paywall locations should be archived`); - yield* Console.log("\nPaywall locations to archive (remote-only):"); - for (const location of diff.locations.toArchive) { - yield* Console.log(` - ${location.slug} ("${location.name}")`); - } - } - - if (diff.perks.toCreate.length > 0) { - issues.push(`${diff.perks.toCreate.length} perks missing from server`); - yield* Console.log("\nMissing perks:"); - for (const perk of diff.perks.toCreate) { - yield* Console.log(` + ${perk.slug} ("${perk.name}")`); - } - } - - if (diff.products.toCreate.length > 0) { - issues.push(`${diff.products.toCreate.length} products missing from server`); - yield* Console.log("\nMissing products:"); - for (const product of diff.products.toCreate) { - yield* Console.log(` + ${product.slug} ("${product.name}")`); - } - } - - if (diff.perks.toUpdate.length > 0) { - issues.push(`${diff.perks.toUpdate.length} perks need updating`); - yield* Console.log("\nOutdated perks:"); - for (const { local, remote } of diff.perks.toUpdate) { - yield* Console.log(` ~ ${local.slug}: "${remote.name}" -> "${local.name}"`); - } - } - - if (diff.products.toUpdate.length > 0) { - issues.push(`${diff.products.toUpdate.length} products need updating`); - yield* Console.log("\nOutdated products:"); - for (const { local, remote } of diff.products.toUpdate) { - yield* Console.log(` ~ ${local.slug}: "${remote.name}" -> "${local.name}"`); - } - } - - if (issues.length === 0) { - yield* Console.log( - "\n\u2713 All local schema entities exist on the server and are up to date." - ); - return; - } - - yield* Console.log(`\n\u2717 Schema check failed: ${issues.join(", ")}`); - yield* Console.log("\nRun 'voidhash schema push' to sync these changes."); - - return yield* Effect.fail( - new SchemaCheckFailedError({ - message: `Schema check failed: ${issues.join(", ")}`, - }) - ); - }).pipe( - Effect.catchTags({ - MissingProviderConfigurationError: () => Effect.void, - SchemaCheckFailedError: () => Effect.void, - }) - ) -).pipe(Command.withDescription("Check that the server contains all local schema entities.")); diff --git a/apps/cli/src/cli/commands/schema-pull.ts b/apps/cli/src/cli/commands/schema-pull.ts deleted file mode 100644 index 2374afefd..000000000 --- a/apps/cli/src/cli/commands/schema-pull.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Command, Flag, Prompt } from "effect/unstable/cli"; -import { Console, Effect, Path } from "effect"; - -import { Auth } from "../../domain/services/auth"; -import { Codegen } from "../../domain/services/codegen"; -import { SchemaService } from "../../domain/services/schema"; -import { SourceCode } from "../../domain/services/source-code"; -import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; - -export const schemaPullCommand = Command.make( - "pull", - { - debug: debugOption, - force: Flag.boolean("force").pipe( - Flag.withDescription("Skip confirmation prompt"), - Flag.withDefault(false) - ), - }, - ({ force }) => - Effect.gen(function* schemaPullCommand() { - const auth = yield* Auth; - const sourceCode = yield* SourceCode; - const schemaService = yield* SchemaService; - const codegen = yield* Codegen; - const pathService = yield* Path.Path; - - // Authenticate - yield* auth.getSignedInSession.pipe( - Effect.catchTag("NoSignedInUserError", () => - Effect.fail( - userError( - "You must be logged in to pull schema. Run 'voidhash auth login' first." - ) - ) - ) - ); - - // Load voidhash.config - const config = yield* sourceCode.loadVoidhashConfig().pipe( - Effect.catchTag("VoidhashConfigNotFoundError", () => - Effect.fail( - userError( - "voidhash.config.ts not found. Run 'voidhash init' to create one." - ) - ) - ) - ); - - // Fetch remote schema - yield* Console.log("Fetching remote schema..."); - const remoteSchema = yield* schemaService.fetchRemoteSchema().pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError(`Failed to fetch remote schema: ${String(e.cause)}`) - ) - ) - ); - - // Display summary - yield* Console.log(`\nRemote schema contains:`); - yield* Console.log(` ${remoteSchema.locations.size} paywall locations`); - yield* Console.log(` ${remoteSchema.perks.size} perks`); - yield* Console.log(` ${remoteSchema.products.size} products`); - yield* Console.log( - ` Providers: ${[...remoteSchema.enabledProviders].join(", ") || "none"}` - ); - - if ( - remoteSchema.locations.size === 0 && - remoteSchema.perks.size === 0 && - remoteSchema.products.size === 0 - ) { - yield* Console.log( - "\nRemote schema is empty. Nothing to pull." - ); - return; - } - - // Confirm unless --force - if (!force) { - const confirmed = yield* Prompt.run( - Prompt.confirm({ - message: `This will overwrite ${config.schema}. Continue?`, - }) - ); - if (!confirmed) { - yield* Console.log("Pull cancelled."); - return; - } - } - - // Generate schema file - const schemaPath = pathService.resolve(config.schema); - yield* codegen.generateSchemaFile(schemaPath, remoteSchema); - - yield* Console.log(`\n\u2713 Schema pulled to ${config.schema}`); - }) -).pipe(Command.withDescription("Pull the Voidhash schema from the server.")); diff --git a/apps/cli/src/cli/commands/schema-push.ts b/apps/cli/src/cli/commands/schema-push.ts deleted file mode 100644 index a77414181..000000000 --- a/apps/cli/src/cli/commands/schema-push.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { Command, Flag, Prompt } from "effect/unstable/cli"; -import { Console, Effect } from "effect"; - -import { MissingProviderConfigurationError } from "../../domain/errors/schema"; -import { Auth } from "../../domain/services/auth"; -import { SchemaService, formatChange } from "../../domain/services/schema"; -import { SourceCode } from "../../domain/services/source-code"; -import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; - -export const schemaPushCommand = Command.make( - "push", - { - debug: debugOption, - dryRun: Flag.boolean("dry-run").pipe( - Flag.withDescription("Preview changes without applying"), - Flag.withDefault(false) - ), - yes: Flag.boolean("yes").pipe( - Flag.withAlias("y"), - Flag.withDescription("Auto-approve all changes"), - Flag.withDefault(false) - ), - }, - ({ dryRun, yes }) => - Effect.gen(function* schemaPushCommand() { - const auth = yield* Auth; - const sourceCode = yield* SourceCode; - const schemaService = yield* SchemaService; - - // Authenticate - yield* auth.getSignedInSession.pipe( - Effect.catchTag("NoSignedInUserError", () => - Effect.fail( - userError( - "You must be logged in to push schema. Run 'voidhash auth login' first." - ) - ) - ) - ); - - // Load voidhash.config - const config = yield* sourceCode - .loadVoidhashConfig() - .pipe( - Effect.catchTag("VoidhashConfigNotFoundError", () => - Effect.fail( - userError( - "voidhash.config.ts not found. Run 'voidhash init' to create one." - ) - ) - ) - ); - - // Load local schema - yield* Console.log("Loading local schema..."); - const localSchema = yield* schemaService - .loadLocalSchema(config.schema) - .pipe( - Effect.catchTag("LocalSchemaNotFoundError", (e) => - Effect.fail(userError(`Schema file not found: ${e.path}`)) - ), - Effect.catchTag("LocalSchemaParseError", (e) => - Effect.fail(userError(`Failed to parse schema: ${e.message}`)) - ) - ); - - yield* Console.log(` Found ${localSchema.locations.size} paywall locations`); - yield* Console.log(` Found ${localSchema.perks.size} perks`); - yield* Console.log(` Found ${localSchema.products.size} products`); - - // Fetch remote schema - yield* Console.log("\nFetching remote schema..."); - const remoteSchema = yield* schemaService - .fetchRemoteSchema() - .pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError(`Failed to fetch remote schema: ${String(e.cause)}`) - ) - ) - ); - - yield* Console.log(` Found ${remoteSchema.locations.size} paywall locations`); - yield* Console.log(` Found ${remoteSchema.perks.size} perks`); - yield* Console.log(` Found ${remoteSchema.products.size} products`); - - // Check provider configurations FIRST - const providerConfigs = yield* schemaService - .fetchProviderConfigurations() - .pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError( - `Failed to fetch provider configurations: ${String(e.cause)}` - ) - ) - ) - ); - - const missingProviders = schemaService.checkProviderConfigurations( - localSchema.enabledProviders, - providerConfigs - ); - - if (missingProviders.length > 0) { - yield* Console.log( - "\nCannot push: Missing payment provider configurations:" - ); - for (const provider of missingProviders) { - yield* Console.log(` - ${provider}`); - } - yield* Console.log( - "\nPlease configure these providers in the dashboard first." - ); - return yield* Effect.fail( - new MissingProviderConfigurationError({ providers: missingProviders }) - ); - } - - // Compute diff - const diff = schemaService.computeDiff(localSchema, remoteSchema); - - // Build changeset (creates + updates only) - const changeset = schemaService.buildChangeset(diff); - - if (changeset.changes.length === 0) { - yield* Console.log( - "\n\u2713 Schema is already in sync. Nothing to push." - ); - return; - } - - // Display changes - yield* Console.log(`\n${changeset.changes.length} changes to push:\n`); - for (const change of changeset.changes) { - yield* Console.log(` ${formatChange(change)}`); - } - - if (dryRun) { - yield* Console.log("\n(Dry run - no changes applied)"); - return; - } - - // Collect approved changes - const approvedChanges: (typeof changeset.changes)[number][] = []; - - for (const change of changeset.changes) { - yield* Console.log(`\n${formatChange(change)}`); - - const approved = - yes || - (yield* Prompt.run( - Prompt.confirm({ initial: true, message: "Apply this change?" }) - )); - - if (approved) { - approvedChanges.push(change); - yield* Console.log(" \u2713 Approved"); - } else { - yield* Console.log(" \u2298 Skipped"); - } - } - - const skipped = changeset.changes.length - approvedChanges.length; - - if (approvedChanges.length === 0) { - yield* Console.log("\nNo changes to apply."); - return; - } - - // Deploy all approved changes at once - yield* Console.log(`\nDeploying ${approvedChanges.length} changes...`); - const result = yield* schemaService - .deployChangeset({ changes: approvedChanges }) - .pipe( - Effect.map(() => true), - Effect.catchTag("ChangeDeploymentError", (e) => - Effect.succeed(false).pipe( - Effect.tap(() => - Console.log(`\u2717 Deployment failed: ${String(e.cause)}`) - ) - ) - ) - ); - - if (result) { - yield* Console.log( - `\n\u2713 Push complete: ${approvedChanges.length} applied, ${skipped} skipped` - ); - } else { - yield* Console.log( - `\n\u2717 Push failed: 0 applied, ${skipped} skipped` - ); - } - }).pipe( - Effect.catchTags({ - MissingProviderConfigurationError: () => Effect.void, - }) - ) -).pipe( - Command.withDescription("Push the local Voidhash schema to the server.") -); diff --git a/apps/cli/src/cli/commands/schema.ts b/apps/cli/src/cli/commands/schema.ts deleted file mode 100644 index b2094019c..000000000 --- a/apps/cli/src/cli/commands/schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Command } from "effect/unstable/cli"; -import { Effect } from "effect"; - -import { debugOption } from "../shared-options"; -import { schemaCheckCommand } from "./schema-check"; -import { schemaPullCommand } from "./schema-pull"; -import { schemaPushCommand } from "./schema-push"; - -export const schemaCommand = Command.make("schema", { debug: debugOption }, () => - Effect.gen(function* schemaCommand() {}) -).pipe( - Command.withDescription("Manage the Voidhash schema."), - Command.withSubcommands([ - schemaPullCommand, - schemaPushCommand, - schemaCheckCommand, - ]) -); diff --git a/apps/cli/src/cli/commands/studio.ts b/apps/cli/src/cli/commands/studio.ts new file mode 100644 index 000000000..881bd6f5b --- /dev/null +++ b/apps/cli/src/cli/commands/studio.ts @@ -0,0 +1,115 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { Console, Effect, FileSystem, Path } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; + +import { userError } from "../../utils/error-formatter"; + +const DEFAULT_PORT = 4830; + +/** Resolves the installed Studio app directory and the Vite CLI entry point. */ +const resolveStudioPaths = () => + Effect.try({ + try: () => { + // `require.resolve` works both in the bundled CJS binary and under tsx in + // development. We resolve the package manifest to get the app root, and + // Vite's own CLI entry so we can launch it without depending on bin + // shims being hoisted in any particular way. + const studioDir = dirname(require.resolve("@voidhash/studio/package.json")); + // Resolve Vite via its package.json (an exported subpath) from the Studio + // package, then join the CLI entry — `vite/bin/vite.js` is not an exported + // subpath, so it can't be resolved directly under Node's exports rules. + const viteDir = dirname(require.resolve("vite/package.json", { paths: [studioDir] })); + const viteBin = join(viteDir, "bin", "vite.js"); + return { studioDir, viteBin }; + }, + catch: () => + userError("Could not locate the Voidhash Studio app. Reinstall the CLI and try again."), + }); + +/** Best-effort: open the given URL in the user's default browser. */ +const openBrowser = (url: string): void => { + const command = + process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + try { + spawn(command, args, { stdio: "ignore", detached: true }).unref(); + } catch { + // Opening the browser is a convenience; never fail the command over it. + } +}; + +/** + * `voidhash-cli studio [--port] [--no-open]` + * + * Launches the paywall preview Studio (a Vite app) against the current project. + * The project's `.voidhash` folder is the source of truth; the Studio process is + * pointed at it via the `VOIDHASH_PROJECT_ROOT` env var. Runs until interrupted + * (Ctrl+C), at which point the Vite child process is terminated. + */ +export const studioCommand = Command.make( + "studio", + { + port: Flag.integer("port").pipe( + Flag.withAlias("p"), + Flag.withDescription("Port for the Studio dev server"), + Flag.withDefault(DEFAULT_PORT), + ), + open: Flag.boolean("open").pipe( + Flag.withDescription("Open Studio in your browser once it starts"), + Flag.withDefault(true), + ), + }, + ({ port, open }) => + Effect.gen(function* studioCommand() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const projectRoot = path.resolve("."); + const voidhashDir = path.join(projectRoot, ".voidhash"); + + const hasVoidhash = yield* fs.exists(voidhashDir); + if (!hasVoidhash) { + yield* Console.warn( + `No .voidhash folder found in ${projectRoot}.\n` + + "Studio will start, but there are no paywalls to preview yet.\n" + + "Create .voidhash/paywalls/.tsx to get started.\n", + ); + } + + const { studioDir, viteBin } = yield* resolveStudioPaths(); + const url = `http://localhost:${port}`; + + yield* Console.log("\n Voidhash Studio"); + yield* Console.log(` Project: ${projectRoot}`); + yield* Console.log(` Preview: ${url}\n`); + + // Spawn Vite, keep the command alive until the child exits, and ensure the + // child is terminated if the fiber is interrupted (Ctrl+C). + yield* Effect.acquireUseRelease( + Effect.sync(() => + spawn(process.execPath, [viteBin, "--port", String(port), "--strictPort"], { + cwd: studioDir, + env: { ...process.env, VOIDHASH_PROJECT_ROOT: projectRoot }, + stdio: "inherit", + }), + ), + (child: ChildProcess) => { + if (open) { + // Give Vite a moment to bind the port before opening the browser. + setTimeout(() => openBrowser(url), 1500); + } + return Effect.callback((resume) => { + child.on("exit", () => resume(Effect.void)); + child.on("error", (error) => resume(Effect.die(error))); + }); + }, + (child: ChildProcess) => + Effect.sync(() => { + if (child.exitCode === null && !child.killed) { + child.kill("SIGTERM"); + } + }), + ); + }), +).pipe(Command.withDescription("Launch the paywall preview Studio for this project.")); diff --git a/apps/cli/src/cli/commands/types-check.ts b/apps/cli/src/cli/commands/types-check.ts new file mode 100644 index 000000000..49d10acee --- /dev/null +++ b/apps/cli/src/cli/commands/types-check.ts @@ -0,0 +1,99 @@ +import { Command } from "effect/unstable/cli"; +import { Console, Effect, Path } from "effect"; + +import { SchemaCheckFailedError } from "../../domain/errors/schema"; +import { resolveTypesOutput } from "../../domain/schema/voidhash-config"; +import { Auth } from "../../domain/services/auth"; +import { Codegen } from "../../domain/services/codegen"; +import { SchemaService } from "../../domain/services/schema"; +import { SourceCode } from "../../domain/services/source-code"; +import { userError } from "../../utils/error-formatter"; + +/** + * `voidhash-cli types check` + * + * CI gate. Compares the `@voidhash:version` header inside the local + * `voidhash.gen.d.ts` against the server's current schema version. Exits + * non-zero with a clear message when they diverge, so a stale commit can't + * pass PR CI. + */ +export const typesCheckCommand = Command.make("check", {}, () => + Effect.gen(function* typesCheckCommand() { + const auth = yield* Auth; + const sourceCode = yield* SourceCode; + const schemaService = yield* SchemaService; + const codegen = yield* Codegen; + const pathService = yield* Path.Path; + + yield* auth.getSignedInSession.pipe( + Effect.catchTag("NoSignedInUserError", () => + Effect.fail( + userError("You must be logged in to check types. Run 'voidhash-cli auth login' first."), + ), + ), + ); + + const config = yield* sourceCode + .loadVoidhashConfig() + .pipe( + Effect.catchTag("VoidhashConfigNotFoundError", () => + Effect.fail( + userError("voidhash.config.ts not found. Run 'voidhash-cli init' to create one."), + ), + ), + ); + + const typesOutput = resolveTypesOutput(config); + const outPath = pathService.resolve(typesOutput); + + const localVersion = yield* codegen + .readDeclarationVersion(outPath) + .pipe( + Effect.catch(() => + Effect.fail( + userError( + `Could not read generated types at ${typesOutput}. Run 'voidhash-cli types generate' first.`, + ), + ), + ), + ); + + if (localVersion === null) { + return yield* Effect.fail( + userError( + `${typesOutput} is missing the @voidhash:version header. Re-run 'voidhash-cli types generate' to regenerate.`, + ), + ); + } + + const remoteVersion = yield* schemaService + .fetchSchemaVersion() + .pipe( + Effect.catchTag("RemoteSchemaFetchError", (e) => + Effect.fail(userError(`Failed to fetch remote schema version: ${String(e.cause)}`)), + ), + ); + + if (localVersion === remoteVersion) { + yield* Console.log(`✓ Types are up to date (version ${localVersion.slice(0, 19)}...)`); + return; + } + + yield* Console.log("✗ Types are stale."); + yield* Console.log(` Local: ${localVersion}`); + yield* Console.log(` Server: ${remoteVersion}`); + yield* Console.log( + "\nRun 'voidhash-cli types generate' to refresh, then commit the updated declaration file.", + ); + + return yield* Effect.fail( + new SchemaCheckFailedError({ + message: `Local types version ${localVersion} does not match server version ${remoteVersion}`, + }), + ); + }), +).pipe( + Command.withDescription( + "Check whether the locally generated types are in sync with the server schema.", + ), +); diff --git a/apps/cli/src/cli/commands/types-generate.ts b/apps/cli/src/cli/commands/types-generate.ts new file mode 100644 index 000000000..cac16c964 --- /dev/null +++ b/apps/cli/src/cli/commands/types-generate.ts @@ -0,0 +1,128 @@ +import { Command, Flag } from "effect/unstable/cli"; +import { Console, Effect, Path, Schedule } from "effect"; + +import { resolveTypesOutput } from "../../domain/schema/voidhash-config"; +import { Auth } from "../../domain/services/auth"; +import { Codegen } from "../../domain/services/codegen"; +import { SchemaService } from "../../domain/services/schema"; +import { SourceCode } from "../../domain/services/source-code"; +import { userError } from "../../utils/error-formatter"; + +/** + * `voidhash-cli types generate [--watch]` + * + * Fetches the schema from the server and emits `voidhash.gen.d.ts` (path + * configurable via `voidhash.config.ts#typesOutput`). With `--watch`, polls + * `GET /schema/version` every `pollIntervalMs` (default 5s) and regenerates + * on hash change. + */ +export const typesGenerateCommand = Command.make( + "generate", + { + pollIntervalMs: Flag.integer("poll-interval-ms").pipe( + Flag.withDescription("Polling interval for --watch mode, in milliseconds"), + Flag.withDefault(5000), + ), + watch: Flag.boolean("watch").pipe( + Flag.withDescription("Watch the server schema and regenerate types when it changes"), + Flag.withDefault(false), + ), + }, + ({ pollIntervalMs, watch }) => + Effect.gen(function* typesGenerateCommand() { + const auth = yield* Auth; + const sourceCode = yield* SourceCode; + const schemaService = yield* SchemaService; + const codegen = yield* Codegen; + const pathService = yield* Path.Path; + + yield* auth.getSignedInSession.pipe( + Effect.catchTag("NoSignedInUserError", () => + Effect.fail( + userError( + "You must be logged in to generate types. Run 'voidhash-cli auth login' first.", + ), + ), + ), + ); + + const config = yield* sourceCode + .loadVoidhashConfig() + .pipe( + Effect.catchTag("VoidhashConfigNotFoundError", () => + Effect.fail( + userError("voidhash.config.ts not found. Run 'voidhash-cli init' to create one."), + ), + ), + ); + + const typesOutput = resolveTypesOutput(config); + const outPath = pathService.resolve(typesOutput); + + const regenerate = Effect.gen(function* regenerate() { + yield* Console.log("Fetching remote schema..."); + const { schema, version } = yield* schemaService + .fetchRemoteSchema() + .pipe( + Effect.catchTag("RemoteSchemaFetchError", (e) => + Effect.fail(userError(`Failed to fetch remote schema: ${String(e.cause)}`)), + ), + ); + + yield* codegen.generateTypesDeclarationFile(outPath, schema, version); + + yield* Console.log( + `✓ Types written to ${typesOutput} (version ${version.slice(0, 19)}...)`, + ); + return version; + }); + + const initialVersion = yield* regenerate; + + if (!watch) { + return; + } + + yield* Console.log( + `\nWatching for schema changes (poll every ${pollIntervalMs}ms). Press Ctrl+C to stop.`, + ); + + // Keep the last known version in a closure-shared ref so the poll loop + // can compare and skip work when the schema hasn't changed. + let lastVersion = initialVersion; + + const pollOnce = Effect.gen(function* pollOnce() { + const latest = yield* schemaService + .fetchSchemaVersion() + .pipe( + Effect.catch((e) => + Effect.logWarning( + `[voidhash-cli types --watch] Skipping poll due to error: ${String(e)}`, + ).pipe(Effect.as(null)), + ), + ); + + if (latest === null || latest === lastVersion) { + return; + } + + yield* Console.log("Schema changed on server — regenerating types..."); + const next = yield* regenerate.pipe( + Effect.catch((e) => + Effect.logWarning( + `[voidhash-cli types --watch] Regeneration failed: ${String(e)}`, + ).pipe(Effect.as(null)), + ), + ); + if (next !== null) { + lastVersion = next; + } + }); + + yield* Effect.repeat(pollOnce, Schedule.spaced(`${pollIntervalMs} millis`)); + }), +).pipe( + Command.withDescription( + "Generate the voidhash.gen.d.ts declaration file from the server schema.", + ), +); diff --git a/apps/cli/src/cli/commands/types.ts b/apps/cli/src/cli/commands/types.ts new file mode 100644 index 000000000..fccced206 --- /dev/null +++ b/apps/cli/src/cli/commands/types.ts @@ -0,0 +1,12 @@ +import { Command } from "effect/unstable/cli"; +import { Effect } from "effect"; + +import { typesCheckCommand } from "./types-check"; +import { typesGenerateCommand } from "./types-generate"; + +export const typesCommand = Command.make("types", {}, () => + Effect.gen(function* typesCommand() {}), +).pipe( + Command.withDescription("Generate and validate the Voidhash TypeScript declaration file."), + Command.withSubcommands([typesGenerateCommand, typesCheckCommand]), +); diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index 129ca0390..bf2349d25 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -1,6 +1,6 @@ -import { Command } from "effect/unstable/cli"; -import { NodeServices, NodeRuntime } from "@effect/platform-node"; +import { NodeRuntime, NodeServices } from "@effect/platform-node"; import { Effect, Layer, References } from "effect"; +import { Command } from "effect/unstable/cli"; import { FetchHttpClient } from "effect/unstable/http"; import { Auth } from "../domain/services/auth"; @@ -9,23 +9,29 @@ import { Codegen } from "../domain/services/codegen"; import { SchemaService } from "../domain/services/schema"; import { SourceCode } from "../domain/services/source-code"; import { ApiClient } from "../utils/api-client"; -import { - isDebugMode, - withValidationErrorHandler, -} from "../utils/error-formatter"; +import { isDebugMode, withValidationErrorHandler } from "../utils/error-formatter"; import { authCommand } from "./commands/auth"; import { configCommand } from "./commands/config"; +import { deployCommand } from "./commands/deploy"; import { initCommand } from "./commands/init"; -import { schemaCommand } from "./commands/schema"; +import { studioCommand } from "./commands/studio"; +import { typesCommand } from "./commands/types"; +import { debugOption, profileOption } from "./shared-options"; -const command = Command.make("voidhash").pipe( +const command = Command.make("voidhash", { debug: debugOption }, () => Effect.void).pipe( Command.withDescription("Voidhash CLI application."), + // Shared flags are accepted before and after the subcommand name (npm-style), + // so `voidhash deploy --profile dev` and `voidhash --profile dev deploy` both + // parse. Must be applied before withSubcommands. + Command.withSharedFlags({ profile: profileOption }), Command.withSubcommands([ initCommand, authCommand, - schemaCommand, + typesCommand, configCommand, - ]) + studioCommand, + deployCommand, + ]), ); const cli = Command.run(command, { @@ -34,14 +40,14 @@ const cli = Command.run(command, { // Apply debug log level if --debug flag is present const cliEffect = cli.pipe( - isDebugMode() ? Effect.provideService(References.MinimumLogLevel, "Debug") : (x) => x + isDebugMode() ? Effect.provideService(References.MinimumLogLevel, "Debug") : (x) => x, ); const ServicesLayer = Layer.mergeAll( SourceCode.Default, Auth.Default, Codegen.Default, - SchemaService.Default + SchemaService.Default, ); const PlatformLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer); @@ -49,9 +55,7 @@ const PlatformLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer); const MainLayer = ServicesLayer.pipe( Layer.provideMerge(ApiClient.Default), Layer.provideMerge(CliConfig.Default), - Layer.provideMerge(PlatformLayer) + Layer.provideMerge(PlatformLayer), ); -NodeRuntime.runMain( - cliEffect.pipe(Effect.provide(MainLayer), withValidationErrorHandler) -); +NodeRuntime.runMain(cliEffect.pipe(Effect.provide(MainLayer), withValidationErrorHandler)); diff --git a/apps/cli/src/cli/shared-options.ts b/apps/cli/src/cli/shared-options.ts index ba647dd19..cc1b49f7a 100644 --- a/apps/cli/src/cli/shared-options.ts +++ b/apps/cli/src/cli/shared-options.ts @@ -8,5 +8,18 @@ import { Flag } from "effect/unstable/cli"; export const debugOption = Flag.boolean("debug").pipe( Flag.withAlias("d"), Flag.withDescription("Enable debug logging with full error traces"), - Flag.withDefault(false) + Flag.withDefault(false), +); + +/** + * Shared profile option for all commands. + * Selects a named set of config overrides merged onto the shared base config. + * The active profile is resolved from process.argv by getActiveProfile() in + * error-formatter.ts; this option just tells the parser to accept it. + * + * No short alias: `-p` is already used by the `studio` command for `--port`. + */ +export const profileOption = Flag.string("profile").pipe( + Flag.withDescription("Use a named config profile (overrides merged onto the base config)"), + Flag.withDefault(""), ); diff --git a/apps/cli/src/domain/errors/auth.ts b/apps/cli/src/domain/errors/auth.ts index d0cbb955f..6600ce04f 100644 --- a/apps/cli/src/domain/errors/auth.ts +++ b/apps/cli/src/domain/errors/auth.ts @@ -1,22 +1,16 @@ import { Data } from "effect"; -export class NoSignedInUserError extends Data.TaggedError( - "NoSignedInUserError" -)<{ +export class NoSignedInUserError extends Data.TaggedError("NoSignedInUserError")<{ readonly cause?: unknown; readonly message: string; }> {} -export class FailedToGetSessionError extends Data.TaggedError( - "FailedToGetSessionError" -)<{ +export class FailedToGetSessionError extends Data.TaggedError("FailedToGetSessionError")<{ readonly cause?: unknown; readonly message: string; }> {} -export class FailedToLogoutError extends Data.TaggedError( - "FailedToLogoutError" -)<{ +export class FailedToLogoutError extends Data.TaggedError("FailedToLogoutError")<{ readonly cause?: unknown; readonly message: string; }> {} diff --git a/apps/cli/src/domain/errors/cli-config.ts b/apps/cli/src/domain/errors/cli-config.ts index 18a0f3c5e..328da7220 100644 --- a/apps/cli/src/domain/errors/cli-config.ts +++ b/apps/cli/src/domain/errors/cli-config.ts @@ -1,15 +1,11 @@ import { Data } from "effect"; -export class CliConfigFileNotFoundError extends Data.TaggedError( - "ConfigFileNotFoundError" -)<{ +export class CliConfigFileNotFoundError extends Data.TaggedError("ConfigFileNotFoundError")<{ readonly cause?: unknown; readonly message: string; }> {} -export class FailedToReadCliConfigError extends Data.TaggedError( - "FailedToReadConfigError" -)<{ +export class FailedToReadCliConfigError extends Data.TaggedError("FailedToReadConfigError")<{ readonly cause?: unknown; readonly message: string; }> {} diff --git a/apps/cli/src/domain/errors/schema.ts b/apps/cli/src/domain/errors/schema.ts index eac5697e6..44f443b87 100644 --- a/apps/cli/src/domain/errors/schema.ts +++ b/apps/cli/src/domain/errors/schema.ts @@ -1,44 +1,9 @@ import { Data } from "effect"; -export class LocalSchemaNotFoundError extends Data.TaggedError( - "LocalSchemaNotFoundError" -)<{ - path: string; -}> {} - -export class LocalSchemaParseError extends Data.TaggedError( - "LocalSchemaParseError" -)<{ - message: string; -}> {} - -export class RemoteSchemaFetchError extends Data.TaggedError( - "RemoteSchemaFetchError" -)<{ - cause: unknown; -}> {} - -export class MissingProviderConfigurationError extends Data.TaggedError( - "MissingProviderConfigurationError" -)<{ - providers: string[]; -}> {} - -export class SchemaValidationError extends Data.TaggedError( - "SchemaValidationError" -)<{ - issues: string[]; -}> {} - -export class ChangeDeploymentError extends Data.TaggedError( - "ChangeDeploymentError" -)<{ +export class RemoteSchemaFetchError extends Data.TaggedError("RemoteSchemaFetchError")<{ cause: unknown; - change: string; }> {} -export class SchemaCheckFailedError extends Data.TaggedError( - "SchemaCheckFailedError" -)<{ +export class SchemaCheckFailedError extends Data.TaggedError("SchemaCheckFailedError")<{ message: string; }> {} diff --git a/apps/cli/src/domain/errors/source-code.ts b/apps/cli/src/domain/errors/source-code.ts index 72148b9d9..1519c7ca7 100644 --- a/apps/cli/src/domain/errors/source-code.ts +++ b/apps/cli/src/domain/errors/source-code.ts @@ -1,53 +1,41 @@ import { Data } from "effect"; -export class PackageJsonNotFoundError extends Data.TaggedError( - "PackageJsonNotFoundError" -)<{ +export class PackageJsonNotFoundError extends Data.TaggedError("PackageJsonNotFoundError")<{ readonly message: string; }> {} -export class InvalidPackageJsonError extends Data.TaggedError( - "InvalidPackageJsonError" -)<{ +export class InvalidPackageJsonError extends Data.TaggedError("InvalidPackageJsonError")<{ readonly message: string; readonly cause?: unknown; }> {} -export class FailedToLoadPackageJsonError extends Data.TaggedError( - "FailedToLoadPackageJsonError" -)<{ +export class FailedToLoadPackageJsonError extends Data.TaggedError("FailedToLoadPackageJsonError")<{ readonly message: string; readonly cause?: unknown; }> {} -export class NoPackageManagerFoundError extends Data.TaggedError( - "NoPackageManagerFoundError" -)<{ +export class NoPackageManagerFoundError extends Data.TaggedError("NoPackageManagerFoundError")<{ readonly message: string; }> {} export class FailedToDetectPackageManagerError extends Data.TaggedError( - "FailedToDetectPackageManagerError" + "FailedToDetectPackageManagerError", )<{ readonly message: string; readonly cause?: unknown; }> {} -export class VoidhashConfigNotFoundError extends Data.TaggedError( - "VoidhashConfigNotFoundError" -)<{ +export class VoidhashConfigNotFoundError extends Data.TaggedError("VoidhashConfigNotFoundError")<{ readonly message: string; }> {} -export class InvalidVoidhashConfigError extends Data.TaggedError( - "InvalidVoidhashConfigError" -)<{ +export class InvalidVoidhashConfigError extends Data.TaggedError("InvalidVoidhashConfigError")<{ readonly message: string; readonly cause?: unknown; }> {} export class FailedToLoadVoidhashConfigError extends Data.TaggedError( - "FailedToLoadVoidhashConfigError" + "FailedToLoadVoidhashConfigError", )<{ readonly message: string; readonly cause?: unknown; diff --git a/apps/cli/src/domain/schema/cli-config.ts b/apps/cli/src/domain/schema/cli-config.ts index 86500772d..208d486a2 100644 --- a/apps/cli/src/domain/schema/cli-config.ts +++ b/apps/cli/src/domain/schema/cli-config.ts @@ -1,7 +1,33 @@ import { Schema } from "effect"; +/** + * The resolved/effective CLI configuration returned by `CliConfig.readConfig`. + * Also describes the shape of the shared base section stored on disk. + */ +export const ResolvedCliConfigSchema = Schema.Struct({ + api_key: Schema.NullishOr(Schema.String), + api_url: Schema.String, + web_url: Schema.String, +}); + +/** + * Per-profile overrides. Every field is optional and stored sparsely so that a + * profile only carries the keys it actually overrides; any missing key falls + * back to the shared base config when resolved. + */ +export const CliProfileSchema = Schema.Struct({ + api_key: Schema.optional(Schema.NullishOr(Schema.String)), + api_url: Schema.optional(Schema.String), + web_url: Schema.optional(Schema.String), +}); + +/** + * The on-disk configuration file: the shared base config plus an optional map + * of named profiles, each holding partial overrides merged onto the base. + */ export const CliConfigSchema = Schema.Struct({ api_key: Schema.NullishOr(Schema.String), api_url: Schema.String, web_url: Schema.String, + profiles: Schema.optional(Schema.Record(Schema.String, CliProfileSchema)), }); diff --git a/apps/cli/src/domain/schema/normalized-schema.ts b/apps/cli/src/domain/schema/normalized-schema.ts index 8d8bef20a..0b57d1a10 100644 --- a/apps/cli/src/domain/schema/normalized-schema.ts +++ b/apps/cli/src/domain/schema/normalized-schema.ts @@ -36,7 +36,7 @@ export const NormalizedProductSchema = Schema.Struct({ perks: Schema.Array(Schema.String), // perk slugs providers: Schema.Array(ProductProviderConfigSchema), slug: Schema.String, - type: Schema.Literal("subscription"), // Extensible later + type: Schema.Literals(["subscription", "one-time", "one-time-consumable"]), }); export type NormalizedProduct = typeof NormalizedProductSchema.Type; diff --git a/apps/cli/src/domain/schema/package-json.ts b/apps/cli/src/domain/schema/package-json.ts index 81fdbd822..a8505353d 100644 --- a/apps/cli/src/domain/schema/package-json.ts +++ b/apps/cli/src/domain/schema/package-json.ts @@ -1,21 +1,12 @@ import { Schema } from "effect"; export const PackageJsonSchema = Schema.Struct({ - dependencies: Schema.optional( - Schema.Record(Schema.String, Schema.String) - ), - devDependencies: Schema.optional( - Schema.Record(Schema.String, Schema.String) - ), + dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), name: Schema.optional(Schema.String), - peerDependencies: Schema.optional( - Schema.Record(Schema.String, Schema.String) - ), + peerDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), version: Schema.optional(Schema.String), workspaces: Schema.optional( - Schema.Union([ - Schema.Array(Schema.String), - Schema.Record(Schema.String, Schema.Unknown), - ]) + Schema.Union([Schema.Array(Schema.String), Schema.Record(Schema.String, Schema.Unknown)]), ), }); diff --git a/apps/cli/src/domain/schema/paywall-deploy.ts b/apps/cli/src/domain/schema/paywall-deploy.ts new file mode 100644 index 000000000..8d6319804 --- /dev/null +++ b/apps/cli/src/domain/schema/paywall-deploy.ts @@ -0,0 +1,155 @@ +/** + * The deploy manifest contract (schemaVersion 2) — the payload `voidhash-cli + * deploy` produces at `.voidhash/.build/manifest.json` and uploads to the + * Voidhash backend. The wire-format source of truth is + * `docs/specs/paywall-deploy-contract.md` (§1); these schemas mirror it + * exactly and MUST stay in sync. Breaking changes bump the schema version. + */ +import { Schema } from "effect"; + +/** Current deploy manifest schema version (contract §1). */ +export const DEPLOY_MANIFEST_VERSION = 2 as const; + +/** Paywall/component slug shape (contract §1.1). */ +export const DEPLOY_SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/; + +const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/; + +/** A slug identifier derived from a source file name, e.g. `onboarding`. */ +export const DeploySlugSchema = Schema.String.check(Schema.isPattern(DEPLOY_SLUG_REGEX)); + +/** Lowercase hex SHA-256 digest. */ +export const DeploySha256Schema = Schema.String.check(Schema.isPattern(SHA256_HEX_REGEX)); + +/** A file's identity: where it lives, how big it is, and its content hash. */ +export const DeployFileSchema = Schema.Struct({ + /** Path relative to the project root, POSIX-separated. */ + path: Schema.String, + bytes: Schema.Number.check(Schema.isInt()), + /** Lowercase hex SHA-256 of the file's raw bytes. */ + sha256: DeploySha256Schema, +}); +export type DeployFile = typeof DeployFileSchema.Type; + +/** A deployable output file with its MIME type. */ +export const DeployArtifactSchema = Schema.Struct({ + ...DeployFileSchema.fields, + contentType: Schema.String, +}); +export type DeployArtifact = typeof DeployArtifactSchema.Type; + +/** A binary asset (image, font, …) referenced by one or more paywalls. */ +export const DeployAssetSchema = DeployArtifactSchema; +export type DeployAsset = typeof DeployAssetSchema.Type; + +/** Author variable values: `string | number | boolean` only (contract §1.1). */ +export const DeployVariableValueSchema = Schema.Union([ + Schema.String, + Schema.Number, + Schema.Boolean, +]); +export type DeployVariableValue = typeof DeployVariableValueSchema.Type; + +/** The paywall's author variables, keyed by name. */ +export const DeployVariablesSchema = Schema.Record(Schema.String, DeployVariableValueSchema); +export type DeployVariables = typeof DeployVariablesSchema.Type; + +/** The compiled, WebView-ready output for a single paywall. */ +export const DeployPaywallArtifactsSchema = Schema.Struct({ + /** The HTML shell that boots the paywall in a WebView. */ + html: DeployArtifactSchema, + /** The bundled JS (paywall tree + renderer + React) the shell loads. */ + js: DeployArtifactSchema, +}); +export type DeployPaywallArtifacts = typeof DeployPaywallArtifactsSchema.Type; + +/** One paywall in the deploy manifest (contract §1). */ +export const DeployPaywallSchema = Schema.Struct({ + /** Slug derived from the source file name, e.g. `onboarding`. */ + id: DeploySlugSchema, + title: Schema.String, + description: Schema.optional(Schema.String), + /** Product slugs the paywall uses (may be empty). */ + products: Schema.Array(Schema.String), + variables: DeployVariablesSchema, + /** The raw author source for this paywall. */ + source: DeployFileSchema, + artifacts: DeployPaywallArtifactsSchema, + /** Paths into the manifest's top-level `assets[]` this paywall references. */ + assets: Schema.Array(Schema.String), + /** + * `sha256(sha256(html) + ":" + sha256(js) + ":" + sortedAssetHashes.join(":"))` + * (contract §1.2) — the paywall's deployable identity: storage prefix, cache + * key, dedupe key. + */ + contentHash: DeploySha256Schema, +}); +export type DeployPaywall = typeof DeployPaywallSchema.Type; + +/** One rendered preview state of a component (contract §3 node tree file). */ +export const DeployComponentPreviewSchema = Schema.Struct({ + state: Schema.String, + file: DeployArtifactSchema, +}); +export type DeployComponentPreview = typeof DeployComponentPreviewSchema.Type; + +/** The compiled artifacts of a component. */ +export const DeployComponentArtifactsSchema = Schema.Struct({ + /** ESM bundle of the component module (react/@voidhash/paywalls external). */ + runtime: DeployArtifactSchema, + /** Custom editor panel bundle, or `null` when none is declared. */ + panel: Schema.NullOr(DeployArtifactSchema), +}); +export type DeployComponentArtifacts = typeof DeployComponentArtifactsSchema.Type; + +/** One reusable component in the deploy manifest (contract §1). */ +export const DeployComponentSchema = Schema.Struct({ + /** Slug derived from the source file name, e.g. `product-option`. */ + id: DeploySlugSchema, + title: Schema.optional(Schema.String), + /** The raw author source for this component. */ + source: DeployFileSchema, + /** The §2 component manifest artifact. */ + manifest: DeployArtifactSchema, + /** §3 preview node trees, one per preview state. */ + previews: Schema.Array(DeployComponentPreviewSchema), + artifacts: DeployComponentArtifactsSchema, + /** + * `sha256(sha256(manifest) + ":" + sha256(runtime) + ":" + (sha256(panel) | "") + * + ":" + sortedPreviewHashes.join(":"))` (contract §1.2). + */ + contentHash: DeploySha256Schema, +}); +export type DeployComponent = typeof DeployComponentSchema.Type; + +/** The full manifest written to `.voidhash/.build/manifest.json` (contract §1). */ +export const DeployManifestSchema = Schema.Struct({ + schemaVersion: Schema.Literal(DEPLOY_MANIFEST_VERSION), + cliVersion: Schema.String, + /** Version of `@voidhash/paywalls` the bundles were built against. */ + runtimeVersion: Schema.String, + /** Organization slug. */ + team: Schema.String, + /** Project slug. */ + project: Schema.String, + /** ISO-8601 build timestamp. */ + createdAt: Schema.String, + paywalls: Schema.Array(DeployPaywallSchema), + components: Schema.Array(DeployComponentSchema), + /** The project's `voidhash.config.*`. */ + config: DeployFileSchema, + /** Every binary asset, deduped by path. */ + assets: Schema.Array(DeployAssetSchema), +}).check( + // Contract §1.1: at least one paywall or one component. + Schema.makeFilter( + (manifest: { + readonly paywalls: ReadonlyArray; + readonly components: ReadonlyArray; + }) => + manifest.paywalls.length > 0 || manifest.components.length > 0 + ? undefined + : "manifest must contain at least one paywall or one component", + ), +); +export type DeployManifest = typeof DeployManifestSchema.Type; diff --git a/apps/cli/src/domain/schema/voidhash-config.ts b/apps/cli/src/domain/schema/voidhash-config.ts index b11bab36f..c2885ac2d 100644 --- a/apps/cli/src/domain/schema/voidhash-config.ts +++ b/apps/cli/src/domain/schema/voidhash-config.ts @@ -1,7 +1,24 @@ import { Schema } from "effect"; +/** + * Default output path for the generated `.d.ts` when `typesOutput` is omitted + * from `voidhash.config.ts`. + */ +export const DEFAULT_TYPES_OUTPUT = "voidhash.gen.d.ts"; + export const VoidhashConfigSchema = Schema.Struct({ project: Schema.String, - schema: Schema.String, team: Schema.String, + /** + * Output path for the generated `.d.ts` declaration file. Optional — + * defaults to `voidhash.gen.d.ts` at the project root. + */ + typesOutput: Schema.optional(Schema.String), }); + +/** + * Resolve `typesOutput` from a loaded config, applying the default. + */ +export function resolveTypesOutput(config: typeof VoidhashConfigSchema.Type): string { + return config.typesOutput ?? DEFAULT_TYPES_OUTPUT; +} diff --git a/apps/cli/src/domain/services/auth.ts b/apps/cli/src/domain/services/auth.ts index c269046d3..bcb7ab708 100644 --- a/apps/cli/src/domain/services/auth.ts +++ b/apps/cli/src/domain/services/auth.ts @@ -1,17 +1,7 @@ import { NodeServices, NodeHttpServer } from "@effect/platform-node"; -import { - Console, - Data, - Effect, - Layer, - PubSub, - ServiceMap, -} from "effect"; -import { - HttpRouter, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http"; +import type { AuthSession200 } from "@voidhash/generated-clients"; +import { Console, Data, Effect, Layer, PubSub, Context } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { customAlphabet } from "nanoid"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; @@ -27,9 +17,7 @@ import { } from "../errors/auth"; import { CliConfig } from "./cli-config"; -export class LoginCancelledError extends Data.TaggedError( - "LoginCancelledError" -)<{ +export class LoginCancelledError extends Data.TaggedError("LoginCancelledError")<{ readonly message: string; }> {} @@ -49,6 +37,38 @@ interface KeyCallbackEvent { type CallbackEvent = CancelledCallbackEvent | KeyCallbackEvent; +interface AuthShape { + readonly getSignedInSession: Effect.Effect< + AuthSession200, + FailedToGetSessionError | NoSignedInUserError + >; + readonly login: Effect.Effect; + readonly logout: Effect.Effect; +} + +const hasTag = (error: unknown, tag: string): error is { readonly _tag: string } => + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" && + error._tag === tag; + +const hasNestedTag = ( + error: unknown, + outerTag: string, + innerTag: string, +): error is { readonly _tag: string; readonly data: { readonly _tag: string } } => + hasTag(error, outerTag) && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "_tag" in error.data && + typeof error.data._tag === "string" && + error.data._tag === innerTag; + +const isNoSignedInUserError = (error: unknown): error is NoSignedInUserError => + error instanceof NoSignedInUserError || hasTag(error, "NoSignedInUserError"); + const runCallbackServer = (callbackEvents: PubSub.PubSub) => Effect.gen(function* runCallbackServer() { // Create the callback route layer @@ -66,14 +86,8 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => if (query.cancelled) { yield* PubSub.publish(callbackEvents, { type: "cancelled" }); return HttpServerResponse.text("Login cancelled").pipe( - HttpServerResponse.setHeader( - "Access-Control-Allow-Origin", - "*" - ), - HttpServerResponse.setHeader( - "Access-Control-Allow-Methods", - "GET, OPTIONS" - ) + HttpServerResponse.setHeader("Access-Control-Allow-Origin", "*"), + HttpServerResponse.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"), ); } @@ -84,18 +98,15 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => }); return HttpServerResponse.text("Login successful").pipe( HttpServerResponse.setHeader("Access-Control-Allow-Origin", "*"), - HttpServerResponse.setHeader( - "Access-Control-Allow-Methods", - "GET, OPTIONS" - ), + HttpServerResponse.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"), HttpServerResponse.setHeader( "Access-Control-Allow-Headers", - "Content-Type, Authorization" - ) + "Content-Type, Authorization", + ), ); - }) + }), ); - }) + }), ); const ServerLive = NodeHttpServer.layer(() => createServer(), { @@ -106,7 +117,7 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => // Launch the server with the callback route return yield* HttpRouter.serve(CallbackRoute).pipe( Layer.provide(Layer.mergeAll(ServerLive, NodeServices.layer)), - Layer.launch + Layer.launch, ); }); @@ -123,64 +134,59 @@ const make = Effect.gen(function* effect() { * @returns {Effect.Effect} * An Effect that yields the signed-in user's information, or fails with an appropriate error. */ - const getSignedInSession = Effect.gen(function* getSignedInSession() { - yield* Effect.logDebug("Reading CLI config for session check"); - const config = (yield* cliConfig - .readConfig() - .pipe( - Effect.catch(() => Effect.die("Failed to read config")) - ))!; - - // If the config file is not found or the api key is not set, we consider the user to be signed out - const apiKey = config.api_key; - if (!apiKey) { - yield* Effect.logDebug("No API key found in config"); - return yield* Effect.fail( - new NoSignedInUserError({ message: "No signed in user" }) - ); - } + const getSignedInSession: AuthShape["getSignedInSession"] = Effect.gen( + function* getSignedInSession() { + yield* Effect.logDebug("Reading CLI config for session check"); + const config = (yield* cliConfig + .readConfig() + .pipe(Effect.catch(() => Effect.die("Failed to read config"))))!; - yield* Effect.logDebug("Fetching session from API"); - const sessionResponse = yield* client.auth.session().pipe( - Effect.tap((session) => - Effect.logDebug(`Session retrieved for user: ${session.name}`) - ), - Effect.catchTags({ - NotAuthenticatedError: () => - Effect.fail(new NoSignedInUserError({ message: "No signed in user" })), - }) - ); + // If the config file is not found or the api key is not set, we consider the user to be signed out + const apiKey = config.api_key; + if (!apiKey) { + yield* Effect.logDebug("No API key found in config"); + return yield* Effect.fail(new NoSignedInUserError({ message: "No signed in user" })); + } - return sessionResponse; - }).pipe( + yield* Effect.logDebug("Fetching session from API"); + const sessionResponse = yield* client.authSession().pipe( + Effect.tap((session) => Effect.logDebug(`Session retrieved for user: ${session.name}`)), + Effect.catchIf( + (error) => hasNestedTag(error, "AuthSession500", "NotAuthenticatedError"), + () => Effect.fail(new NoSignedInUserError({ message: "No signed in user" })), + ), + ); + + return sessionResponse; + }, + ).pipe( Effect.withSpan("Auth.getSignedInSession"), Effect.catchIf( - (e) => e._tag !== "NoSignedInUserError", - (e) => + isNoSignedInUserError, + (error) => Effect.fail(error), + (error) => Effect.fail( new FailedToGetSessionError({ - cause: e, + cause: error, message: "Failed to get session", - }) - ) - ) + }), + ), + ), ); - const login = Effect.scoped( + const login: AuthShape["login"] = Effect.scoped( Effect.gen(function* login() { yield* Effect.logDebug("Starting login flow"); const callbackEventsPubSub = yield* PubSub.unbounded(); // Launch the callback server in a separate fiber to avoid blocking - yield* Effect.logDebug( - `Starting callback server on ${host}:${port}` - ); + 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); - }) + }), ); // Set up the application server with routing @@ -189,19 +195,15 @@ const make = Effect.gen(function* effect() { const code = nanoid(); const config = (yield* cliConfig .readConfig() - .pipe( - Effect.catch(() => Effect.die("Failed to read config")) - ))!; + .pipe(Effect.catch(() => Effect.die("Failed to read config"))))!; const confirmationUrl = new URL(`${config.web_url}/auth/devices`); confirmationUrl.searchParams.append("code", code); confirmationUrl.searchParams.append("redirect", redirect); - yield* Effect.logDebug( - `Opening browser for authentication: ${confirmationUrl.toString()}` - ); + yield* Effect.logDebug(`Opening browser for authentication: ${confirmationUrl.toString()}`); yield* Console.log(`Confirmation code: ${code}\n`); yield* Console.log( - `If something goes wrong, copy and paste this URL into your browser: ${confirmationUrl.toString()}\n` + `If something goes wrong, copy and paste this URL into your browser: ${confirmationUrl.toString()}\n`, ); spawn("open", [confirmationUrl.toString()]); @@ -212,9 +214,7 @@ const make = Effect.gen(function* effect() { if (callbackEvent.type === "cancelled") { yield* Effect.logDebug("Login cancelled by user"); - return yield* Effect.fail( - new LoginCancelledError({ message: "Login cancelled" }) - ); + return yield* Effect.fail(new LoginCancelledError({ message: "Login cancelled" })); } // Store in config @@ -222,18 +222,15 @@ const make = Effect.gen(function* effect() { yield* cliConfig.writeToConfig({ api_key: callbackEvent.key }); yield* Console.log( - `Authentication successful! Your key has been stored in your config file. To view it, type 'cat ~/${CONFIG_FILE_NAME}'.\n);` + `Authentication successful! Your key has been stored in your config file. To view it, type 'cat ~/${CONFIG_FILE_NAME}'.\n);`, ); - }) + }), ).pipe( Effect.withSpan("Auth.login"), Effect.catchIf( (e) => e._tag !== "LoginCancelledError", - (e) => - Effect.fail( - new FailedToLoginError({ cause: e, message: "Failed to login" }) - ) - ) + (e) => Effect.fail(new FailedToLoginError({ cause: e, message: "Failed to login" })), + ), ); /** @@ -241,7 +238,7 @@ const make = Effect.gen(function* effect() { * * @returns An Effect that logs out the current user, or fails with a FailedToLogoutError if the logout fails. */ - const logout = Effect.gen(function* logout() { + const logout: AuthShape["logout"] = Effect.gen(function* logout() { yield* Effect.logDebug("Starting logout"); const config = yield* cliConfig.readConfig(); if (!config.api_key) { @@ -260,24 +257,18 @@ const make = Effect.gen(function* effect() { new FailedToLogoutError({ cause: e, message: "Failed to logout", - }) - ) - ) + }), + ), + ), ); return { getSignedInSession, login, logout, - } as const; + } satisfies AuthShape; }); -type AuthShape = Effect.Success; - -export class Auth extends ServiceMap.Service()( - "voidhash-cli/Auth" -) { - static Default = Layer.effect(Auth, make).pipe( - Layer.provide(CliConfig.Default) - ) +export class Auth extends Context.Service()("voidhash-cli/Auth") { + static Default = Layer.effect(Auth, make).pipe(Layer.provide(CliConfig.Default)); } diff --git a/apps/cli/src/domain/services/cli-config.ts b/apps/cli/src/domain/services/cli-config.ts index 92fc3799b..2827d76a7 100644 --- a/apps/cli/src/domain/services/cli-config.ts +++ b/apps/cli/src/domain/services/cli-config.ts @@ -1,19 +1,34 @@ -import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema, Context } from "effect"; import os from "node:os"; -import { - CONFIG_FILE_NAME, - DEFAULT_API_URL, - DEFAULT_WEB_URL, -} from "../../constants"; +import { CONFIG_FILE_NAME, DEFAULT_API_URL, DEFAULT_WEB_URL } from "../../constants"; +import { getActiveProfile } from "../../utils/error-formatter"; import { FailedToReadCliConfigError } from "../errors/cli-config"; -import { CliConfigSchema } from "../schema/cli-config"; +import { + CliConfigSchema, + type CliProfileSchema, + type ResolvedCliConfigSchema, +} from "../schema/cli-config"; + +type ConfigFile = typeof CliConfigSchema.Type; +type ResolvedConfig = typeof ResolvedCliConfigSchema.Type; +type ProfileOverrides = typeof CliProfileSchema.Type; export const emptyConfig = { api_key: null, api_url: DEFAULT_API_URL, web_url: DEFAULT_WEB_URL, -} satisfies typeof CliConfigSchema.Type; +} satisfies ResolvedConfig; + +/** + * Strips the `profiles` map from a config file, leaving only the resolved base + * fields (`api_key`, `api_url`, `web_url`). + */ +const baseOf = (config: ConfigFile): ResolvedConfig => ({ + api_key: config.api_key, + api_url: config.api_url, + web_url: config.web_url, +}); const make = Effect.gen(function* effect() { const fileSystem = yield* FileSystem.FileSystem; @@ -22,17 +37,23 @@ const make = Effect.gen(function* effect() { const homeDir = os.homedir(); const filePath = path.join(homeDir, CONFIG_FILE_NAME); + // Resolved once at layer build time, mirroring the isDebugMode() pattern. + const activeProfile = getActiveProfile(); + /** - * Reads and decodes the user's configuration file from the home directory. + * Reads and decodes the raw config file, including the full `profiles` map. + * Returns the defaults when the file does not exist. * - * @returns An Effect that yields the parsed configuration object, or fails with a ConfigFileNotFoundError if the config file does not exist, or a Schema.DecodeError if the file contents are invalid. + * @returns An Effect that yields the parsed config file, or fails with a + * FailedToReadCliConfigError if the file cannot be read or is invalid. */ - const readConfig = () => - Effect.gen(function* readConfig() { + const readRawConfig = () => + Effect.gen(function* readRawConfig() { yield* Effect.logDebug(`Reading config from ${filePath}`); - if (!fileSystem.exists(filePath)) { + const exists = yield* fileSystem.exists(filePath); + if (!exists) { yield* Effect.logDebug("Config file not found, using defaults"); - return yield* Effect.succeed(emptyConfig); + return yield* Effect.succeed(emptyConfig); } const configString = yield* fileSystem.readFileString(filePath); const configJson = JSON.parse(configString); @@ -42,58 +63,103 @@ const make = Effect.gen(function* effect() { ...configJson, }); }).pipe( - Effect.withSpan("CliConfig.readConfig"), + Effect.withSpan("CliConfig.readRawConfig"), Effect.catchTags({ PlatformError: (e) => Effect.fail( new FailedToReadCliConfigError({ cause: e, message: "Failed to read config", - }) + }), ), SchemaError: (e) => Effect.fail( new FailedToReadCliConfigError({ cause: e, message: "Failed to read config", - }) + }), ), - }) + }), ); /** - * Writes the provided partial configuration to the user's config file. - * Merges the new config with any existing config, then saves the result. + * Reads the effective configuration: the shared base config with the active + * profile's overrides merged on top. When no profile is active (or the + * requested profile has no overrides) this equals the base config. * - * @param config - Partial configuration object to write to the config file. + * @returns An Effect that yields the resolved configuration object. + */ + const readConfig = () => + Effect.gen(function* readConfig() { + const raw = yield* readRawConfig(); + const base = baseOf(raw); + if (!activeProfile) return base; + const overrides = raw.profiles?.[activeProfile] ?? {}; + return { ...base, ...overrides } satisfies ResolvedConfig; + }).pipe(Effect.withSpan("CliConfig.readConfig")); + + /** + * Writes the provided partial configuration. When a profile is active the + * values are merged into that profile's overrides, leaving the base config and + * other profiles untouched; otherwise they are merged into the base config. + * + * @param config - Partial configuration values to persist. * @returns An Effect that writes the merged configuration to disk. */ - const writeToConfig = (config: Partial) => + const writeToConfig = (config: Partial) => Effect.gen(function* writeToConfig() { yield* Effect.logDebug(`Writing config to ${filePath}`); - const currentConfig = yield* readConfig().pipe( - Effect.catch(() => Effect.succeed({})) - ); - const mergedConfig = { ...currentConfig, ...config }; - const validatedConfig = - yield* Schema.decodeUnknownEffect(CliConfigSchema)(mergedConfig); - yield* fileSystem.writeFileString( - filePath, - JSON.stringify(validatedConfig) + const currentConfig = yield* readRawConfig().pipe( + Effect.catch(() => Effect.succeed(emptyConfig)), ); + + const mergedConfig: ConfigFile = activeProfile + ? { + ...currentConfig, + profiles: { + ...currentConfig.profiles, + [activeProfile]: { + ...currentConfig.profiles?.[activeProfile], + ...config, + }, + }, + } + : { ...currentConfig, ...config }; + + const validatedConfig = yield* Schema.decodeUnknownEffect(CliConfigSchema)(mergedConfig); + yield* fileSystem.writeFileString(filePath, JSON.stringify(validatedConfig)); yield* Effect.logDebug("Config file written successfully"); }).pipe(Effect.withSpan("CliConfig.writeToConfig")); /** - * Resets the configuration to the default values. If authenticated, persists the authentication state. + * Resets the configuration to the default values. If authenticated, persists + * the authentication state. When a profile is active, only that profile's + * overrides are cleared (so it reverts to the base config), preserving a + * non-null api_key override if present. * - * @returns An Effect that resets the configuration to the default values. + * @returns An Effect that resets the configuration. */ const resetConfig = () => Effect.gen(function* resetConfig() { yield* Effect.logDebug("Resetting config to defaults"); - const config = yield* readConfig(); + if (activeProfile) { + const raw = yield* readRawConfig(); + const apiKey = raw.profiles?.[activeProfile]?.api_key; + // Never persist `api_key: null` as an override — that would mask the + // base key. Keep only a real, non-null profile key. + const preserved: ProfileOverrides = apiKey ? { api_key: apiKey } : {}; + const mergedConfig: ConfigFile = { + ...baseOf(raw), + profiles: { ...raw.profiles, [activeProfile]: preserved }, + }; + const validatedConfig = yield* Schema.decodeUnknownEffect(CliConfigSchema)(mergedConfig); + yield* fileSystem.writeFileString(filePath, JSON.stringify(validatedConfig)); + yield* Effect.logDebug("Config reset complete"); + return; + } + + const config = yield* readConfig(); yield* writeToConfig({ ...emptyConfig, api_key: config.api_key ?? null, @@ -103,6 +169,7 @@ const make = Effect.gen(function* effect() { return { readConfig, + readRawConfig, resetConfig, writeToConfig, } as const; @@ -110,8 +177,8 @@ const make = Effect.gen(function* effect() { type CliConfigShape = Effect.Success; -export class CliConfig extends ServiceMap.Service()( - "voidhash-cli/CliConfig" +export class CliConfig extends Context.Service()( + "voidhash-cli/CliConfig", ) { - static Default = Layer.effect(CliConfig, make) + static Default = Layer.effect(CliConfig, make); } diff --git a/apps/cli/src/domain/services/codegen.ts b/apps/cli/src/domain/services/codegen.ts index 5c052f6f8..09d8f08f9 100644 --- a/apps/cli/src/domain/services/codegen.ts +++ b/apps/cli/src/domain/services/codegen.ts @@ -1,186 +1,152 @@ -import { Effect, FileSystem, Layer, ServiceMap } from "effect"; +import { Effect, FileSystem, Layer, Path, Context } from "effect"; +import { + VOIDHASH_FETCHED_AT_COMMENT_PREFIX, + VOIDHASH_VERSION_COMMENT_PREFIX, + parseVersionFromDeclaration, +} from "../../utils/schema/version"; import type { Writable } from "../../utils/types"; -import type { NormalizedSchema, ProviderId } from "../schema/normalized-schema"; +import type { NormalizedSchema } from "../schema/normalized-schema"; import type { VoidhashConfigSchema } from "../schema/voidhash-config"; -/** - * Convert slug to camelCase variable name - * e.g., "all-access" -> "allAccess", "monthly_sub" -> "monthlySub" - */ -function slugToCamelCase(slug: string): string { - return slug - .split(/[-_]/) - .map((part, i) => - i === 0 - ? part.toLowerCase() - : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() - ) - .join(""); -} - -function toTsStringLiteral(value: string): string { - return JSON.stringify(value); +function toUnionType(slugs: string[]): string { + if (slugs.length === 0) { + return "never"; + } + return slugs.map((slug) => JSON.stringify(slug)).join(" | "); } /** - * Generate TypeScript code for a schema file + * Generate the contents of the `voidhash.gen.d.ts` declaration file. + * + * The version baked into the header is supplied by the caller (and ultimately + * by the server's `GET /api/v1/schema` / `GET /api/v1/schema/version` + * endpoints) so client and server can never disagree on the hash. + * + * The file: + * - Starts with a `@voidhash:version` header so `voidhash-cli types check` and the + * dev-mode runtime warning can detect staleness without re-downloading the + * full schema. + * - Augments `@voidhash/react-native`'s `VoidhashRegister` interface so all + * typed hook arguments (slug literals) resolve via the registry. */ -function generateSchemaCode(schema: NormalizedSchema): string { - const lines: string[] = []; +export function generateTypesDeclaration( + schema: NormalizedSchema, + version: string, + options: { fetchedAt?: Date } = {}, +): string { + const fetchedAt = (options.fetchedAt ?? new Date()).toISOString(); - // Collect all provider IDs used - const providerIds = new Set(); - for (const product of schema.products.values()) { - for (const provider of product.providers) { - providerIds.add(provider.providerId); - } - } - // Also include providers from enabledProviders - for (const providerId of schema.enabledProviders) { - providerIds.add(providerId); - } + const productSlugs = [...schema.products.keys()].sort(); + const locationSlugs = [...schema.locations.keys()].sort(); + const perkSlugs = [...schema.perks.keys()].sort(); - // Imports - lines.push( - 'import { schemaConfiguration, unlockablePerk } from "@voidhash/react-native";' - ); + const lines: string[] = []; + lines.push("// voidhash.gen.d.ts — generated by voidhash-cli, do not edit"); + lines.push(`${VOIDHASH_VERSION_COMMENT_PREFIX}${version}`); + lines.push(`${VOIDHASH_FETCHED_AT_COMMENT_PREFIX}${fetchedAt}`); lines.push(""); - - // Schema configuration - lines.push("export const sc = schemaConfiguration({"); - - // Perks - lines.push(" perks: {"); - const sortedPerks = [...schema.perks.values()].sort((a, b) => - a.slug.localeCompare(b.slug) - ); - for (const perk of sortedPerks) { - const varName = slugToCamelCase(perk.slug); - lines.push( - ` ${varName}: unlockablePerk(${toTsStringLiteral(perk.slug)}, { name: ${toTsStringLiteral(perk.name)} }),` - ); - } - lines.push(" },"); - - // Providers - lines.push(" providers: {"); - const sortedProviders = [...providerIds].sort(); - for (const providerId of sortedProviders) { - lines.push(` ${providerId}: true,`); - } - lines.push(" },"); - - lines.push("});"); + lines.push('declare module "@voidhash/react-native" {'); + lines.push(" interface VoidhashRegister {"); + lines.push(" schema: {"); + lines.push(` products: ${toUnionType(productSlugs)};`); + lines.push(` locations: ${toUnionType(locationSlugs)};`); + lines.push(` perks: ${toUnionType(perkSlugs)};`); + lines.push(" };"); + lines.push(" }"); + lines.push("}"); + lines.push(""); + lines.push("export {};"); lines.push(""); - - // Paywall locations - const sortedLocations = [...schema.locations.values()].sort((a, b) => - a.slug.localeCompare(b.slug) - ); - for (const location of sortedLocations) { - const varName = slugToCamelCase(location.slug); - lines.push( - `export const ${varName} = sc.location(${toTsStringLiteral(location.slug)}, {` - ); - lines.push(` name: ${toTsStringLiteral(location.name)},`); - if (location.description !== null) { - lines.push(` description: ${toTsStringLiteral(location.description)},`); - } - lines.push("});"); - lines.push(""); - } - - // Products - const sortedProducts = [...schema.products.values()].sort((a, b) => - a.slug.localeCompare(b.slug) - ); - for (const product of sortedProducts) { - const varName = slugToCamelCase(product.slug); - lines.push( - `export const ${varName} = sc.subscription(${toTsStringLiteral(product.slug)}, {` - ); - lines.push(` name: ${toTsStringLiteral(product.name)},`); - - // Perks - lines.push(" perks: {"); - const sortedProductPerks = [...product.perks].sort(); - for (const perkSlug of sortedProductPerks) { - const perkVarName = slugToCamelCase(perkSlug); - lines.push(` ${perkVarName}: true,`); - } - lines.push(" },"); - - // Providers - lines.push(" providers: {"); - const sortedProductProviders = [...product.providers].sort((a, b) => - a.providerId.localeCompare(b.providerId) - ); - for (const provider of sortedProductProviders) { - const configStr = JSON.stringify(provider.configuration, null, 2) - .split("\n") - .map((line, i) => (i === 0 ? line : ` ${line}`)) - .join("\n"); - lines.push(` ${provider.providerId}: ${configStr},`); - } - lines.push(" },"); - - lines.push("});"); - lines.push(""); - } return lines.join("\n"); } const make = Effect.gen(function* effect() { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + /** + * Scaffold the SDK client module (`voidhash.ts` / `.js`) that wires up + * `@voidhash/react-native` with the project's publishable key. + * + * The key is pre-filled as the default so the file works out of the box; + * the user is free to edit it and the client options afterwards. The client + * is exported as a single `voidhash` object, so the rest of the app uses it + * like `` or `voidhash.useProducts()`. The parent + * directory (`src/lib` or `lib`) is created if it does not exist yet. + */ + const generateClientFile = (filePath: string, options: { publishableKey: string }) => + Effect.gen(function* generateClientFile() { + yield* fileSystem.makeDirectory(path.dirname(filePath), { + recursive: true, + }); + const lines = [ + 'import { createVoidhashClient } from "@voidhash/react-native";', + "", + "/**", + " * Voidhash SDK client. Safe to edit.", + " *", + " * Use it like `` and `voidhash.useProducts()`. The", + " * publishable key is safe to ship in client code.", + " */", + `export const voidhash = createVoidhashClient("${options.publishableKey}");`, + "", + ]; + yield* fileSystem.writeFileString(filePath, lines.join("\n")); + }); const generateVoidhashConfigFile = ( filePath: string, - config: Writable + config: Writable, ) => Effect.gen(function* generateVoidhashConfigFile() { - const content = `import { defineConfig } from 'voidhash-cli'; - -export default defineConfig({ - team: '${config.team}', - project: '${config.project}', - schema: '${config.schema}' -}); -`; - yield* fileSystem.writeFileString(filePath, content); + const lines = [ + "import { defineConfig } from 'voidhash-cli';", + "", + "export default defineConfig({", + ` team: '${config.team}',`, + ` project: '${config.project}',`, + ]; + if (config.typesOutput !== undefined) { + lines.push(` typesOutput: '${config.typesOutput}',`); + } + lines.push("});", ""); + yield* fileSystem.writeFileString(filePath, lines.join("\n")); }); - const generateClientFile = (filePath: string, publishableKey: string) => - Effect.gen(function* generateClientFile() { - const content = `import { createVoidhashClient } from "@voidhash/react-native"; -import * as schema from "./schema"; - -export const voidhash = createVoidhashClient( - "${publishableKey}", - schema -); -`; + /** + * Generate the `.d.ts` declaration file from the remote schema and write it + * to disk. `version` is the server-provided hash that gets baked into the + * header so the CI gate / dev-mode warning can detect drift. + */ + const generateTypesDeclarationFile = ( + filePath: string, + schema: NormalizedSchema, + version: string, + ) => + Effect.gen(function* generateTypesDeclarationFile() { + const content = generateTypesDeclaration(schema, version); yield* fileSystem.writeFileString(filePath, content); + return version; }); - const generateSchemaFile = (filePath: string, schema: NormalizedSchema) => - Effect.gen(function* generateSchemaFile() { - const content = generateSchemaCode(schema); - yield* fileSystem.writeFileString(filePath, content); + const readDeclarationVersion = (filePath: string) => + Effect.gen(function* readDeclarationVersion() { + const content = yield* fileSystem.readFileString(filePath); + return parseVersionFromDeclaration(content); }); return { generateClientFile, - generateSchemaFile, + generateTypesDeclarationFile, generateVoidhashConfigFile, + readDeclarationVersion, } as const; }); type CodegenShape = Effect.Success; -export class Codegen extends ServiceMap.Service()( - "voidhash-cli/Codegen" -) { - static Default = Layer.effect(Codegen, make) +export class Codegen extends Context.Service()("voidhash-cli/Codegen") { + static Default = Layer.effect(Codegen, make); } diff --git a/apps/cli/src/domain/services/paywall-build.ts b/apps/cli/src/domain/services/paywall-build.ts new file mode 100644 index 000000000..67d108e93 --- /dev/null +++ b/apps/cli/src/domain/services/paywall-build.ts @@ -0,0 +1,1045 @@ +/** + * The deploy build pipeline: scans `.voidhash/paywalls` and + * `.voidhash/components`, typechecks them, compiles every paywall into a + * WebView-ready HTML/JS bundle and every component into its §2 manifest, §3 + * preview trees and runtime bundle, and assembles the content-addressed + * schemaVersion-2 deploy manifest (contract: docs/specs/paywall-deploy-contract.md). + */ +import { createHash } from "node:crypto"; +import { existsSync, promises as fsp, readdirSync, statSync } from "node:fs"; +import { basename, dirname, extname, join, posix, relative, sep } from "node:path"; +import { Data, Effect, Schema } from "effect"; +import * as esbuild from "esbuild"; + +import { + DEPLOY_MANIFEST_VERSION, + DEPLOY_SLUG_REGEX, + type DeployArtifact, + type DeployAsset, + type DeployComponent, + type DeployComponentPreview, + type DeployFile, + type DeployManifest, + DeployManifestSchema, + type DeployPaywall, + type DeployVariables, +} from "../schema/paywall-deploy"; +import { closedImportsPlugin } from "./paywall-closed-imports"; +import { PAYWALL_ASSET_EXTENSIONS, typecheckPaywallSources } from "./paywall-typecheck"; + +export class PaywallBuildError extends Data.TaggedError("PaywallBuildError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** Directory (relative to the project root) where build output is written. */ +export const BUILD_DIR = join(".voidhash", ".build"); + +const SOURCE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"]; + +/** + * Binary asset types paywall bundles may import; emitted as files. Derived + * from the typecheck gate's extension list so the two can never drift. + */ +const PAYWALL_ASSET_LOADERS: Record = Object.fromEntries( + PAYWALL_ASSET_EXTENSIONS.map((ext) => [`.${ext}`, "file"]), +); + +/** + * Component runtime bundles must stay a single file (the manifest has no + * per-component asset list), so binary imports are inlined as data URLs. + */ +const COMPONENT_ASSET_LOADERS: Record = Object.fromEntries( + Object.keys(PAYWALL_ASSET_LOADERS).map((ext) => [ext, "dataurl"]), +); + +const CONTENT_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +const textEncoder = new TextEncoder(); + +/** Lowercase hex SHA-256 of a string or byte payload. */ +export const sha256Hex = (data: Uint8Array | string): string => + createHash("sha256").update(data).digest("hex"); + +/** + * Contract §1.2 paywall content hash: + * `sha256(sha256(html) + ":" + sha256(js) + ":" + sortedAssetHashes.join(":"))`. + */ +export const computePaywallContentHash = (input: { + readonly htmlSha256: string; + readonly jsSha256: string; + readonly assetSha256s: ReadonlyArray; +}): string => + sha256Hex(`${input.htmlSha256}:${input.jsSha256}:${[...input.assetSha256s].sort().join(":")}`); + +/** + * Contract §1.2 component content hash: + * `sha256(sha256(manifest) + ":" + sha256(runtime) + ":" + (sha256(panel) | "") + * + ":" + sortedPreviewHashes.join(":"))`. + */ +export const computeComponentContentHash = (input: { + readonly manifestSha256: string; + readonly runtimeSha256: string; + readonly panelSha256?: string | null; + readonly previewSha256s: ReadonlyArray; +}): string => + sha256Hex( + `${input.manifestSha256}:${input.runtimeSha256}:${ + input.panelSha256 ?? "" + }:${[...input.previewSha256s].sort().join(":")}`, + ); + +const contentTypeFor = (path: string): string => + CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream"; + +/** Normalizes an absolute path to a project-root-relative POSIX path. */ +const toRelPosix = (projectRoot: string, abs: string): string => + relative(projectRoot, abs).split(sep).join(posix.sep); + +// Discovery (isSourceFile / idFromFile / listFilesRecursive) is mirrored by +// Studio's virtual-paywalls plugin +// (apps/studio/src/server/virtual-paywalls-plugin.ts) — keep both in sync. +const isSourceFile = (name: string): boolean => + SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !name.endsWith(".d.ts"); + +const idFromFile = (file: string): string => basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); + +/** Recursively lists files under a directory (absolute paths). */ +const listFilesRecursive = (dir: string): string[] => { + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...listFilesRecursive(full)); + } else { + out.push(full); + } + } + return out; +}; + +const listSourceFiles = (dir: string): string[] => + listFilesRecursive(dir).filter((f) => isSourceFile(basename(f))); + +/** Turns an esbuild failure into a readable, file-located error message. */ +const describeEsbuildFailure = (cause: unknown): string | undefined => { + if ( + typeof cause === "object" && + cause !== null && + "errors" in cause && + Array.isArray((cause as esbuild.BuildFailure).errors) + ) { + return (cause as esbuild.BuildFailure).errors + .map((error) => { + const location = error.location + ? `${error.location.file}:${error.location.line}:${error.location.column}: ` + : ""; + return ` ${location}${error.text}`; + }) + .join("\n"); + } + return; +}; + +const bundleFailure = (subject: string) => (cause: unknown) => { + const details = describeEsbuildFailure(cause); + return new PaywallBuildError({ + cause, + message: details ? `Failed to bundle ${subject}:\n${details}` : `Failed to bundle ${subject}`, + }); +}; + +// ── User-project library access ────────────────────────────────────────────── +// +// `@voidhash/paywalls` (and React) are intentionally NOT dependencies of the +// CLI: modules are resolved from the *user's* project so the paywall module, +// the tree renderer and React are all one instance. + +interface UserPaywallsLib { + readonly extractComponentManifest: ( + definition: ComponentDefinitionLike, + ) => { readonly id: string } & Record; +} + +interface UserTreeLib { + readonly renderToNodeTree: ( + element: unknown, + options?: { + readonly config?: { + readonly products?: ReadonlyArray; + readonly variables?: Record; + readonly platform?: "ios" | "android" | "web"; + readonly safeAreaInsets?: ComponentPreviewSafeAreaInsets; + readonly dimensions?: ComponentPreviewDimensions; + }; + readonly state?: string; + }, + ) => Promise; +} + +interface UserReactLib { + readonly createElement: (type: unknown, props: Record | null) => unknown; +} + +interface ComponentPreviewStateLike { + readonly props?: Record; + readonly data?: { + readonly products?: ReadonlyArray; + readonly variables?: Record; + readonly platform?: "ios" | "android" | "web"; + readonly safeAreaInsets?: ComponentPreviewSafeAreaInsets; + readonly dimensions?: ComponentPreviewDimensions; + }; +} + +interface ComponentPreviewSafeAreaInsets { + readonly top: number; + readonly right: number; + readonly bottom: number; + readonly left: number; +} + +interface ComponentPreviewDimensions { + readonly screen: { + readonly width: number; + readonly height: number; + readonly x: number; + readonly y: number; + }; + readonly window: { + readonly width: number; + readonly height: number; + readonly x: number; + readonly y: number; + }; +} + +interface ComponentDefinitionLike { + readonly id: string; + readonly title?: string; + readonly description?: string; + readonly previews: Record; + readonly panel?: unknown; + readonly component: unknown; + readonly __voidhash: { readonly kind: string }; +} + +const requireFromProject = ( + projectRoot: string, + specifier: string, +): Effect.Effect => + Effect.try({ + try: () => require(require.resolve(specifier, { paths: [projectRoot] })) as T, + catch: (cause) => + new PaywallBuildError({ + cause, + message: + `Failed to load "${specifier}" from the project. ` + + `Make sure "@voidhash/paywalls" is installed in your project.`, + }), + }); + +/** + * Registers an esbuild `require` hook with the `tsx` loader so paywall and + * component modules (which contain JSX) can be loaded for metadata extraction + * and preview rendering. The shared `safeRegister` helper uses the `ts` + * loader, which rejects JSX — hence a dedicated hook here. + */ +const registerTsxLoader = (): Effect.Effect<{ unregister: () => void }, PaywallBuildError> => + Effect.tryPromise({ + try: async () => { + const { register } = await import("esbuild-register/dist/node"); + return register({ format: "cjs", loader: "tsx" }); + }, + catch: (cause) => + new PaywallBuildError({ + cause, + message: "Failed to initialize the TypeScript/JSX loader.", + }), + }); + +const loadModuleDefault = (file: string): Effect.Effect => + Effect.try({ + try: () => { + delete require.cache[require.resolve(file)]; + const mod = require(file) as { default?: unknown }; + return mod?.default ?? mod; + }, + catch: (cause) => + new PaywallBuildError({ + cause, + message: `Failed to load ${file}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }), + }); + +const isScalar = (value: unknown): value is string | number | boolean => + typeof value === "string" || typeof value === "number" || typeof value === "boolean"; + +interface PaywallModuleMeta { + readonly title: string; + readonly description?: string; + readonly products: ReadonlyArray; + readonly variables: DeployVariables; +} + +/** Reads the `__voidhash` metadata off a paywall module's default export. */ +const loadPaywallMeta = (file: string): Effect.Effect => + loadModuleDefault(file).pipe( + Effect.flatMap((def) => { + const meta = (def as { __voidhash?: Record } | null | undefined)?.__voidhash; + if (!meta || meta.kind !== "paywall") { + return Effect.fail( + new PaywallBuildError({ + message: `${file} must default-export createPaywall({ … }) from "@voidhash/paywalls".`, + }), + ); + } + const title = + typeof meta.title === "string" && meta.title.length > 0 ? meta.title : idFromFile(file); + const description = typeof meta.description === "string" ? meta.description : undefined; + const products = Array.isArray(meta.products) + ? meta.products.filter((p): p is string => typeof p === "string") + : []; + const rawVariables = + typeof meta.variables === "object" && meta.variables !== null + ? (meta.variables as Record) + : {}; + const variables: Record = {}; + for (const [key, value] of Object.entries(rawVariables)) { + if (!isScalar(value)) { + return Effect.fail( + new PaywallBuildError({ + message: + `Variable "${key}" of paywall ${basename(file)} must be a ` + + "string, number or boolean (contract §1.1).", + }), + ); + } + variables[key] = value; + } + return Effect.succeed({ + description, + products, + title, + variables, + }); + }), + ); + +/** Loads a component module's default export and validates its shape. */ +const loadComponentDefinition = ( + file: string, +): Effect.Effect => + loadModuleDefault(file).pipe( + Effect.flatMap((def) => { + const candidate = def as Partial | null; + if ( + !candidate || + candidate.__voidhash?.kind !== "component" || + typeof candidate.component !== "function" || + typeof candidate.id !== "string" + ) { + return Effect.fail( + new PaywallBuildError({ + message: `${file} must default-export defineComponent({ … }) from "@voidhash/paywalls".`, + }), + ); + } + const expectedId = idFromFile(file); + if (candidate.id !== expectedId) { + return Effect.fail( + new PaywallBuildError({ + message: + `Component id "${candidate.id}" does not match its file name ` + + `"${expectedId}" (${basename(file)}). Rename the file or the id.`, + }), + ); + } + return Effect.succeed({ + ...candidate, + previews: candidate.previews ?? {}, + } as ComponentDefinitionLike); + }), + ); + +// ── Output writing ─────────────────────────────────────────────────────────── + +const writeFile = (absPath: string, bytes: Uint8Array): Effect.Effect => + Effect.tryPromise({ + try: async () => { + await fsp.mkdir(dirname(absPath), { recursive: true }); + await fsp.writeFile(absPath, bytes); + }, + catch: (cause) => new PaywallBuildError({ cause, message: `Failed to write ${absPath}` }), + }); + +/** Writes `bytes` to `absPath` and returns its manifest artifact entry. */ +const writeArtifact = ( + projectRoot: string, + absPath: string, + bytes: Uint8Array, +): Effect.Effect => + writeFile(absPath, bytes).pipe( + Effect.map(() => ({ + bytes: bytes.byteLength, + contentType: contentTypeFor(absPath), + path: toRelPosix(projectRoot, absPath), + sha256: sha256Hex(bytes), + })), + ); + +const readDeployFile = ( + projectRoot: string, + absPath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const bytes = await fsp.readFile(absPath); + return { + bytes: bytes.byteLength, + path: toRelPosix(projectRoot, absPath), + sha256: sha256Hex(bytes), + }; + }, + catch: (cause) => new PaywallBuildError({ cause, message: `Failed to read ${absPath}` }), + }); + +// ── Paywall bundling ───────────────────────────────────────────────────────── + +/** The WebView HTML shell that boots a compiled paywall bundle. */ +const htmlShell = (jsFileName: string): string => + ` + + + + + Voidhash Paywall + + + + +
+ + + +`; + +/** The in-memory entry esbuild bundles for a paywall. */ +const paywallEntryContents = (paywallAbsPath: string): string => + `import paywall from ${JSON.stringify(paywallAbsPath)}; +import { mountPaywall } from "@voidhash/paywalls/dom"; +const root = document.getElementById("root"); +if (root) mountPaywall(paywall, root); +`; + +interface BuiltPaywallArtifacts { + readonly htmlBytes: Uint8Array; + readonly jsBytes: Uint8Array; + readonly jsFileName: string; + readonly assets: ReadonlyArray<{ relName: string; bytes: Uint8Array }>; +} + +/** Bundles a single paywall to HTML + JS (+ assets) in memory via esbuild. */ +const bundlePaywall = ( + projectRoot: string, + voidhashDir: string, + paywallAbsPath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const result = await esbuild.build({ + assetNames: "assets/[name]-[hash]", + bundle: true, + define: { "process.env.NODE_ENV": '"production"' }, + format: "iife", + jsx: "automatic", + jsxImportSource: "react", + loader: PAYWALL_ASSET_LOADERS, + logLevel: "silent", + minify: true, + outdir: "out", + platform: "browser", + plugins: [closedImportsPlugin(voidhashDir)], + publicPath: ".", + stdin: { + contents: paywallEntryContents(paywallAbsPath), + loader: "tsx", + resolveDir: projectRoot, + sourcefile: "voidhash-entry.tsx", + }, + target: ["es2019", "safari13"], + write: false, + }); + + let jsBytes: Uint8Array | undefined; + const assets: Array<{ relName: string; bytes: Uint8Array }> = []; + + for (const file of result.outputFiles) { + const rel = file.path.split(sep).join(posix.sep); + if (rel.endsWith(".js")) { + jsBytes = file.contents; + } else { + // Asset emitted under out/assets/… — keep the assets/… suffix. + const idx = rel.indexOf("/assets/"); + const relName = idx >= 0 ? rel.slice(idx + 1) : posix.basename(rel); + assets.push({ bytes: file.contents, relName }); + } + } + + if (!jsBytes) { + throw new Error("esbuild produced no JavaScript output"); + } + + const jsFileName = "bundle.js"; + return { + assets, + htmlBytes: textEncoder.encode(htmlShell(jsFileName)), + jsBytes, + jsFileName, + }; + }, + catch: bundleFailure(`paywall ${basename(paywallAbsPath)}`), + }); + +// ── Component bundling ─────────────────────────────────────────────────────── + +/** Modules a component runtime bundle leaves to the consumer (Studio). */ +const COMPONENT_RUNTIME_EXTERNALS = [ + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "@voidhash/paywalls", + "@voidhash/paywalls/*", +]; + +const componentBuildOptions = (voidhashDir: string): esbuild.BuildOptions => ({ + bundle: true, + define: { "process.env.NODE_ENV": '"production"' }, + external: [...COMPONENT_RUNTIME_EXTERNALS], + format: "esm", + jsx: "automatic", + jsxImportSource: "react", + loader: COMPONENT_ASSET_LOADERS, + logLevel: "silent", + minify: true, + platform: "browser", + plugins: [closedImportsPlugin(voidhashDir)], + target: ["es2020"], + write: false, +}); + +/** + * The exact module specifiers the studio panel sandbox's `require` shim + * resolves (`@voidhash/paywalls/sandbox`'s `modules` map). A panel bundle MUST + * leave every one of these external so the shim satisfies it at evaluation + * time; a bundled copy would ship a second React/SDK instance and break hooks. + * Kept an explicit list (not the runtime's `@voidhash/paywalls/*` glob) so it + * mirrors the shim's keys one-for-one. + */ +export const PANEL_SANDBOX_EXTERNALS = [ + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "@voidhash/paywalls", + "@voidhash/paywalls/panel", + "@voidhash/paywalls/jsx-runtime", + "@voidhash/paywalls/jsx-dev-runtime", +]; + +/** + * esbuild settings for the panel bundle. It mirrors the studio's BROWSER + * compile pipeline (`code-mode/compile-pipeline.ts`) exactly so the emitted + * module is byte-compatible with what the panel sandbox evaluates: + * + * - `format: "cjs"` — the sandbox runs it via `new Function("require", + * "module", "exports", code)` and reads `module.exports.default`, so ES + * module syntax cannot be used. + * - `jsxImportSource: "@voidhash/paywalls"` — JSX compiles to + * `require("@voidhash/paywalls/jsx-runtime")`, which the shim resolves to the + * sandbox's single shared React (matching the browser transform). + * - externals = {@link PANEL_SANDBOX_EXTERNALS}, the shim's module keys. + */ +const panelBuildOptions = (voidhashDir: string): esbuild.BuildOptions => ({ + bundle: true, + define: { "process.env.NODE_ENV": '"production"' }, + external: [...PANEL_SANDBOX_EXTERNALS], + format: "cjs", + jsx: "automatic", + jsxImportSource: "@voidhash/paywalls", + loader: COMPONENT_ASSET_LOADERS, + logLevel: "silent", + minify: true, + platform: "browser", + plugins: [closedImportsPlugin(voidhashDir)], + target: ["es2020"], + write: false, +}); + +/** + * Whether a component definition declares a custom editor panel — decided the + * SAME way the studio's browser pipeline does (`@voidhash/paywalls`'s + * `definitionHasPanel`): a live `panel` FUNCTION, not merely a present key. + * Only a `hasPanel` component emits/uploads the `panel.js` artifact. + */ +export const definitionHasPanel = (definition: { readonly panel?: unknown }): boolean => + typeof definition.panel === "function"; + +const firstJsOutput = (result: esbuild.BuildResult): Uint8Array => { + const file = (result.outputFiles ?? []).find((f) => f.path.endsWith(".js")); + if (!file) { + throw new Error("esbuild produced no JavaScript output"); + } + return file.contents; +}; + +/** Bundles a component module to a single ESM `runtime.js`. */ +const bundleComponentRuntime = ( + voidhashDir: string, + componentAbsPath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => + firstJsOutput( + await esbuild.build({ + ...componentBuildOptions(voidhashDir), + entryPoints: [componentAbsPath], + outdir: "out", + }), + ), + catch: bundleFailure(`component ${basename(componentAbsPath)}`), + }); + +/** + * Bundles a component's custom editor panel: the WHOLE definition module as a + * single CJS module whose `default` export is the `defineComponent({ … })` + * definition — identical in shape to what the studio's browser compile + * pipeline produces. The panel sandbox evaluates this module, reads the + * definition off `module.exports.default`, and drives `definition.panel` live; + * it needs the full definition (props + panel + render), not the panel tree + * alone. Uses {@link panelBuildOptions} (CJS, `@voidhash/paywalls` JSX, shim + * externals) so the byte output matches the sandbox's require shim exactly. + */ +const bundleComponentPanel = ( + voidhashDir: string, + componentAbsPath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => + firstJsOutput( + await esbuild.build({ + ...panelBuildOptions(voidhashDir), + entryPoints: [componentAbsPath], + outdir: "out", + }), + ), + catch: bundleFailure(`panel of component ${basename(componentAbsPath)}`), + }); + +// ── Preview tree inspection ────────────────────────────────────────────────── + +/** The one placeholder reason that is NOT a render error (§3). */ +const LEGITIMATE_NULL_REASON = "render returned null"; + +/** + * Collects the reasons of placeholder nodes a §3 preview tree contains that + * were produced by render ERRORS — a thrown render (`"render threw: …"`), an + * unsupported element type, … Placeholders carrying the legitimate + * `"render returned null"` reason are not errors and are skipped. + */ +export const collectRenderErrorPlaceholderReasons = (tree: unknown): string[] => { + const reasons: string[] = []; + const visit = (node: unknown): void => { + if (typeof node !== "object" || node === null) { + return; + } + if ("root" in node) { + visit(node.root); + } + if ( + "type" in node && + node.type === "placeholder" && + "reason" in node && + typeof node.reason === "string" && + node.reason !== LEGITIMATE_NULL_REASON + ) { + reasons.push(node.reason); + } + if ("children" in node && Array.isArray(node.children)) { + for (const child of node.children) { + visit(child); + } + } + }; + visit(tree); + return reasons; +}; + +// ── Validation helpers ─────────────────────────────────────────────────────── + +const validateIds = ( + kind: "paywall" | "component", + files: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* validateIds() { + const seen = new Map(); + for (const file of files) { + const id = idFromFile(file); + if (!DEPLOY_SLUG_REGEX.test(id)) { + return yield* Effect.fail( + new PaywallBuildError({ + message: + `Invalid ${kind} id "${id}" (${basename(file)}). Ids derive ` + + `from file names and must match ${DEPLOY_SLUG_REGEX}.`, + }), + ); + } + const existing = seen.get(id); + if (existing !== undefined) { + return yield* Effect.fail( + new PaywallBuildError({ + message: `Duplicate ${kind} id "${id}" (${existing} and ${file}).`, + }), + ); + } + seen.set(id, file); + } + }); + +// ── Public API ─────────────────────────────────────────────────────────────── + +export interface BuildPaywallsOptions { + readonly projectRoot: string; + readonly team: string; + readonly project: string; + readonly cliVersion: string; + readonly runtimeVersion: string; + /** Non-fatal warning callback (e.g. `Console.log`). */ + readonly onWarn?: (message: string) => Effect.Effect; +} + +export interface BuildPaywallsResult { + readonly manifest: DeployManifest; + /** Absolute path to the build output directory. */ + readonly outDir: string; + /** Absolute path to the written manifest.json. */ + readonly manifestPath: string; +} + +/** + * Compiles every paywall and component in `.voidhash` into deployable + * artifacts and writes the content-addressed schemaVersion-2 + * {@link DeployManifest} — the exact payload `voidhash-cli deploy` uploads. + * Output lands in {@link BUILD_DIR}: + * + * - `paywalls//` — `index.html`, `bundle.js`, `assets/…` + * - `components//` — `manifest.json`, `previews/.json`, + * `runtime.js` and (when declared) `panel.js` + * - `manifest.json` — the assembled deploy manifest + * + * The build runs a TypeScript gate over all discovered sources first, and + * every bundle enforces the closed-import rules (only `@voidhash/paywalls`, + * React's runtime entries and relative imports within `.voidhash`). + */ +export const buildPaywalls = ({ + projectRoot, + team, + project, + cliVersion, + runtimeVersion, + onWarn, +}: BuildPaywallsOptions): Effect.Effect => + Effect.gen(function* buildPaywalls() { + const warn = (message: string): Effect.Effect => (onWarn ? onWarn(message) : Effect.void); + const voidhashDir = join(projectRoot, ".voidhash"); + const paywallsDir = join(voidhashDir, "paywalls"); + const componentsDir = join(voidhashDir, "components"); + const outDir = join(projectRoot, BUILD_DIR); + + const paywallFiles = listSourceFiles(paywallsDir); + const componentFiles = listSourceFiles(componentsDir); + + if (paywallFiles.length === 0 && componentFiles.length === 0) { + return yield* Effect.fail( + new PaywallBuildError({ + message: `No paywalls or components found in ${voidhashDir}.`, + }), + ); + } + + yield* validateIds("paywall", paywallFiles); + yield* validateIds("component", componentFiles); + + // Typecheck gate: fail fast, before any bundling. + yield* typecheckPaywallSources({ + files: [...paywallFiles, ...componentFiles], + projectRoot, + }).pipe( + Effect.catchTag("PaywallTypecheckError", (e) => + Effect.fail(new PaywallBuildError({ cause: e.cause, message: e.message })), + ), + ); + + // Clear any previous build so removed paywalls/components don't linger. + yield* Effect.tryPromise({ + try: () => fsp.rm(outDir, { force: true, recursive: true }), + catch: (cause) => new PaywallBuildError({ cause, message: "Failed to clean build dir" }), + }); + + // Register esbuild so we can `require` paywall/component modules (JSX) to + // read metadata and render preview trees. + const { unregister } = yield* registerTsxLoader(); + + // ── Paywalls ───────────────────────────────────────────────────────────── + + const assetIndex = new Map(); + const paywalls: DeployPaywall[] = []; + + for (const file of paywallFiles) { + const id = idFromFile(file); + const meta = yield* loadPaywallMeta(file); + const built = yield* bundlePaywall(projectRoot, voidhashDir, file); + + const paywallOutDir = join(outDir, "paywalls", id); + const html = yield* writeArtifact( + projectRoot, + join(paywallOutDir, "index.html"), + built.htmlBytes, + ); + const js = yield* writeArtifact( + projectRoot, + join(paywallOutDir, built.jsFileName), + built.jsBytes, + ); + + const referencedAssets: string[] = []; + for (const asset of built.assets) { + const deployAsset = yield* writeArtifact( + projectRoot, + join(paywallOutDir, asset.relName), + asset.bytes, + ); + assetIndex.set(deployAsset.path, deployAsset); + referencedAssets.push(deployAsset.path); + } + referencedAssets.sort(); + + const source = yield* readDeployFile(projectRoot, file); + + paywalls.push({ + artifacts: { html, js }, + assets: referencedAssets, + contentHash: computePaywallContentHash({ + assetSha256s: referencedAssets.map((path) => assetIndex.get(path)?.sha256 ?? ""), + htmlSha256: html.sha256, + jsSha256: js.sha256, + }), + description: meta.description, + id, + products: meta.products, + source, + title: meta.title, + variables: meta.variables, + }); + } + + // ── Components ─────────────────────────────────────────────────────────── + + const components: DeployComponent[] = []; + + if (componentFiles.length > 0) { + const paywallsLib = yield* requireFromProject( + projectRoot, + "@voidhash/paywalls", + ); + const treeLib = yield* requireFromProject( + projectRoot, + "@voidhash/paywalls/tree", + ); + const react = yield* requireFromProject(projectRoot, "react"); + + for (const file of componentFiles) { + const id = idFromFile(file); + const definition = yield* loadComponentDefinition(file); + const componentOutDir = join(outDir, "components", id); + + // §2 component manifest. + const manifestJson = yield* Effect.try({ + try: () => paywallsLib.extractComponentManifest(definition), + catch: (cause) => + new PaywallBuildError({ + cause, + message: + `Failed to extract the manifest of component "${id}"` + + (cause instanceof Error ? `: ${cause.message}` : "."), + }), + }); + const manifest = yield* writeArtifact( + projectRoot, + join(componentOutDir, "manifest.json"), + textEncoder.encode(`${JSON.stringify(manifestJson, null, 2)}\n`), + ); + + // §3 preview trees — one per declared state, always including + // "default" (rendered with prop defaults when not declared). + const previewStates: Record = { + default: definition.previews.default ?? {}, + ...definition.previews, + }; + const previews: DeployComponentPreview[] = []; + for (const [state, preview] of Object.entries(previewStates)) { + const tree = yield* Effect.tryPromise({ + try: () => + treeLib.renderToNodeTree( + react.createElement(definition.component, preview.props ?? {}), + { + config: { + products: preview.data?.products ?? [], + variables: preview.data?.variables ?? {}, + platform: preview.data?.platform, + safeAreaInsets: preview.data?.safeAreaInsets, + dimensions: preview.data?.dimensions, + }, + state, + }, + ), + catch: (cause) => + new PaywallBuildError({ + cause, + message: `Failed to render preview "${state}" of component "${id}".`, + }), + }); + // A placeholder produced by a render error (thrown render, + // unsupported element) still yields a valid tree — surface it so + // authors don't ship broken previews silently. + for (const reason of collectRenderErrorPlaceholderReasons(tree)) { + yield* warn( + `Component "${id}" preview "${state}" contains a render-error ` + + `placeholder: ${reason}`, + ); + } + + const previewFile = yield* writeArtifact( + projectRoot, + join(componentOutDir, "previews", `${state}.json`), + textEncoder.encode(`${JSON.stringify(tree, null, 2)}\n`), + ); + previews.push({ file: previewFile, state }); + } + + // Runtime bundle (and panel bundle, when declared). + const runtimeBytes = yield* bundleComponentRuntime(voidhashDir, file); + const runtime = yield* writeArtifact( + projectRoot, + join(componentOutDir, "runtime.js"), + runtimeBytes, + ); + + // Emit + upload the panel.js artifact ONLY for a component with a live + // `panel` function (the same `hasPanel` test the browser pipeline uses), + // per the reserved `artifacts.panel` contract field. + let panel: DeployArtifact | null = null; + if (definitionHasPanel(definition)) { + const panelBytes = yield* bundleComponentPanel(voidhashDir, file); + panel = yield* writeArtifact(projectRoot, join(componentOutDir, "panel.js"), panelBytes); + } + + const source = yield* readDeployFile(projectRoot, file); + + components.push({ + artifacts: { panel, runtime }, + contentHash: computeComponentContentHash({ + manifestSha256: manifest.sha256, + panelSha256: panel?.sha256 ?? null, + previewSha256s: previews.map((p) => p.file.sha256), + runtimeSha256: runtime.sha256, + }), + id, + manifest, + previews, + source, + title: definition.title, + }); + } + } + + yield* Effect.sync(() => unregister()); + + // ── Manifest ───────────────────────────────────────────────────────────── + + const configFile = ["ts", "js", "cjs", "mjs"] + .map((ext) => join(projectRoot, `voidhash.config.${ext}`)) + .find((p) => existsSync(p)); + if (!configFile) { + return yield* Effect.fail( + new PaywallBuildError({ + message: "voidhash.config.* not found. Run 'voidhash-cli init' first.", + }), + ); + } + const config = yield* readDeployFile(projectRoot, configFile); + + const manifest: DeployManifest = { + assets: [...assetIndex.values()].sort((a, b) => a.path.localeCompare(b.path)), + cliVersion, + components, + config, + createdAt: new Date().toISOString(), + paywalls, + project, + runtimeVersion, + schemaVersion: DEPLOY_MANIFEST_VERSION, + team, + }; + + // Self-check against the contract schema before writing — a manifest the + // server would reject should never leave the build. + yield* Schema.decodeUnknownEffect(DeployManifestSchema)(manifest).pipe( + Effect.mapError( + (cause) => + new PaywallBuildError({ + cause, + message: `The build produced an invalid deploy manifest: ${cause.message}`, + }), + ), + ); + + const manifestPath = join(outDir, "manifest.json"); + yield* writeFile(manifestPath, textEncoder.encode(`${JSON.stringify(manifest, null, 2)}\n`)); + + return { manifest, manifestPath, outDir }; + }); diff --git a/apps/cli/src/domain/services/paywall-closed-imports.ts b/apps/cli/src/domain/services/paywall-closed-imports.ts new file mode 100644 index 000000000..0a68518cf --- /dev/null +++ b/apps/cli/src/domain/services/paywall-closed-imports.ts @@ -0,0 +1,117 @@ +/** + * Closed-import enforcement for `.voidhash` sources. Paywalls and components + * may only import the paywalls SDK, React's runtime entries, and each other — + * anything else (react-dom, lodash, app code outside `.voidhash`, …) fails the + * build with an error naming the offending import. + */ +import { realpathSync } from "node:fs"; +import { isAbsolute, resolve, sep } from "node:path"; + +import type * as esbuild from "esbuild"; + +/** Bare specifiers `.voidhash` sources may import. */ +export const ALLOWED_BARE_IMPORTS: ReadonlyArray = [ + "@voidhash/paywalls", + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", +]; + +const PAYWALLS_PACKAGE = "@voidhash/paywalls"; +/** The Node-only tree renderer never belongs in a shipped bundle. */ +const FORBIDDEN_PAYWALLS_SUBPATH = `${PAYWALLS_PACKAGE}/tree`; + +/** Resolves symlinks (macOS tmp dirs, pnpm) so containment checks compare real paths. */ +const toRealPath = (path: string): string => { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +}; + +const isPathWithin = (parent: string, child: string): boolean => { + const parentPath = toRealPath(parent); + const childPath = toRealPath(child); + return childPath === parentPath || childPath.startsWith(parentPath + sep); +}; + +const isAllowedBareImport = (specifier: string): boolean => { + if (ALLOWED_BARE_IMPORTS.includes(specifier)) { + return true; + } + // Any @voidhash/paywalls subpath except ./tree (dom, panel, …). + return specifier.startsWith(`${PAYWALLS_PACKAGE}/`) && specifier !== FORBIDDEN_PAYWALLS_SUBPATH; +}; + +const disallowedMessage = (specifier: string, importer: string): string => + `Import "${specifier}" (in ${importer}) is not allowed in .voidhash sources. ` + + `Allowed imports: ${ALLOWED_BARE_IMPORTS.join(", ")} ` + + `(any "@voidhash/paywalls/*" subpath except "@voidhash/paywalls/tree"), ` + + "plus relative imports within .voidhash."; + +/** + * An esbuild plugin that rejects any import from a `.voidhash` source file + * other than: + * + * - `@voidhash/paywalls` and its subpaths (except the Node-only `./tree`), + * - `react`, `react/jsx-runtime`, `react/jsx-dev-runtime`, + * - relative/absolute imports that stay within `voidhashDir` (components + * importing components is allowed in P1 — they are bundled together). + * + * Imports made by `node_modules` code (e.g. the SDK importing `react-dom` + * internally) are not constrained — only user-authored sources are. + * + * @param voidhashDir Absolute path to the project's `.voidhash` directory. + */ +export const closedImportsPlugin = (voidhashDir: string): esbuild.Plugin => ({ + name: "voidhash-closed-imports", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.kind === "entry-point") { + return null; + } + // Only user-authored sources are constrained. Synthetic stdin entries + // (non-absolute importer) count as user sources. + const fromUserSource = !isAbsolute(args.importer) || isPathWithin(voidhashDir, args.importer); + if (!fromUserSource) { + return null; + } + + const specifier = args.path; + + if (specifier.startsWith(".")) { + const target = resolve(args.resolveDir, specifier); + if (!isPathWithin(voidhashDir, target)) { + return { + errors: [ + { + text: + `Import "${specifier}" (in ${args.importer}) escapes the ` + + ".voidhash directory. Paywall sources may only import files " + + "within .voidhash.", + }, + ], + }; + } + return null; + } + + if (isAbsolute(specifier)) { + return isPathWithin(voidhashDir, specifier) + ? null + : { + errors: [{ text: disallowedMessage(specifier, args.importer) }], + }; + } + + if (isAllowedBareImport(specifier)) { + return null; + } + + return { + errors: [{ text: disallowedMessage(specifier, args.importer) }], + }; + }); + }, +}); diff --git a/apps/cli/src/domain/services/paywall-deploy-upload.ts b/apps/cli/src/domain/services/paywall-deploy-upload.ts new file mode 100644 index 000000000..fb904b99f --- /dev/null +++ b/apps/cli/src/domain/services/paywall-deploy-upload.ts @@ -0,0 +1,360 @@ +/** + * The deploy upload flow (contract §4): create the deploy from the manifest, + * upload whatever blobs the server is missing, then finalize. Transport + * follows the CLI's API conventions — `api_url` base + `x-api-key` header from + * the user's CLI config. + */ +import { promises as fsp } from "node:fs"; +import { join } from "node:path"; + +import { Data, Effect, Schema } from "effect"; +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"; + +import type { DeployManifest } from "../schema/paywall-deploy"; +import { CliConfig } from "./cli-config"; + +export class PaywallDeployUploadError extends Data.TaggedError("PaywallDeployUploadError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** `POST /api/v1/paywall-deploys` response (contract §4.1). */ +const CreateDeployResponseSchema = Schema.Struct({ + deployId: Schema.String, + /** Manifest file hashes the server does not already have for this project. */ + missing: Schema.Array(Schema.String), +}); +export type CreateDeployResponse = typeof CreateDeployResponseSchema.Type; + +const FinalizedPaywallSchema = Schema.Struct({ + id: Schema.String, + paywallId: Schema.String, + releaseId: Schema.String, + version: Schema.Number, + contentHash: Schema.String, + url: Schema.String, +}); +export type FinalizedPaywall = typeof FinalizedPaywallSchema.Type; + +const FinalizedComponentSchema = Schema.Struct({ + id: Schema.String, + componentId: Schema.String, + version: Schema.Number, + contentHash: Schema.String, +}); +export type FinalizedComponent = typeof FinalizedComponentSchema.Type; + +/** `POST /api/v1/paywall-deploys/:deployId/finalize` response (contract §4.3). */ +const FinalizeResponseSchema = Schema.Struct({ + deployId: Schema.String, + status: Schema.String, + paywalls: Schema.Array(FinalizedPaywallSchema), + components: Schema.Array(FinalizedComponentSchema), +}); +export type FinalizeResponse = typeof FinalizeResponseSchema.Type; + +/** + * Maps every file hash in the manifest to its project-root-relative path — + * the lookup used to satisfy the server's `missing` list. + */ +export const collectManifestFiles = (manifest: DeployManifest): Map => { + const files = new Map(); + const add = (file: { readonly path: string; readonly sha256: string } | null): void => { + if (file) { + files.set(file.sha256, file.path); + } + }; + for (const paywall of manifest.paywalls) { + add(paywall.source); + add(paywall.artifacts.html); + add(paywall.artifacts.js); + } + for (const component of manifest.components) { + add(component.source); + add(component.manifest); + for (const preview of component.previews) { + add(preview.file); + } + add(component.artifacts.runtime); + add(component.artifacts.panel); + } + add(manifest.config); + for (const asset of manifest.assets) { + add(asset); + } + return files; +}; + +const tryParseJson = (body: string): unknown => { + try { + return JSON.parse(body); + } catch { + return; + } +}; + +/** + * Extracts the `missing` hash list from a finalize `409` body (contract §4.3: + * `409 { missing: [...] }`). Returns `undefined` when the body carries no + * such list — callers then fall back to the generic failure path. + */ +const readMissingHashes = (body: string): string[] | undefined => { + const parsed = tryParseJson(body); + if (typeof parsed !== "object" || parsed === null || !("missing" in parsed)) { + return; + } + const missing = parsed.missing; + if ( + !Array.isArray(missing) || + missing.length === 0 || + !missing.every((hash): hash is string => typeof hash === "string") + ) { + return; + } + return missing; +}; + +/** Renders a non-2xx response into an actionable message (esp. 422 details). */ +const describeHttpFailure = (step: string, status: number, body: string): string => { + const parsed = tryParseJson(body); + const details = parsed !== undefined ? JSON.stringify(parsed, null, 2) : body.trim(); + const hint = + status === 400 + ? " The server rejected the manifest — your CLI may be outdated; try upgrading voidhash-cli." + : status === 401 + ? " Authentication failed. Run 'voidhash-cli auth login' and retry." + : status === 403 + ? " Check that the team/project in voidhash.config.ts match a project you have access to." + : status === 409 + ? " The deploy is incomplete (blobs missing server-side). Re-run deploy to retry." + : status === 422 + ? " The server rejected the deploy contents:" + : ""; + return `${step} failed with status ${status}.${hint}${details ? `\n${details}` : ""}`; +}; + +const failHttp = ( + step: string, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect => + response.text.pipe( + Effect.orElseSucceed(() => ""), + Effect.flatMap((body) => + Effect.fail( + new PaywallDeployUploadError({ + message: describeHttpFailure(step, response.status, body), + }), + ), + ), + ); + +const networkFailure = (step: string) => (cause: unknown) => + new PaywallDeployUploadError({ + cause, + message: `${step} failed: could not reach the Voidhash API.`, + }); + +const decodeJson = ( + step: string, + schema: S, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect => + response.json.pipe( + Effect.mapError(networkFailure(step)), + Effect.flatMap((json) => + Schema.decodeUnknownEffect(schema)(json).pipe( + Effect.mapError( + (cause) => + new PaywallDeployUploadError({ + cause, + message: `${step} returned an unexpected response shape: ${cause.message}`, + }), + ), + ), + ), + ); + +export interface UploadPaywallDeployOptions { + readonly manifest: DeployManifest; + /** Absolute project root the manifest's relative paths resolve against. */ + readonly projectRoot: string; + /** Progress callback, e.g. `Console.log`. */ + readonly onProgress?: (message: string) => Effect.Effect; +} + +export interface UploadPaywallDeployResult { + readonly deployId: string; + readonly finalize: FinalizeResponse; + /** Blobs actually uploaded this run. */ + readonly uploadedCount: number; + /** Manifest files the server already had. */ + readonly cachedCount: number; +} + +/** + * Runs the full contract-§4 deploy flow against the configured Voidhash API: + * + * 1. `POST /api/v1/paywall-deploys` with the manifest → `{ deployId, missing }` + * 2. `PUT /api/v1/paywall-deploys/:deployId/blobs/:sha256` for each missing blob + * 3. `POST /api/v1/paywall-deploys/:deployId/finalize` → released versions/URLs + * + * Requires a logged-in CLI (an `x-api-key` credential in the CLI config). + */ +export const uploadPaywallDeploy = ({ + manifest, + projectRoot, + onProgress, +}: UploadPaywallDeployOptions): Effect.Effect< + UploadPaywallDeployResult, + PaywallDeployUploadError, + HttpClient.HttpClient | CliConfig +> => + Effect.gen(function* uploadPaywallDeploy() { + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* CliConfig; + + const config = yield* cliConfig.readConfig().pipe( + Effect.mapError( + (cause) => + new PaywallDeployUploadError({ + cause, + message: "Failed to read the CLI config.", + }), + ), + ); + if (!config.api_key) { + return yield* Effect.fail( + new PaywallDeployUploadError({ + message: "You must be logged in to deploy. Run 'voidhash-cli auth login' first.", + }), + ); + } + const apiKey = config.api_key; + + const send = ( + step: string, + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + httpClient + .execute( + request.pipe( + HttpClientRequest.prependUrl(config.api_url), + HttpClientRequest.setHeaders({ "x-api-key": apiKey }), + ), + ) + .pipe(Effect.mapError(networkFailure(step))); + + const report = (message: string): Effect.Effect => + onProgress ? onProgress(message) : Effect.void; + + // 1. Create the deploy from the manifest. + const createStep = "Creating the deploy"; + const createResponse = yield* send( + createStep, + HttpClientRequest.post("/api/v1/paywall-deploys").pipe( + HttpClientRequest.bodyJsonUnsafe(manifest), + ), + ); + if (createResponse.status < 200 || createResponse.status >= 300) { + return yield* failHttp(createStep, createResponse); + } + const created = yield* decodeJson(createStep, CreateDeployResponseSchema, createResponse); + + // 2. Upload every blob the server is missing. + const filesByHash = collectManifestFiles(manifest); + + const uploadBlob = (sha256: string): Effect.Effect => + Effect.gen(function* uploadBlob() { + const relPath = filesByHash.get(sha256); + if (relPath === undefined) { + return yield* Effect.fail( + new PaywallDeployUploadError({ + message: + `The server requested blob ${sha256}, which is not part of the ` + + "manifest. Re-run the build and deploy again.", + }), + ); + } + const bytes = yield* Effect.tryPromise({ + try: () => fsp.readFile(join(projectRoot, relPath)), + catch: (cause) => + new PaywallDeployUploadError({ + cause, + message: `Failed to read ${relPath} for upload.`, + }), + }); + const uploadStep = `Uploading ${relPath}`; + const uploadResponse = yield* send( + uploadStep, + HttpClientRequest.put(`/api/v1/paywall-deploys/${created.deployId}/blobs/${sha256}`).pipe( + HttpClientRequest.bodyUint8Array(bytes, "application/octet-stream"), + ), + ); + if (uploadResponse.status < 200 || uploadResponse.status >= 300) { + return yield* failHttp(uploadStep, uploadResponse); + } + }); + + yield* report( + `Uploading ${created.missing.length} blob(s) ` + + `(${filesByHash.size - created.missing.length} already on the server)…`, + ); + + for (const sha256 of created.missing) { + yield* uploadBlob(sha256); + } + + // 3. Finalize — the immutable commit point. A 409 with a `missing` list + // (e.g. a blob lost server-side between create and finalize) is retried + // ONCE after re-uploading exactly those blobs; a second failure surfaces + // the server's readable error, hashes included. + const finalizeStep = "Finalizing the deploy"; + const requestFinalize = (): Effect.Effect< + HttpClientResponse.HttpClientResponse, + PaywallDeployUploadError + > => + send( + finalizeStep, + HttpClientRequest.post(`/api/v1/paywall-deploys/${created.deployId}/finalize`), + ); + + let finalizeResponse = yield* requestFinalize(); + let retriedUploadCount = 0; + + if (finalizeResponse.status === 409) { + const body = yield* finalizeResponse.text.pipe(Effect.orElseSucceed(() => "")); + const missingOnFinalize = readMissingHashes(body); + if ( + missingOnFinalize === undefined || + missingOnFinalize.some((sha256) => !filesByHash.has(sha256)) + ) { + return yield* Effect.fail( + new PaywallDeployUploadError({ + message: describeHttpFailure(finalizeStep, 409, body), + }), + ); + } + + yield* report( + `Finalize reported ${missingOnFinalize.length} missing blob(s); ` + + "re-uploading and retrying once…", + ); + for (const sha256 of missingOnFinalize) { + yield* uploadBlob(sha256); + retriedUploadCount += 1; + } + finalizeResponse = yield* requestFinalize(); + } + + if (finalizeResponse.status < 200 || finalizeResponse.status >= 300) { + return yield* failHttp(finalizeStep, finalizeResponse); + } + const finalize = yield* decodeJson(finalizeStep, FinalizeResponseSchema, finalizeResponse); + + return { + cachedCount: filesByHash.size - created.missing.length, + deployId: created.deployId, + finalize, + uploadedCount: created.missing.length + retriedUploadCount, + }; + }); diff --git a/apps/cli/src/domain/services/paywall-typecheck.ts b/apps/cli/src/domain/services/paywall-typecheck.ts new file mode 100644 index 000000000..e11c76617 --- /dev/null +++ b/apps/cli/src/domain/services/paywall-typecheck.ts @@ -0,0 +1,159 @@ +/** + * The deploy typecheck gate: before anything is bundled, the discovered + * `.voidhash` sources are typechecked with the TypeScript compiler API using + * the project's own `tsconfig.json`, and the build fails listing diagnostics. + */ +import { dirname, join } from "node:path"; + +import { Data, Effect } from "effect"; +import ts from "typescript"; + +export class PaywallTypecheckError extends Data.TaggedError("PaywallTypecheckError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** + * Asset extensions the build's esbuild loaders accept in paywall/component + * sources. The typecheck gate injects matching ambient module declarations so + * `import hero from "./hero.png"` typechecks, and the bundler emits/inlines + * the file. + */ +export const PAYWALL_ASSET_EXTENSIONS = [ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "ttf", + "otf", + "woff", + "woff2", +] as const; + +/** Ambient `declare module "*.png" { … }` block per supported asset extension. */ +const ASSET_MODULE_DECLARATIONS = PAYWALL_ASSET_EXTENSIONS.map( + (ext) => `declare module "*.${ext}" {\n const url: string;\n export default url;\n}\n`, +).join("\n"); + +/** + * Virtual file name (resolved under the project root) for the injected asset + * declarations. Never written to disk — served from memory by the gate's + * compiler host. + */ +const ASSET_DECLARATIONS_FILE_NAME = "__voidhash-asset-modules__.d.ts"; + +/** Options used when the project has no `tsconfig.json` to inherit from. */ +const FALLBACK_OPTIONS: ts.CompilerOptions = { + jsx: ts.JsxEmit.ReactJSX, + lib: ["lib.es2022.d.ts", "lib.dom.d.ts"], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + target: ts.ScriptTarget.ES2022, +}; + +const formatHost: ts.FormatDiagnosticsHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => ts.sys.newLine, +}; + +const loadCompilerOptions = ( + projectRoot: string, +): { options: ts.CompilerOptions; configPath: string | undefined } => { + const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, "tsconfig.json"); + if (!configPath) { + return { configPath: undefined, options: { ...FALLBACK_OPTIONS } }; + } + + const read = ts.readConfigFile(configPath, ts.sys.readFile); + if (read.error) { + throw new Error(ts.formatDiagnostics([read.error], formatHost)); + } + const parsed = ts.parseJsonConfigFileContent( + read.config, + ts.sys, + dirname(configPath), + undefined, + configPath, + ); + // "no inputs were found" (18003) is irrelevant — we supply our own roots. + const configErrors = parsed.errors.filter((e) => e.code !== 18_003); + if (configErrors.length > 0) { + throw new Error(ts.formatDiagnostics(configErrors, formatHost)); + } + return { configPath, options: parsed.options }; +}; + +/** + * Wraps a compiler host so the in-memory asset declaration file exists at + * `assetDeclPath` without ever touching disk. + */ +const withAssetDeclarations = (host: ts.CompilerHost, assetDeclPath: string): ts.CompilerHost => { + const getSourceFile = host.getSourceFile.bind(host); + const fileExists = host.fileExists.bind(host); + const readFile = host.readFile.bind(host); + return { + ...host, + fileExists: (fileName) => fileName === assetDeclPath || fileExists(fileName), + getSourceFile: (fileName, languageVersionOrOptions, ...rest) => + fileName === assetDeclPath + ? ts.createSourceFile(fileName, ASSET_MODULE_DECLARATIONS, languageVersionOrOptions, true) + : getSourceFile(fileName, languageVersionOrOptions, ...rest), + readFile: (fileName) => + fileName === assetDeclPath ? ASSET_MODULE_DECLARATIONS : readFile(fileName), + }; +}; + +/** + * Typechecks the given `.voidhash` source files with the project's + * `tsconfig.json` (falling back to strict react-jsx defaults when the project + * has none). An in-memory ambient declaration file covering the + * esbuild-supported asset extensions ({@link PAYWALL_ASSET_EXTENSIONS}) is + * injected so asset imports typecheck as string default exports. Fails with a + * {@link PaywallTypecheckError} listing every error-severity diagnostic. + */ +export const typecheckPaywallSources = (options: { + readonly projectRoot: string; + readonly files: ReadonlyArray; +}): Effect.Effect => + Effect.try({ + try: () => { + const { options: compilerOptions } = loadCompilerOptions(options.projectRoot); + + const finalOptions: ts.CompilerOptions = { + ...compilerOptions, + // The gate only checks — never emit, and never stumble over + // third-party declaration files. + incremental: false, + jsx: compilerOptions.jsx ?? ts.JsxEmit.ReactJSX, + noEmit: true, + skipLibCheck: true, + }; + + const assetDeclPath = join(options.projectRoot, ASSET_DECLARATIONS_FILE_NAME); + const program = ts.createProgram({ + host: withAssetDeclarations(ts.createCompilerHost(finalOptions), assetDeclPath), + options: finalOptions, + rootNames: [...options.files, assetDeclPath], + }); + + const diagnostics = ts + .getPreEmitDiagnostics(program) + .filter((d) => d.category === ts.DiagnosticCategory.Error); + + if (diagnostics.length > 0) { + throw new Error( + `TypeScript found ${diagnostics.length} error(s) in .voidhash sources:\n\n` + + ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost), + ); + } + }, + catch: (cause) => + new PaywallTypecheckError({ + cause, + message: cause instanceof Error ? cause.message : "Failed to typecheck .voidhash sources.", + }), + }); diff --git a/apps/cli/src/domain/services/schema.ts b/apps/cli/src/domain/services/schema.ts index 846f3da56..1fd76b2d6 100644 --- a/apps/cli/src/domain/services/schema.ts +++ b/apps/cli/src/domain/services/schema.ts @@ -1,247 +1,121 @@ -import type { ChangesetSchema } from "@voidhash/shared"; -import { Effect, Layer, Schedule, ServiceMap } from "effect"; +import { Effect, Layer, Context } from "effect"; import { ApiClient } from "../../utils/api-client"; +import { RemoteSchemaFetchError } from "../errors/schema"; import { - buildChangeset, - formatChange, -} from "../../utils/schema/changeset-builder"; -import { computeDiff, summarizeDiff } from "../../utils/schema/diff"; -import { loadLocalSchema } from "../../utils/schema/local-schema-loader"; -import { - ChangeDeploymentError, - RemoteSchemaFetchError, -} from "../errors/schema"; -import { - type ProviderId, - createEmptyNormalizedSchema, + type ProviderId, + createEmptyNormalizedSchema, + type NormalizedSchema, } from "../schema/normalized-schema"; -// Re-export types for convenience -export type { SchemaDiff } from "../../utils/schema/diff"; -export { formatChange } from "../../utils/schema/changeset-builder"; - -type Change = (typeof ChangesetSchema.Type)["changes"][number]; - const make = Effect.gen(function* effect() { - const apiClient = yield* ApiClient; - - /** - * Fetch the remote schema from the API - */ - const fetchRemoteSchema = () => - Effect.gen(function* fetchRemoteSchema() { - yield* Effect.logDebug("Fetching remote schema from API"); - const schema = createEmptyNormalizedSchema(); - - // 1. Fetch all perks - const remotePerks = yield* apiClient.perks.listPerks(); - for (const perk of remotePerks) { - schema.perks.set(perk.slug, { - name: perk.name, - slug: perk.slug, - }); - } - - // 1b. Fetch all active paywall locations - const remoteLocations = - yield* apiClient.paywall_locations.listPaywallLocations(); - for (const location of remoteLocations) { - schema.locations.set(location.slug, { - description: location.description, - name: location.name, - slug: location.slug, - }); - } - - // 2. Fetch all products - const remoteProducts = yield* apiClient.products.listProducts(); - - // 3. Fetch payment provider configurations - const providerConfigs = - yield* apiClient.payment_provider_configurations.listPaymentProviderConfigurations(); - for (const config of providerConfigs) { - if ( - config.providerId === "appleAppStore" || - config.providerId === "googlePlay" - ) { - schema.enabledProviders.add(config.providerId); - } - } - - // 4. Fetch all payment provider products - const providerProducts = - yield* apiClient.payment_provider_products.listPaymentProviderProducts(); - - // Build a map of productId -> provider products - const productProviderMap = new Map< - string, - { providerId: ProviderId; configuration: Record }[] - >(); - for (const pp of providerProducts) { - if ( - pp.providerId !== "appleAppStore" && - pp.providerId !== "googlePlay" - ) { - continue; - } - const existing = productProviderMap.get(pp.productId) || []; - existing.push({ - configuration: pp.configuration as Record, - providerId: pp.providerId, - }); - productProviderMap.set(pp.productId, existing); - } - - // 5. For each product, fetch its perks - - yield* Effect.all( - remoteProducts.map((product) => - Effect.gen(function* () { - const productPerks = yield* apiClient.product_perks - .listProductPerksByProductId({ - params: { productId: product.id }, - }) - .pipe( - Effect.retry({ - schedule: Schedule.exponential(1000), - times: 3, - }), - ); - - // Map perkIds to slugs - const perkSlugs: string[] = []; - for (const pp of productPerks) { - const perk = remotePerks.find((p) => p.id === pp.perkId); - if (perk) { - perkSlugs.push(perk.slug); - } - } - - schema.products.set(product.slug, { - name: product.name, - perks: perkSlugs, - providers: productProviderMap.get(product.id) || [], - slug: product.slug, - type: "subscription", // TODO: map from product.type - }); - }), - ), - { - concurrency: 8, - }, - ); - - yield* Effect.logDebug( - `Fetched ${schema.locations.size} locations, ${schema.perks.size} perks, ${schema.products.size} products` - ); - return schema; - }).pipe( - Effect.withSpan("SchemaService.fetchRemoteSchema"), - Effect.catch( - (e) => - Effect.fail(new RemoteSchemaFetchError({ - cause: e, - })), - ), - ); - - /** - * Fetch payment provider configurations - */ - const fetchProviderConfigurations = () => - apiClient.payment_provider_configurations - .listPaymentProviderConfigurations() - .pipe( - Effect.tap((configs) => - Effect.logDebug( - `Fetched ${configs.length} provider configurations` - ) - ), - Effect.withSpan("SchemaService.fetchProviderConfigurations"), - Effect.catch( - (e) => - Effect.fail(new RemoteSchemaFetchError({ - cause: e, - })), - ), - ); - /** - * Check which providers are missing configurations - */ - const checkProviderConfigurations = ( - localProviders: Set, - remoteConfigs: readonly { providerId: string }[], - ): ProviderId[] => { - const remoteProviderIds = new Set( - remoteConfigs.map((c) => c.providerId), - ); - return [...localProviders].filter( - (p) => !remoteProviderIds.has(p), - ) as ProviderId[]; - }; - - /** - * Deploy a single change to the server - */ - const deployChange = (change: Change) => - Effect.logDebug(`Deploying change: ${formatChange(change)}`).pipe( - Effect.andThen( - apiClient.changesets.deployChangeset({ - payload: { changeset: { changes: [change] } }, - }) - ), - Effect.withSpan("SchemaService.deployChange"), - Effect.catch( - (e) => - Effect.fail(new ChangeDeploymentError({ - cause: e, - change: formatChange(change), - })) - ) - ); - - /** - * Deploy an entire changeset to the server - */ - const deployChangeset = (changeset: typeof ChangesetSchema.Type) => - Effect.logDebug( - `Deploying changeset with ${changeset.changes.length} changes` - ).pipe( - Effect.andThen( - apiClient.changesets.deployChangeset({ - payload: { changeset }, - }) - ), - Effect.withSpan("SchemaService.deployChangeset"), - Effect.catch( - (e) => - Effect.fail(new ChangeDeploymentError({ - cause: e, - change: "Full changeset deployment", - })) - ) - ); - - return { - buildChangeset, - checkProviderConfigurations, - computeDiff, - deployChange, - deployChangeset, - fetchProviderConfigurations, - fetchRemoteSchema, - loadLocalSchema, - summarizeDiff, - } as const; + const apiClient = yield* ApiClient; + + /** + * Fetch the full schema (perks, locations, products, provider configs) from + * the server's consolidated `GET /api/v1/schema` endpoint and project it + * into the CLI's `NormalizedSchema`. + * + * Replaces the five round-trips the CLI used to make against the + * per-entity endpoints. The server is now the canonical source for the + * version hash too — we no longer re-derive it on the client. + */ + const fetchRemoteSchema = () => + Effect.gen(function* fetchRemoteSchema() { + yield* Effect.logDebug("Fetching remote schema from API"); + const response = yield* apiClient.schemaGetSchema(); + + const schema: NormalizedSchema = createEmptyNormalizedSchema(); + + for (const perk of response.perks) { + schema.perks.set(perk.slug, { + name: perk.name, + slug: perk.slug, + }); + } + + for (const location of response.locations) { + schema.locations.set(location.slug, { + description: location.description, + name: location.name, + slug: location.slug, + }); + } + + const SUPPORTED_PROVIDER_IDS: ReadonlySet = new Set([ + "appleAppStore", + "googlePlay", + ]); + + for (const product of response.products) { + schema.products.set(product.slug, { + name: product.name, + perks: [...product.perks], + providers: product.providers + .filter((provider) => SUPPORTED_PROVIDER_IDS.has(provider.providerId as string)) + .map((provider) => ({ + configuration: provider.configuration, + providerId: provider.providerId as ProviderId, + })), + slug: product.slug, + type: product.type, + }); + } + + for (const providerId of response.enabledProviders) { + schema.enabledProviders.add(providerId); + } + + yield* Effect.logDebug( + `Fetched ${schema.locations.size} locations, ${schema.perks.size} perks, ${schema.products.size} products`, + ); + + // The server-side version is the canonical hash and trumps any local + // re-derivation. Surface it so callers (codegen, `types check`) can + // bake it into the `.d.ts` header / compare against the local one. + return { schema, version: response.version }; + }).pipe( + Effect.withSpan("SchemaService.fetchRemoteSchema"), + Effect.catch((e) => + Effect.fail( + new RemoteSchemaFetchError({ + cause: e, + }), + ), + ), + ); + + /** + * Cheap version probe used by `voidhash-cli types check`, the `--watch` poll + * loop, and (indirectly) the dev-mode SDK drift warning. Hits the dedicated + * `GET /api/v1/schema/version` endpoint so we don't ship the whole schema + * just to compare hashes. + */ + const fetchSchemaVersion = () => + Effect.gen(function* fetchSchemaVersion() { + const response = yield* apiClient.schemaGetSchemaVersion(); + return response.version; + }).pipe( + Effect.withSpan("SchemaService.fetchSchemaVersion"), + Effect.catch((e) => + Effect.fail( + new RemoteSchemaFetchError({ + cause: e, + }), + ), + ), + ); + + return { + fetchRemoteSchema, + fetchSchemaVersion, + } as const; }); type SchemaServiceShape = Effect.Success; -export class SchemaService extends ServiceMap.Service()( - "voidhash-cli/Schema" +export class SchemaService extends Context.Service()( + "voidhash-cli/Schema", ) { - static Default = Layer.effect(SchemaService, make).pipe( - Layer.provide(ApiClient.Default) - ) + static Default = Layer.effect(SchemaService, make).pipe(Layer.provide(ApiClient.Default)); } diff --git a/apps/cli/src/domain/services/source-code.ts b/apps/cli/src/domain/services/source-code.ts index efb083d01..c5bd0ef25 100644 --- a/apps/cli/src/domain/services/source-code.ts +++ b/apps/cli/src/domain/services/source-code.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema, Context } from "effect"; import { safeRegister } from "../../utils/js-loading/js-file-loading"; import { relativePathPrefixFromDepth } from "../../utils/source-code"; @@ -54,15 +54,11 @@ const make = Effect.gen(function* effect() { const pathPrefix = relativePathPrefixFromDepth(depth); // Check for monorepo indicators - const isPnpmWorkspace = yield* fs.exists( - path.resolve(pathPrefix, "pnpm-workspace.yaml") - ); + const isPnpmWorkspace = yield* fs.exists(path.resolve(pathPrefix, "pnpm-workspace.yaml")); const isYarnWorkspaces = yield* fs.exists( - path.resolve(pathPrefix, "yarn.workspaces.json") - ); - const isTurboRoot = yield* fs.exists( - path.resolve(pathPrefix, "turbo.json") + path.resolve(pathPrefix, "yarn.workspaces.json"), ); + const isTurboRoot = yield* fs.exists(path.resolve(pathPrefix, "turbo.json")); // Check for package.json with workspaces field const packageJsonPath = path.resolve(pathPrefix, "package.json"); @@ -72,18 +68,11 @@ const make = Effect.gen(function* effect() { if (packageJsonExists) { const packageJson = yield* loadPackageJson(pathPrefix); hasWorkspacesField = - (packageJson.workspaces !== undefined && - Array.isArray(packageJson.workspaces)) || - (packageJson.workspaces !== undefined && - typeof packageJson.workspaces === "object"); + (packageJson.workspaces !== undefined && Array.isArray(packageJson.workspaces)) || + (packageJson.workspaces !== undefined && typeof packageJson.workspaces === "object"); } - return ( - isPnpmWorkspace || - isYarnWorkspaces || - isTurboRoot || - hasWorkspacesField - ); + return isPnpmWorkspace || isYarnWorkspaces || isTurboRoot || hasWorkspacesField; }); // Check current directory and traverse up @@ -115,22 +104,20 @@ const make = Effect.gen(function* effect() { return yield* Effect.fail( new PackageJsonNotFoundError({ message: "Package JSON not found in this directory.", - }) + }), ); } const packageJson = yield* fs.readFileString(packageJsonPath); - return yield* Schema.decodeUnknownEffect(PackageJsonSchema)( - JSON.parse(packageJson) - ).pipe( + return yield* Schema.decodeUnknownEffect(PackageJsonSchema)(JSON.parse(packageJson)).pipe( Effect.catchTag("SchemaError", (e) => Effect.fail( new InvalidPackageJsonError({ cause: e, message: "Invalid package JSON", - }) - ) - ) + }), + ), + ), ); }).pipe( Effect.catchTag("PlatformError", (e) => @@ -138,9 +125,9 @@ const make = Effect.gen(function* effect() { new FailedToLoadPackageJsonError({ cause: e, message: "Failed to load package JSON", - }) - ) - ) + }), + ), + ), ); // =================================== @@ -185,7 +172,7 @@ const make = Effect.gen(function* effect() { return yield* Effect.fail( new NoPackageManagerFoundError({ message: "No package manager found in this directory.", - }) + }), ); }).pipe( Effect.catchTag("PlatformError", (e) => @@ -193,9 +180,9 @@ const make = Effect.gen(function* effect() { new FailedToDetectPackageManagerError({ cause: e, message: "Failed to detect package manager", - }) - ) - ) + }), + ), + ), ); // =================================== @@ -239,11 +226,11 @@ const make = Effect.gen(function* effect() { Effect.gen(function* existingPaths() { const exists = yield* fs.exists(path); return { exists, path }; - }) + }), ), { concurrency: "unbounded", - } + }, ); const existingPath = existingPaths.find((path) => path.exists)?.path; @@ -251,7 +238,7 @@ const make = Effect.gen(function* effect() { return yield* Effect.fail( new VoidhashConfigNotFoundError({ message: "Voidhash config not found", - }) + }), ); } @@ -267,25 +254,24 @@ const make = Effect.gen(function* effect() { Effect.fail( new FailedToLoadVoidhashConfigError({ cause: e, - message: - "There has been an error while trying to load the voidhash config.", - }) + message: "There has been an error while trying to load the voidhash config.", + }), ), PlatformError: (e) => Effect.fail( new FailedToLoadVoidhashConfigError({ cause: e, message: "Failed to load voidhash config", - }) + }), ), SchemaError: () => Effect.fail( new InvalidVoidhashConfigError({ message: "Could not parse voidhash config. Please check your voidhash.config.(ts|js|cjs|mjs) file is valid.", - }) + }), ), - }) + }), ); const deleteVoidhashConfig = () => @@ -307,8 +293,8 @@ const make = Effect.gen(function* effect() { type SourceCodeShape = Effect.Success; -export class SourceCode extends ServiceMap.Service()( - "voidhash-cli/SourceCode" +export class SourceCode extends Context.Service()( + "voidhash-cli/SourceCode", ) { - static Default = Layer.effect(SourceCode, make) + static Default = Layer.effect(SourceCode, make); } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d971dd272..2d388959b 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,7 +1,11 @@ interface Config { - schema: string; team: string; project: string; + /** + * Output path for the generated `.d.ts` declaration file. Defaults to + * `voidhash.gen.d.ts` at the project root. + */ + typesOutput?: string; } export const defineConfig = (config: Config) => config; diff --git a/apps/cli/src/services/auth/get-session.ts b/apps/cli/src/services/auth/get-session.ts index 03943b09f..eeed3882e 100644 --- a/apps/cli/src/services/auth/get-session.ts +++ b/apps/cli/src/services/auth/get-session.ts @@ -3,37 +3,46 @@ import { Effect } from "effect"; import { ApiClient } from "../../utils/api-client"; import { OrganizationServiceError } from "../organization/errors"; +const hasNestedTag = ( + error: unknown, + outerTag: string, + innerTag: string, +): error is { readonly _tag: string; readonly data: { readonly _tag: string } } => + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === outerTag && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "_tag" in error.data && + error.data._tag === innerTag; + export const getSession = Effect.gen(function* getSession() { const client = yield* ApiClient; return Effect.fn("getSession")( - function* getSession(input: { name: string }) { - const organization = yield* client.organizations.createOrganization({ - payload: { - name: input.name, - }, - }); - - return organization; + function* getSession() { + const session = yield* client.authSession(); + return session; }, (effect) => effect.pipe( Effect.catch((error) => { - if (error._tag === "NotAuthenticatedError") { + if (hasNestedTag(error, "AuthSession500", "NotAuthenticatedError")) { return Effect.fail( new OrganizationServiceError({ - message: - "Failed to create an organization because you are not authenticated.", - }) + message: "Failed to fetch the session because you are not authenticated.", + }), ); } return Effect.fail( new OrganizationServiceError({ message: - "Failed to create an organization because of an unknown error. Please try again. If the problem persists, please contact us at support@voidhash.com", - }) + "Failed to fetch the session because of an unknown error. Please try again. If the problem persists, please contact us at support@voidhash.com", + }), ); - }) - ) + }), + ), ); }); diff --git a/apps/cli/src/services/auth/index.ts b/apps/cli/src/services/auth/index.ts index 492774624..a22186a7c 100644 --- a/apps/cli/src/services/auth/index.ts +++ b/apps/cli/src/services/auth/index.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ServiceMap } from "effect"; +import { Effect, Layer, Context } from "effect"; const make = Effect.gen(function* scoped() { return {} as const; @@ -6,8 +6,8 @@ const make = Effect.gen(function* scoped() { type AuthServiceShape = Effect.Success; -export class AuthService extends ServiceMap.Service()( - "voidhash-cli/services/AuthService" +export class AuthService extends Context.Service()( + "voidhash-cli/services/AuthService", ) { - static Default = Layer.effect(AuthService, make) + static Default = Layer.effect(AuthService, make); } diff --git a/apps/cli/src/services/auth/utils/better-auth.ts b/apps/cli/src/services/auth/utils/better-auth.ts index a1e63da87..4df81b91a 100644 --- a/apps/cli/src/services/auth/utils/better-auth.ts +++ b/apps/cli/src/services/auth/utils/better-auth.ts @@ -1,12 +1,10 @@ +import { apiKeyClient } from "@better-auth/api-key/client"; import { createAuthClient } from "better-auth/client"; -import { apiKeyClient } from "better-auth/client/plugins"; -import { Data, Effect, Layer, ServiceMap } from "effect"; +import { Context, Data, Effect, Layer } from "effect"; import { CliConfig } from "../../../domain/services/cli-config"; -export class BetterAuthClientError extends Data.TaggedError( - "BetterAuthClientError" -)<{ +export class BetterAuthClientError extends Data.TaggedError("BetterAuthClientError")<{ readonly cause?: unknown; readonly message: string; }> {} @@ -23,8 +21,8 @@ const make = Effect.gen(function* effect() { return { use: ( fn: ( - client: typeof authClient - ) => Promise<{ error: E; data?: null } | { error?: null; data: D }> + client: typeof authClient, + ) => Promise<{ error: E; data?: null } | { error?: null; data: D }>, ) => Effect.tryPromise({ catch: (error) => @@ -45,8 +43,8 @@ const make = Effect.gen(function* effect() { type BetterAuthClientShape = Effect.Success; -export class BetterAuthClient extends ServiceMap.Service()( - "app/BetterAuthClient" +export class BetterAuthClient extends Context.Service()( + "app/BetterAuthClient", ) { - static Default = Layer.effect(BetterAuthClient, make) + static Default = Layer.effect(BetterAuthClient, make); } diff --git a/apps/cli/src/services/cli-config/index.ts b/apps/cli/src/services/cli-config/index.ts index b07018236..0dfddff68 100644 --- a/apps/cli/src/services/cli-config/index.ts +++ b/apps/cli/src/services/cli-config/index.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ServiceMap } from "effect"; +import { Effect, Layer, Context } from "effect"; const make = Effect.gen(function* scoped() { return {} as const; @@ -6,8 +6,8 @@ const make = Effect.gen(function* scoped() { type CliConfigServiceShape = Effect.Success; -export class CliConfigService extends ServiceMap.Service()( - "voidhash-cli/services/CliConfigService" +export class CliConfigService extends Context.Service()( + "voidhash-cli/services/CliConfigService", ) { - static Default = Layer.effect(CliConfigService, make) + static Default = Layer.effect(CliConfigService, make); } diff --git a/apps/cli/src/services/organization/create-organization.ts b/apps/cli/src/services/organization/create-organization.ts index 7bf2ed355..60df91445 100644 --- a/apps/cli/src/services/organization/create-organization.ts +++ b/apps/cli/src/services/organization/create-organization.ts @@ -3,14 +3,27 @@ import { Effect } from "effect"; import { ApiClient } from "../../utils/api-client"; import { OrganizationServiceError } from "./errors"; +const hasNestedTag = ( + error: unknown, + outerTag: string, + innerTag: string, +): error is { readonly _tag: string; readonly data: { readonly _tag: string } } => + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === outerTag && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "_tag" in error.data && + error.data._tag === innerTag; + export const createOrganization = Effect.gen(function* createOrganization() { const client = yield* ApiClient; return Effect.fn("createOrganization")( function* createOrganization(input: { name: string }) { - const organization = yield* client.organizations.createOrganization({ - payload: { - name: input.name, - }, + const organization = yield* client.organizationsCreateOrganization({ + name: input.name, }); return organization; @@ -18,12 +31,11 @@ export const createOrganization = Effect.gen(function* createOrganization() { (effect) => effect.pipe( Effect.catch((error) => { - if (error._tag === "NotAuthenticatedError") { + if (hasNestedTag(error, "OrganizationsCreateOrganization500", "NotAuthenticatedError")) { return Effect.fail( new OrganizationServiceError({ - message: - "Failed to create an organization because you are not authenticated.", - }) + message: "Failed to create an organization because you are not authenticated.", + }), ); } @@ -31,9 +43,9 @@ export const createOrganization = Effect.gen(function* createOrganization() { new OrganizationServiceError({ message: "Failed to create an organization because of an unknown error. Please try again. If the problem persists, please contact us at support@voidhash.com", - }) + }), ); - }) - ) + }), + ), ); }); diff --git a/apps/cli/src/services/organization/errors.ts b/apps/cli/src/services/organization/errors.ts index ad49c43c8..cdb643117 100644 --- a/apps/cli/src/services/organization/errors.ts +++ b/apps/cli/src/services/organization/errors.ts @@ -4,5 +4,5 @@ export class OrganizationServiceError extends Schema.TaggedErrorClass; -export class OrganizationService extends ServiceMap.Service()( - "voidhash-cli/services/OrganizationService" -) { - static Default = Layer.effect(OrganizationService, make) +export class OrganizationService extends Context.Service< + OrganizationService, + OrganizationServiceShape +>()("voidhash-cli/services/OrganizationService") { + static Default = Layer.effect(OrganizationService, make); } diff --git a/apps/cli/src/services/organization/list-organizations.ts b/apps/cli/src/services/organization/list-organizations.ts index 76be481c5..680811df7 100644 --- a/apps/cli/src/services/organization/list-organizations.ts +++ b/apps/cli/src/services/organization/list-organizations.ts @@ -3,14 +3,27 @@ import { Effect } from "effect"; import { ApiClient } from "../../utils/api-client"; import { OrganizationServiceError } from "./errors"; +const hasNestedTag = ( + error: unknown, + outerTag: string, + innerTag: string, +): error is { readonly _tag: string; readonly data: { readonly _tag: string } } => + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === outerTag && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "_tag" in error.data && + error.data._tag === innerTag; + export const listOrganizations = Effect.gen(function* listOrganizations() { const client = yield* ApiClient; return Effect.fn("listOrganizations")( function* listOrganizations(input: { name: string }) { - const organization = yield* client.organizations.createOrganization({ - payload: { - name: input.name, - }, + const organization = yield* client.organizationsCreateOrganization({ + name: input.name, }); return organization; @@ -18,12 +31,11 @@ export const listOrganizations = Effect.gen(function* listOrganizations() { (effect) => effect.pipe( Effect.catch((error) => { - if (error._tag === "NotAuthenticatedError") { + if (hasNestedTag(error, "OrganizationsCreateOrganization500", "NotAuthenticatedError")) { return Effect.fail( new OrganizationServiceError({ - message: - "Failed to create an organization because you are not authenticated.", - }) + message: "Failed to create an organization because you are not authenticated.", + }), ); } @@ -31,9 +43,9 @@ export const listOrganizations = Effect.gen(function* listOrganizations() { new OrganizationServiceError({ message: "Failed to create an organization because of an unknown error. Please try again. If the problem persists, please contact us at support@voidhash.com", - }) + }), ); - }) - ) + }), + ), ); }); diff --git a/apps/cli/src/services/project/index.ts b/apps/cli/src/services/project/index.ts index 5891ab42a..94be9ccdc 100644 --- a/apps/cli/src/services/project/index.ts +++ b/apps/cli/src/services/project/index.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ServiceMap } from "effect"; +import { Effect, Layer, Context } from "effect"; const make = Effect.gen(function* scoped() { return {} as const; @@ -6,8 +6,8 @@ const make = Effect.gen(function* scoped() { type ProjectServiceShape = Effect.Success; -export class ProjectService extends ServiceMap.Service()( - "voidhash-cli/services/ProjectService" +export class ProjectService extends Context.Service()( + "voidhash-cli/services/ProjectService", ) { - static Default = Layer.effect(ProjectService, make) + static Default = Layer.effect(ProjectService, make); } diff --git a/apps/cli/src/services/repository/index.ts b/apps/cli/src/services/repository/index.ts index dad5c0fcf..da583e606 100644 --- a/apps/cli/src/services/repository/index.ts +++ b/apps/cli/src/services/repository/index.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ServiceMap } from "effect"; +import { Effect, Layer, Context } from "effect"; const make = Effect.gen(function* scoped() { return {} as const; @@ -6,8 +6,8 @@ const make = Effect.gen(function* scoped() { type RepositoryServiceShape = Effect.Success; -export class RepositoryService extends ServiceMap.Service()( - "voidhash-cli/services/RepositoryService" +export class RepositoryService extends Context.Service()( + "voidhash-cli/services/RepositoryService", ) { - static Default = Layer.effect(RepositoryService, make) + static Default = Layer.effect(RepositoryService, make); } diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index e5a4b5f73..8c30ae8f6 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -1,48 +1,42 @@ -import { VoidhashV1Api } from "@voidhash/api-spec"; -import { Effect, Layer, ServiceMap } from "effect"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; -import { HttpApiClient } from "effect/unstable/httpapi"; +import { make as makeCoreClient, type VoidhashCoreClient } from "@voidhash/generated-clients"; +import { Effect, Layer, Context } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { CliConfig } from "../domain/services/cli-config"; const make = Effect.gen(function* effect() { yield* Effect.logDebug("Initializing API client"); const cliConfig = yield* CliConfig; - return yield* HttpApiClient.make(VoidhashV1Api, { - baseUrl: "http://localhost:5001", + const httpClient = yield* HttpClient.HttpClient; + return makeCoreClient(httpClient as VoidhashCoreClient["httpClient"], { transformClient: (client) => - client.pipe( - HttpClient.mapRequestEffect((request) => - Effect.gen(function* transformClient() { - const config = yield* cliConfig.readConfig().pipe( - Effect.catch(() => - Effect.die("Failed to read config") - ) - ); + Effect.succeed( + client.pipe( + HttpClient.mapRequestEffect((request) => + Effect.gen(function* transformClient() { + const config = yield* cliConfig + .readConfig() + .pipe(Effect.catch(() => Effect.die("Failed to read config"))); - yield* Effect.logDebug( - `API Request: ${request.method} ${request.url}` - ); + yield* Effect.logDebug(`API Request: ${request.method} ${request.url}`); - return { - ...request, - headers: { - ...request.headers, - ...(config.api_key ? { "x-api-key": config.api_key } : {}), - }, - }; - }).pipe(Effect.withSpan("ApiClient.transformRequest")) - ) + return HttpClientRequest.setHeaders( + HttpClientRequest.prependUrl(request, config.api_url), + config.api_key ? { "x-api-key": config.api_key } : {}, + ); + }).pipe(Effect.withSpan("ApiClient.transformRequest")), + ), + ), ), }); }).pipe(Effect.withSpan("ApiClient.make")); type ApiClientShape = Effect.Success; -export class ApiClient extends ServiceMap.Service()( - "voidhash-cli/ApiClient" +export class ApiClient extends Context.Service()( + "voidhash-cli/ApiClient", ) { static Default = Layer.effect(ApiClient, make).pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, CliConfig.Default)) - ) + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, CliConfig.Default)), + ); } diff --git a/apps/cli/src/utils/error-formatter.ts b/apps/cli/src/utils/error-formatter.ts index 10b754089..488ade62b 100644 --- a/apps/cli/src/utils/error-formatter.ts +++ b/apps/cli/src/utils/error-formatter.ts @@ -9,6 +9,28 @@ const CliErrorTypeId = Symbol.for("~effect/cli/CliError"); export const isDebugMode = (): boolean => process.argv.includes("--debug") || process.argv.includes("-d"); +/** + * Resolve the active config profile from the `--profile ` / `--profile=` + * flag, or `null` when no profile is requested. + * + * Like {@link isDebugMode}, this reads `process.argv` directly because the parsed + * flag value isn't available where the CliConfig layer is built. The flag itself + * is registered as a shared flag on the root command so the parser accepts it on + * every command. + */ +export const getActiveProfile = (): string | null => { + const argv = process.argv; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === undefined) continue; + if (arg === "--profile") return argv[i + 1] ?? null; + if (arg.startsWith("--profile=")) { + return arg.slice("--profile=".length) || null; + } + } + return null; +}; + /** * Creates a CliError.UserError with a message. * Use this in command error handlers to show user-friendly errors. @@ -16,7 +38,7 @@ export const isDebugMode = (): boolean => * @example * ```ts * Effect.catchTag("NoSignedInUserError", () => - * Effect.fail(userError("You must be logged in. Run 'voidhash auth login' first.")) + * Effect.fail(userError("You must be logged in. Run 'voidhash-cli auth login' first.")) * ) * ``` */ @@ -26,9 +48,7 @@ export const userError = (message: string): CliError.UserError => /** * Check if an error is a CliError from @effect/cli */ -const isCliError = ( - error: unknown -): error is CliError.CliError => +const isCliError = (error: unknown): error is CliError.CliError => typeof error === "object" && error !== null && CliErrorTypeId in error; /** @@ -41,7 +61,7 @@ const isCliError = ( * In debug mode, it also logs the full error cause chain before the user-friendly message. */ export const withValidationErrorHandler = ( - effect: Effect.Effect + effect: Effect.Effect, ): Effect.Effect
, R> => effect.pipe( Effect.catchCause((cause) => { @@ -54,18 +74,16 @@ export const withValidationErrorHandler = ( // In debug mode, show the full cause chain if (isDebugMode()) { return Console.error("\n--- Debug Trace ---").pipe( - Effect.andThen( - Console.error(Cause.pretty(cause)) - ), + Effect.andThen(Console.error(Cause.pretty(cause))), Effect.andThen(Console.error("--- End Debug Trace ---\n")), Effect.andThen(Console.error(failure.message)), - Effect.andThen(Effect.sync(() => process.exit(1))) + Effect.andThen(Effect.sync(() => process.exit(1))), ); } // Normal mode: just show the user-friendly message return Console.error(failure.message).pipe( - Effect.andThen(Effect.sync(() => process.exit(1))) + Effect.andThen(Effect.sync(() => process.exit(1))), ); } } @@ -73,25 +91,19 @@ export const withValidationErrorHandler = ( // For non-CliError failures, show full cause in debug mode if (isDebugMode() && cause.reasons.length > 0) { return Console.error("\n--- Debug Trace ---").pipe( - Effect.andThen( - Console.error(Cause.pretty(cause)) - ), + Effect.andThen(Console.error(Cause.pretty(cause))), Effect.andThen(Console.error("--- End Debug Trace ---\n")), - Effect.andThen(Effect.sync(() => process.exit(1))) + Effect.andThen(Effect.sync(() => process.exit(1))), ); } // Re-fail with non-CliError const firstFailure = failures[0]; if (firstFailure !== undefined) { - return Effect.fail( - firstFailure as Exclude - ); + return Effect.fail(firstFailure as Exclude); } // Handle defects - return Effect.failCause( - cause as Cause.Cause> - ); - }) + return Effect.failCause(cause as Cause.Cause>); + }), ); diff --git a/apps/cli/src/utils/fs.ts b/apps/cli/src/utils/fs.ts index 19ba47b27..1bcad642d 100644 --- a/apps/cli/src/utils/fs.ts +++ b/apps/cli/src/utils/fs.ts @@ -12,10 +12,7 @@ export class FileExistsError extends Data.TaggedError("FileExistsError")<{ * @param pathToAssert - The path to assert the file can be created at. * @returns An Effect that succeeds if the file can be created, or fails if the file already exists and the user does not want to overwrite it. */ -export const assertFileCanBeCreated = ( - filename: string, - pathToAssert: string -) => +export const assertFileCanBeCreated = (filename: string, pathToAssert: string) => Effect.gen(function* assertFileCanBeCreated() { const fileSystem = yield* FileSystem.FileSystem; @@ -25,12 +22,10 @@ export const assertFileCanBeCreated = ( const overwrite = yield* Prompt.run( Prompt.confirm({ message: `File ${filename} already exists. Do you want to overwrite it?`, - }) + }), ); if (!overwrite) { - return yield* Effect.fail( - new FileExistsError({ message: "File already exists" }) - ); + return yield* Effect.fail(new FileExistsError({ message: "File already exists" })); } return yield* Effect.succeed(true); 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 92ef1d028..0f6c2d6c5 100644 --- a/apps/cli/src/utils/js-loading/js-file-loading.ts +++ b/apps/cli/src/utils/js-loading/js-file-loading.ts @@ -2,9 +2,7 @@ import { Data, Effect } from "effect"; -export class FailedToLoadJsFileError extends Data.TaggedError( - "FailedToLoadJsFileError" -)<{ +export class FailedToLoadJsFileError extends Data.TaggedError("FailedToLoadJsFileError")<{ readonly message: string; readonly cause?: unknown; }> {} @@ -18,7 +16,7 @@ const assertES5 = ({ unregister }: { unregister: () => void }) => 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`) + it.text?.includes(`("es5") is not supported yet`), ); if (es5Error) { return new FailedToLoadJsFileError({ @@ -61,8 +59,8 @@ export const safeRegister = () => Effect.succeed({ // biome-ignore lint/suspicious/noEmptyBlockStatements: it is on purpose an empty function. It is here instead of try-catch due to tsx. unregister(): void {}, - }) - ) + }), + ), ); yield* assertES5(res); diff --git a/apps/cli/src/utils/organizations/create-organization.ts b/apps/cli/src/utils/organizations/create-organization.ts index 548d812d6..c9969bf56 100644 --- a/apps/cli/src/utils/organizations/create-organization.ts +++ b/apps/cli/src/utils/organizations/create-organization.ts @@ -19,28 +19,22 @@ export const createOrganization = () => Effect.gen(function* createOrganization() { const client = yield* ApiClient; - const attemptToCreateOrganization = Effect.gen( - function* attemptToCreateOrganization() { - const name = yield* Prompt.run( - Prompt.text({ - message: "Enter a name for the organization", - validate: (value) => validateOrganizationName(value), - }) - ); - - const organization = yield* client.organizations.createOrganization({ - payload: { - name, - }, - }); - - yield* Console.log( - `Successfully created organization ${organization.name}` - ); - - return organization; - } - ); + const attemptToCreateOrganization = Effect.gen(function* attemptToCreateOrganization() { + const name = yield* Prompt.run( + Prompt.text({ + message: "Enter a name for the organization", + validate: (value) => validateOrganizationName(value), + }), + ); + + const organization = yield* client.organizationsCreateOrganization({ + name, + }); + + yield* Console.log(`Successfully created organization ${organization.name}`); + + return organization; + }); return yield* attemptToCreateOrganization; }); diff --git a/apps/cli/src/utils/organizations/select-organization.ts b/apps/cli/src/utils/organizations/select-organization.ts index ffe4c4be5..41f57aa11 100644 --- a/apps/cli/src/utils/organizations/select-organization.ts +++ b/apps/cli/src/utils/organizations/select-organization.ts @@ -4,7 +4,7 @@ import { Effect } from "effect"; import { createOrganization } from "./create-organization"; export const selectOrganization = ( - organizations: readonly { id: string; slug: string; name: string }[] + organizations: readonly { id: string; slug: string; name: string }[], ) => Effect.gen(function* selectOrganization() { if (organizations.length === 0) { @@ -23,7 +23,7 @@ export const selectOrganization = ( }, ], message: "Select an organization", - }) + }), ); if (organizationSlug === "create-new-organization") { return yield* createOrganization(); @@ -31,7 +31,7 @@ export const selectOrganization = ( const organization = organizations.find((t) => t.slug === organizationSlug); if (!organization) { return yield* Effect.die( - "Organization not found even though it was selected and should exist." + "Organization not found even though it was selected and should exist.", ); } return organization; diff --git a/apps/cli/src/utils/projects/create-project.ts b/apps/cli/src/utils/projects/create-project.ts index 78d4cce19..344479bc2 100644 --- a/apps/cli/src/utils/projects/create-project.ts +++ b/apps/cli/src/utils/projects/create-project.ts @@ -27,38 +27,27 @@ export const createProject = (input: { organizationId: string }) => // If the config file is not found or the api key is not set, we consider the user to be signed out const apiKey = config.api_key; if (!apiKey) { - yield* Effect.logInfo( - "Api key is not set, considering the user to be signed out" - ); - return yield* Effect.fail( - new NoSignedInUserError({ message: "No signed in user" }) - ); + yield* Effect.logInfo("Api key is not set, considering the user to be signed out"); + return yield* Effect.fail(new NoSignedInUserError({ message: "No signed in user" })); } - const attemptToCreateProject = Effect.gen( - function* attemptToCreateProject() { - const name = yield* Prompt.run( - Prompt.text({ - message: "Enter a name for the project", - validate: (value) => validateProjectName(value), - }) - ); + const attemptToCreateProject = Effect.gen(function* attemptToCreateProject() { + const name = yield* Prompt.run( + Prompt.text({ + message: "Enter a name for the project", + validate: (value) => validateProjectName(value), + }), + ); - const project = yield* client.projects.createProject({ - // headers: { - // 'x-api-key': apiKey - // }, - payload: { - name, - organizationId: input.organizationId, - }, - }); + const project = yield* client.projectsCreateProject({ + name, + organizationId: input.organizationId, + }); - yield* Console.log(`Successfully created project ${project.name}`); + yield* Console.log(`Successfully created project ${project.name}`); - return project; - } - ); + return project; + }); return yield* attemptToCreateProject; }); diff --git a/apps/cli/src/utils/projects/select-project.ts b/apps/cli/src/utils/projects/select-project.ts index 3c6150b01..01bfcb40e 100644 --- a/apps/cli/src/utils/projects/select-project.ts +++ b/apps/cli/src/utils/projects/select-project.ts @@ -5,7 +5,7 @@ import { createProject } from "./create-project"; export const selectProject = ( organizationId: string, - projects: readonly { id: string; slug: string; name: string }[] + projects: readonly { id: string; slug: string; name: string }[], ) => Effect.gen(function* selectProject() { if (projects.length === 0) { @@ -21,7 +21,7 @@ export const selectProject = ( { title: "(+) Create new project", value: "create-new-project" }, ], message: "Select a project", - }) + }), ); if (projectSlug === "create-new-project") { return yield* createProject({ organizationId }); @@ -29,9 +29,7 @@ export const selectProject = ( const project = projects.find((p) => p.slug === projectSlug); if (!project) { - return yield* Effect.die( - "Project not found even though it was selected and should exist." - ); + return yield* Effect.die("Project not found even though it was selected and should exist."); } return project; }); diff --git a/apps/cli/src/utils/schema/changeset-builder.ts b/apps/cli/src/utils/schema/changeset-builder.ts deleted file mode 100644 index 5ae94ab24..000000000 --- a/apps/cli/src/utils/schema/changeset-builder.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { ChangesetSchema } from "@voidhash/shared"; - -import type { NormalizedProduct } from "../../domain/schema/normalized-schema"; -import type { SchemaDiff } from "./diff"; - -// ======================================================== -// Types -// ======================================================== - -// Re-export the Change type from the changeset -type Change = (typeof ChangesetSchema.Type)["changes"][number]; -type Changeset = typeof ChangesetSchema.Type; - -// ======================================================== -// Changeset Builder -// ======================================================== - -/** - * Build a changeset from a schema diff. - * Only includes creates and updates, NOT deletes. - * - * Order: - * 1. Create locations - * 2. Update locations - * 3. Create perks - * 4. Update perks - * 5. Create products - * 6. Update products - * 7. Create product-perks (for new products) - * 8. Create payment-provider-products (for new products) - * 9. Update payment-provider-products (for updated products) - * 10. Archive locations - */ -export function buildChangeset(diff: SchemaDiff): Changeset { - const changes: Change[] = []; - - // 1. Create locations - for (const location of diff.locations.toCreate) { - changes.push({ - changeType: "create-paywall-location", - key: location.slug, - payload: { - description: location.description, - name: location.name, - slug: location.slug, - }, - }); - } - - // 2. Update locations - for (const { local } of diff.locations.toUpdate) { - changes.push({ - changeType: "update-paywall-location", - key: local.slug, - payload: { - description: local.description, - name: local.name, - slug: local.slug, - }, - }); - } - - // 3. Create perks (first, as products depend on them) - for (const perk of diff.perks.toCreate) { - changes.push({ - changeType: "create-perk", - key: perk.slug, - payload: { name: perk.name, slug: perk.slug }, - }); - } - - // 4. Update perks - for (const { local } of diff.perks.toUpdate) { - changes.push({ - changeType: "update-perk", - key: local.slug, - payload: { name: local.name, slug: local.slug }, - }); - } - - // 5. Create products - for (const product of diff.products.toCreate) { - changes.push({ - changeType: "create-product", - key: product.slug, - payload: { name: product.name, slug: product.slug }, - }); - - // 3a. Create product-perks for this product - for (const perkSlug of product.perks) { - changes.push({ - changeType: "create-product-perk", - key: `${product.slug}:${perkSlug}`, - payload: { perkSlug, productSlug: product.slug }, - }); - } - - // 3b. Create payment-provider-products for this product - for (const provider of product.providers) { - changes.push({ - changeType: "create-payment-provider-product", - key: `${product.slug}:${provider.providerId}`, - payload: { - configuration: provider.configuration as Record, - productSlug: product.slug, - providerId: provider.providerId, - }, - }); - } - } - - // 6. Update products - for (const { local, remote } of diff.products.toUpdate) { - // Check if product name changed - if (local.name !== remote.name) { - changes.push({ - changeType: "update-product", - key: local.slug, - payload: { name: local.name, slug: local.slug }, - }); - } - - // Handle product-perk changes - const localPerkSet = new Set(local.perks); - const remotePerkSet = new Set(remote.perks); - - // Add new perks - for (const perkSlug of local.perks) { - if (!remotePerkSet.has(perkSlug)) { - changes.push({ - changeType: "create-product-perk", - key: `${local.slug}:${perkSlug}`, - payload: { perkSlug, productSlug: local.slug }, - }); - } - } - - // Note: We don't delete product-perks here (no deletes in push) - - // Handle payment-provider-product changes - const localProviderMap = new Map( - local.providers.map((p) => [p.providerId, p]) - ); - const remoteProviderMap = new Map( - remote.providers.map((p) => [p.providerId, p]) - ); - - for (const [providerId, localProvider] of localProviderMap) { - const remoteProvider = remoteProviderMap.get(providerId); - - if (!remoteProvider) { - // New provider configuration - changes.push({ - changeType: "create-payment-provider-product", - key: `${local.slug}:${providerId}`, - payload: { - configuration: localProvider.configuration as Record, - productSlug: local.slug, - providerId, - }, - }); - } else if ( - JSON.stringify(localProvider.configuration) !== - JSON.stringify(remoteProvider.configuration) - ) { - // Updated provider configuration - changes.push({ - changeType: "update-payment-provider-product", - key: `${local.slug}:${providerId}`, - payload: { - configuration: localProvider.configuration as Record, - productSlug: local.slug, - providerId, - }, - }); - } - } - - // Note: We don't delete payment-provider-products here (no deletes in push) - } - - // 10. Archive locations - for (const location of diff.locations.toArchive) { - changes.push({ - changeType: "archive-paywall-location", - key: location.slug, - payload: { slug: location.slug }, - }); - } - - return { changes }; -} - -// ======================================================== -// Change Formatting -// ======================================================== - -export function formatChange(change: Change): string { - switch (change.changeType) { - case "create-paywall-location": - return `+ Create paywall location: ${change.payload.slug} ("${change.payload.name}")`; - case "update-paywall-location": - return `~ Update paywall location: ${change.payload.slug} ("${change.payload.name}")`; - case "archive-paywall-location": - return `- Archive paywall location: ${change.payload.slug}`; - case "create-perk": - return `+ Create perk: ${change.payload.slug} ("${change.payload.name}")`; - case "update-perk": - return `~ Update perk: ${change.payload.slug} ("${change.payload.name}")`; - case "create-product": - return `+ Create product: ${change.payload.slug} ("${change.payload.name}")`; - case "update-product": - return `~ Update product: ${change.payload.slug} ("${change.payload.name}")`; - case "create-product-perk": - return `+ Link perk "${change.payload.perkSlug}" to product "${change.payload.productSlug}"`; - case "delete-product-perk": - return `- Unlink perk "${change.payload.perkSlug}" from product "${change.payload.productSlug}"`; - case "create-payment-provider-product": - return `+ Configure ${change.payload.providerId} for product "${change.payload.productSlug}"`; - case "update-payment-provider-product": - return `~ Update ${change.payload.providerId} config for product "${change.payload.productSlug}"`; - case "delete-payment-provider-product": - return `- Remove ${change.payload.providerId} config from product "${change.payload.productSlug}"`; - case "delete-perk": - return `- Delete perk: ${change.payload.slug}`; - case "delete-product": - return `- Delete product: ${change.payload.slug}`; - default: - return `? Unknown change type`; - } -} - -export function formatChangeShort(change: Change): string { - switch (change.changeType) { - case "create-paywall-location": - case "update-paywall-location": - case "archive-paywall-location": - return `PaywallLocation: ${change.payload.slug}`; - case "create-perk": - case "update-perk": - case "delete-perk": - return `Perk: ${change.payload.slug}`; - case "create-product": - case "update-product": - case "delete-product": - return `Product: ${change.payload.slug}`; - case "create-product-perk": - case "delete-product-perk": - return `ProductPerk: ${change.payload.productSlug}:${change.payload.perkSlug}`; - case "create-payment-provider-product": - case "update-payment-provider-product": - case "delete-payment-provider-product": - return `ProviderProduct: ${change.payload.productSlug}:${change.payload.providerId}`; - default: - return "Unknown"; - } -} diff --git a/apps/cli/src/utils/schema/diff.ts b/apps/cli/src/utils/schema/diff.ts deleted file mode 100644 index 4be3e4a55..000000000 --- a/apps/cli/src/utils/schema/diff.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { - NormalizedPaywallLocation, - NormalizedPerk, - NormalizedProduct, - NormalizedSchema, -} from "../../domain/schema/normalized-schema"; - -// ======================================================== -// Types -// ======================================================== - -export interface PerkDiff { - remoteOnly: NormalizedPerk[]; - toCreate: NormalizedPerk[]; - toUpdate: { local: NormalizedPerk; remote: NormalizedPerk }[]; -} - -export interface ProductDiff { - remoteOnly: NormalizedProduct[]; - toCreate: NormalizedProduct[]; - toUpdate: { local: NormalizedProduct; remote: NormalizedProduct }[]; -} - -export interface PaywallLocationDiff { - remoteOnly: NormalizedPaywallLocation[]; - toArchive: NormalizedPaywallLocation[]; - toCreate: NormalizedPaywallLocation[]; - toUpdate: { local: NormalizedPaywallLocation; remote: NormalizedPaywallLocation }[]; -} - -export interface SchemaDiff { - locations: PaywallLocationDiff; - perks: PerkDiff; - products: ProductDiff; -} - -// ======================================================== -// Comparison Helpers -// ======================================================== - -function perksEqual(a: NormalizedPerk, b: NormalizedPerk): boolean { - return a.slug === b.slug && a.name === b.name; -} - -function arraysEqual(a: readonly T[], b: readonly T[]): boolean { - if (a.length !== b.length) return false; - const sortedA = [...a].sort(); - const sortedB = [...b].sort(); - return sortedA.every((val, i) => val === sortedB[i]); -} - -function providerConfigsEqual( - a: readonly { providerId: string; configuration: Readonly> }[], - b: readonly { providerId: string; configuration: Readonly> }[] -): boolean { - if (a.length !== b.length) return false; - - const sortedA = [...a].sort((x, y) => x.providerId.localeCompare(y.providerId)); - const sortedB = [...b].sort((x, y) => x.providerId.localeCompare(y.providerId)); - - for (let i = 0; i < sortedA.length; i++) { - const provA = sortedA[i]; - const provB = sortedB[i]; - if (!provA || !provB) return false; - if (provA.providerId !== provB.providerId) return false; - // Deep compare configurations - if (JSON.stringify(provA.configuration) !== JSON.stringify(provB.configuration)) { - return false; - } - } - - return true; -} - -function productsEqual(a: NormalizedProduct, b: NormalizedProduct): boolean { - return ( - a.slug === b.slug && - a.name === b.name && - a.type === b.type && - arraysEqual(a.perks, b.perks) && - providerConfigsEqual(a.providers, b.providers) - ); -} - -function locationsEqual( - a: NormalizedPaywallLocation, - b: NormalizedPaywallLocation -): boolean { - return ( - a.slug === b.slug && - a.name === b.name && - (a.description ?? null) === (b.description ?? null) - ); -} - -// ======================================================== -// Diff Algorithm -// ======================================================== - -/** - * Compute the difference between local and remote schemas. - * - * - toCreate: exists in local but not in remote - * - toUpdate: exists in both but values differ - * - remoteOnly: exists in remote but not in local (for info, we don't delete) - */ -export function computeDiff( - local: NormalizedSchema, - remote: NormalizedSchema -): SchemaDiff { - const diff: SchemaDiff = { - locations: { remoteOnly: [], toArchive: [], toCreate: [], toUpdate: [] }, - perks: { remoteOnly: [], toCreate: [], toUpdate: [] }, - products: { remoteOnly: [], toCreate: [], toUpdate: [] }, - }; - - // Compare paywall locations - for (const [slug, localLocation] of local.locations) { - const remoteLocation = remote.locations.get(slug); - if (!remoteLocation) { - diff.locations.toCreate.push(localLocation); - } else if (!locationsEqual(localLocation, remoteLocation)) { - diff.locations.toUpdate.push({ local: localLocation, remote: remoteLocation }); - } - } - - // Find remote-only locations (active on server, absent locally) - for (const [slug, remoteLocation] of remote.locations) { - if (!local.locations.has(slug)) { - diff.locations.remoteOnly.push(remoteLocation); - diff.locations.toArchive.push(remoteLocation); - } - } - - // Compare perks - for (const [slug, localPerk] of local.perks) { - const remotePerk = remote.perks.get(slug); - if (!remotePerk) { - diff.perks.toCreate.push(localPerk); - } else if (!perksEqual(localPerk, remotePerk)) { - diff.perks.toUpdate.push({ local: localPerk, remote: remotePerk }); - } - } - - // Find remote-only perks - for (const [slug, remotePerk] of remote.perks) { - if (!local.perks.has(slug)) { - diff.perks.remoteOnly.push(remotePerk); - } - } - - // Compare products - for (const [slug, localProduct] of local.products) { - const remoteProduct = remote.products.get(slug); - if (!remoteProduct) { - diff.products.toCreate.push(localProduct); - } else if (!productsEqual(localProduct, remoteProduct)) { - diff.products.toUpdate.push({ local: localProduct, remote: remoteProduct }); - } - } - - // Find remote-only products - for (const [slug, remoteProduct] of remote.products) { - if (!local.products.has(slug)) { - diff.products.remoteOnly.push(remoteProduct); - } - } - - return diff; -} - -// ======================================================== -// Summary Helpers -// ======================================================== - -export interface DiffSummary { - locationsToArchive: number; - locationsToCreate: number; - locationsToUpdate: number; - locationsRemoteOnly: number; - perksToCreate: number; - perksToUpdate: number; - perksRemoteOnly: number; - productsToCreate: number; - productsToUpdate: number; - productsRemoteOnly: number; - totalChanges: number; -} - -export function summarizeDiff(diff: SchemaDiff): DiffSummary { - const locationsToCreate = diff.locations.toCreate.length; - const locationsToUpdate = diff.locations.toUpdate.length; - const locationsToArchive = diff.locations.toArchive.length; - const locationsRemoteOnly = diff.locations.remoteOnly.length; - const perksToCreate = diff.perks.toCreate.length; - const perksToUpdate = diff.perks.toUpdate.length; - const perksRemoteOnly = diff.perks.remoteOnly.length; - const productsToCreate = diff.products.toCreate.length; - const productsToUpdate = diff.products.toUpdate.length; - const productsRemoteOnly = diff.products.remoteOnly.length; - - return { - locationsRemoteOnly, - locationsToArchive, - locationsToCreate, - locationsToUpdate, - perksRemoteOnly, - perksToCreate, - perksToUpdate, - productsRemoteOnly, - productsToCreate, - productsToUpdate, - totalChanges: - locationsToCreate + - locationsToUpdate + - locationsToArchive + - perksToCreate + - perksToUpdate + - productsToCreate + - productsToUpdate, - }; -} diff --git a/apps/cli/src/utils/schema/local-schema-loader.ts b/apps/cli/src/utils/schema/local-schema-loader.ts deleted file mode 100644 index da5c6a803..000000000 --- a/apps/cli/src/utils/schema/local-schema-loader.ts +++ /dev/null @@ -1,339 +0,0 @@ -import Module from "node:module"; -import { Effect, FileSystem, Path } from "effect"; - -import { - LocalSchemaNotFoundError, - LocalSchemaParseError, -} from "../../domain/errors/schema"; -import { - type NormalizedSchema, - type ProviderId, - createEmptyNormalizedSchema, -} from "../../domain/schema/normalized-schema"; -import { safeRegister } from "../js-loading/js-file-loading"; - -// Extended Module type to include internal _resolveFilename method -interface ModuleInternal { - _resolveFilename: ( - request: string, - parent: unknown, - isMain: boolean, - options: unknown - ) => string; -} - -/** - * Sets up module resolution alias to redirect @voidhash/react-native imports - * to the schema-only exports. This prevents the CLI from loading the full - * React Native package which requires native bindings. - * - * @returns A cleanup function to restore original module resolution - */ -function setupSchemaModuleAlias(): () => void { - const moduleInternal = Module as unknown as ModuleInternal; - const originalResolve = moduleInternal._resolveFilename; - - moduleInternal._resolveFilename = function ( - request: string, - parent: unknown, - isMain: boolean, - options: unknown - ) { - // Redirect @voidhash/react-native to its schema-only exports - if (request === "@voidhash/react-native") { - // Resolve the schema subpath using the original resolver - return originalResolve.call( - this, - "@voidhash/react-native/schema", - parent, - isMain, - options - ); - } - return originalResolve.call(this, request, parent, isMain, options); - }; - - return () => { - moduleInternal._resolveFilename = originalResolve; - }; -} - -/** - * Symbol used to identify schema entity types at runtime. - * Must match the symbol used in @voidhash/react-native schema definitions. - */ -export const SCHEMA_KIND = Symbol.for("voidhash.schema.kind"); - -export const SchemaKind = { - Perk: "perk", - PaywallLocation: "paywall-location", - Product: "product", - SchemaConfiguration: "schema-configuration", -} as const; - -/** - * Check if a value is a SchemaConfiguration object using the schema kind symbol - */ -export function isSchemaConfiguration( - value: unknown -): value is { - perks: Record; - providers: Record; - location: unknown; - subscription: unknown; -} { - return ( - value !== null && - typeof value === "object" && - (value as Record)[SCHEMA_KIND] === - SchemaKind.SchemaConfiguration - ); -} - -/** - * Check if a value is a PaywallLocationDefinition instance using the schema kind symbol - */ -export function isPaywallLocationDefinition( - value: unknown -): value is { - description: string | null; - name: string; - slug: string; -} { - return ( - value !== null && - typeof value === "object" && - (value as Record)[SCHEMA_KIND] === - SchemaKind.PaywallLocation - ); -} - -/** - * Check if a value is a PerkDefinition instance using the schema kind symbol - */ -export function isPerkDefinition( - value: unknown -): value is { slug: string; name: string } { - return ( - value !== null && - typeof value === "object" && - (value as Record)[SCHEMA_KIND] === SchemaKind.Perk - ); -} - -/** - * Check if a value is a ProductDefinition instance using the schema kind symbol - */ -export function isProductDefinition( - value: unknown -): value is { - type: string; - slug: string; - properties: { name: string }; - configuration: { - perks?: Record; - providers?: Record; - }; -} { - return ( - value !== null && - typeof value === "object" && - (value as Record)[SCHEMA_KIND] === SchemaKind.Product - ); -} - -/** - * Extract perk slugs from a product's configuration - */ -export function extractPerkSlugs( - perksConfig: Record | undefined, - allPerks: Map -): string[] { - if (!perksConfig) return []; - - const perkSlugs: string[] = []; - - for (const [key, value] of Object.entries(perksConfig)) { - // Skip the metadata key - if (key === "_") continue; - - // If value is true, this perk is enabled - if (value === true) { - // Find the perk by variable name (key) in our perks map - // We need to match by the variable name, not slug - for (const [slug, perk] of allPerks) { - // The key in the config should match the camelCase version of the slug - const expectedKey = slugToCamelCase(slug); - if (expectedKey === key) { - perkSlugs.push(slug); - break; - } - } - } - } - - return perkSlugs; -} - -/** - * Extract provider configurations from a product - */ -export function extractProviderConfigs( - providersConfig: Record | undefined -): { providerId: ProviderId; configuration: Record }[] { - if (!providersConfig) return []; - - const providers: { - providerId: ProviderId; - configuration: Record; - }[] = []; - - for (const [providerId, config] of Object.entries(providersConfig)) { - // Skip the metadata key - if (providerId === "_") continue; - - // Only include appleAppStore and googlePlay - if (providerId === "appleAppStore" || providerId === "googlePlay") { - if (config && typeof config === "object") { - providers.push({ - configuration: config as Record, - providerId, - }); - } - } - } - - return providers; -} - -/** - * Convert slug to camelCase variable name - * e.g., "all-access" -> "allAccess", "monthly_sub" -> "monthlySub" - */ -export function slugToCamelCase(slug: string): string { - return slug - .split(/[-_]/) - .map((part, i) => - i === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() - ) - .join(""); -} - -/** - * Load and parse a local schema file into a NormalizedSchema - */ -export const loadLocalSchema = (schemaPath: string) => - Effect.gen(function* loadLocalSchema() { - const fs = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - - // Resolve the absolute path - const absolutePath = pathService.resolve(schemaPath); - - // Check if file exists - const exists = yield* fs.exists(absolutePath); - if (!exists) { - return yield* Effect.fail( - new LocalSchemaNotFoundError({ path: schemaPath }) - ); - } - - // Register esbuild for TypeScript - const { unregister } = yield* safeRegister().pipe( - Effect.catchTag("FailedToLoadJsFileError", (e) => - Effect.fail( - new LocalSchemaParseError({ - message: `Failed to register TypeScript loader: ${e.message}`, - }) - ) - ) - ); - - // Set up module alias to redirect @voidhash/react-native to schema-only exports - // This prevents loading React Native native bindings in the CLI - const removeAlias = setupSchemaModuleAlias(); - - // Load the schema module - let schemaModule: Record; - try { - // Clear require cache to ensure fresh load - delete require.cache[require.resolve(absolutePath)]; - schemaModule = require(absolutePath); - } catch (e) { - removeAlias(); - unregister(); - return yield* Effect.fail( - new LocalSchemaParseError({ - message: `Failed to load schema file: ${e instanceof Error ? e.message : String(e)}`, - }) - ); - } - - removeAlias(); - unregister(); - - // Create the normalized schema - const schema = createEmptyNormalizedSchema(); - - // First pass: extract perks and providers from SchemaConfiguration - for (const [, value] of Object.entries(schemaModule)) { - if (isSchemaConfiguration(value)) { - // Extract perks - for (const [, perkDef] of Object.entries(value.perks)) { - if (isPerkDefinition(perkDef)) { - schema.perks.set(perkDef.slug, { - name: perkDef.name, - slug: perkDef.slug, - }); - } - } - - // Extract enabled providers - for (const [providerId, enabled] of Object.entries(value.providers)) { - if ( - enabled === true && - (providerId === "appleAppStore" || providerId === "googlePlay") - ) { - schema.enabledProviders.add(providerId); - } - } - } - } - - // Second pass: extract products - for (const [, value] of Object.entries(schemaModule)) { - if (isProductDefinition(value)) { - const perkSlugs = extractPerkSlugs( - value.configuration.perks, - schema.perks - ); - const providers = extractProviderConfigs(value.configuration.providers); - - // Add providers from this product to enabled providers - for (const provider of providers) { - schema.enabledProviders.add(provider.providerId); - } - - schema.products.set(value.slug, { - name: value.properties.name, - perks: perkSlugs, - providers, - slug: value.slug, - type: "subscription", // For now, only subscription is supported - }); - } - } - - // Third pass: extract paywall locations - for (const [, value] of Object.entries(schemaModule)) { - if (isPaywallLocationDefinition(value)) { - schema.locations.set(value.slug, { - description: value.description, - name: value.name, - slug: value.slug, - }); - } - } - - return schema; - }); diff --git a/apps/cli/src/utils/schema/version.ts b/apps/cli/src/utils/schema/version.ts new file mode 100644 index 000000000..52461a327 --- /dev/null +++ b/apps/cli/src/utils/schema/version.ts @@ -0,0 +1,21 @@ +/** + * Header-comment helpers used by the generated `voidhash.gen.d.ts`. The + * version hash itself is supplied by the server (`GET /api/v1/schema`, + * `GET /api/v1/schema/version`) — there is no client-side derivation. + */ + +export const VOIDHASH_VERSION_COMMENT_PREFIX = "// @voidhash:version "; +export const VOIDHASH_FETCHED_AT_COMMENT_PREFIX = "// @voidhash:fetched-at "; + +/** + * Extract the version header from a generated `.d.ts`, if present. + * Returns null when the header is missing (e.g. the file was hand-edited). + */ +export function parseVersionFromDeclaration(content: string): string | null { + for (const line of content.split(/\r?\n/)) { + if (line.startsWith(VOIDHASH_VERSION_COMMENT_PREFIX)) { + return line.slice(VOIDHASH_VERSION_COMMENT_PREFIX.length).trim(); + } + } + return null; +} diff --git a/apps/cli/src/utils/source-code-details.ts b/apps/cli/src/utils/source-code-details.ts index d4b19e195..f22af9b58 100644 --- a/apps/cli/src/utils/source-code-details.ts +++ b/apps/cli/src/utils/source-code-details.ts @@ -1,4 +1,4 @@ -import { Effect, ServiceMap } from "effect"; +import { Effect, Context } from "effect"; import type { PackageJsonSchema } from "../domain/schema/package-json"; import { SourceCode } from "../domain/services/source-code"; @@ -14,7 +14,9 @@ export interface SourceCodeDetailsType { packageJson: typeof PackageJsonSchema.Type; } -export class SourceCodeDetails extends ServiceMap.Service()("app/SourceCodeDetails") { +export class SourceCodeDetails extends Context.Service()( + "app/SourceCodeDetails", +) { static readonly provide = (details: SourceCodeDetailsType) => (effect: Effect.Effect) => @@ -37,12 +39,10 @@ export const retrieveSourceCodeDetails = () => ], { concurrency: "unbounded", - } + }, ); - const packageManager = yield* sourceCode.detectPackageManager( - monorepoRootPath ?? "./" - ); + const packageManager = yield* sourceCode.detectPackageManager(monorepoRootPath ?? "./"); const isExpoProject = checkIsExpoProject(packageJson); return { diff --git a/apps/cli/src/utils/source-code.ts b/apps/cli/src/utils/source-code.ts index 1184e24b8..5e37e7c4d 100644 --- a/apps/cli/src/utils/source-code.ts +++ b/apps/cli/src/utils/source-code.ts @@ -15,6 +15,5 @@ export const relativePathPrefixFromDepth = (depth: number) => * @param packageJson - The package.json contents. * @returns True if the project is an Expo project, false otherwise. */ -export const checkIsExpoProject = ( - packageJson: typeof PackageJsonSchema.Type -) => packageJson.dependencies?.expo !== undefined; +export const checkIsExpoProject = (packageJson: typeof PackageJsonSchema.Type) => + packageJson.dependencies?.expo !== undefined; diff --git a/apps/cli/sst-env.d.ts b/apps/cli/sst-env.d.ts new file mode 100644 index 000000000..f2ed71576 --- /dev/null +++ b/apps/cli/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst"; +export {}; diff --git a/apps/cli/tests/auth-token.test.ts b/apps/cli/tests/auth-token.test.ts new file mode 100644 index 000000000..69f81bc92 --- /dev/null +++ b/apps/cli/tests/auth-token.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { buildMcpHeaders } from "../src/cli/commands/auth-token"; + +describe("buildMcpHeaders", () => { + it("emits bearer authorization and an optional project selector", () => { + expect(buildMcpHeaders("vh_cli_secret", "project-slug")).toEqual({ + Authorization: "Bearer vh_cli_secret", + "X-Voidhash-Project": "project-slug", + }); + expect(buildMcpHeaders("vh_cli_secret", undefined)).toEqual({ + Authorization: "Bearer vh_cli_secret", + }); + }); +}); diff --git a/apps/cli/tests/domain/schema/normalized-schema.test.ts b/apps/cli/tests/domain/schema/normalized-schema.test.ts index 445f74a1f..f7589ded5 100644 --- a/apps/cli/tests/domain/schema/normalized-schema.test.ts +++ b/apps/cli/tests/domain/schema/normalized-schema.test.ts @@ -3,84 +3,84 @@ import { describe, expect, it } from "vitest"; import { createEmptyNormalizedSchema } from "../../../src/domain/schema/normalized-schema"; describe("createEmptyNormalizedSchema", () => { - it("returns object with empty locations Map", () => { - const schema = createEmptyNormalizedSchema(); - - expect(schema.locations).toBeInstanceOf(Map); - expect(schema.locations.size).toBe(0); - }); - - it("returns object with empty perks Map", () => { - const schema = createEmptyNormalizedSchema(); - - expect(schema.perks).toBeInstanceOf(Map); - expect(schema.perks.size).toBe(0); - }); - - it("returns object with empty products Map", () => { - const schema = createEmptyNormalizedSchema(); - - expect(schema.products).toBeInstanceOf(Map); - expect(schema.products.size).toBe(0); - }); - - it("returns object with empty enabledProviders Set", () => { - const schema = createEmptyNormalizedSchema(); - - expect(schema.enabledProviders).toBeInstanceOf(Set); - expect(schema.enabledProviders.size).toBe(0); - }); - - it("maps are mutable", () => { - const schema = createEmptyNormalizedSchema(); - - schema.locations.set("test-location", { - description: "Shown after onboarding", - slug: "test-location", - name: "Test Location", - }); - schema.perks.set("test-perk", { slug: "test-perk", name: "Test Perk" }); - schema.products.set("test-product", { - slug: "test-product", - name: "Test Product", - type: "subscription", - perks: [], - providers: [], - }); - - expect(schema.locations.size).toBe(1); - expect(schema.perks.size).toBe(1); - expect(schema.products.size).toBe(1); - }); - - it("set is mutable", () => { - const schema = createEmptyNormalizedSchema(); - - schema.enabledProviders.add("appleAppStore"); - schema.enabledProviders.add("googlePlay"); - - expect(schema.enabledProviders.size).toBe(2); - expect(schema.enabledProviders.has("appleAppStore")).toBe(true); - expect(schema.enabledProviders.has("googlePlay")).toBe(true); - }); - - it("each call returns a new instance", () => { - const schema1 = createEmptyNormalizedSchema(); - const schema2 = createEmptyNormalizedSchema(); - - // Modify schema1 - schema1.perks.set("perk", { slug: "perk", name: "Perk" }); - schema1.enabledProviders.add("appleAppStore"); - - // schema2 should be unaffected - expect(schema2.perks.size).toBe(0); - expect(schema2.enabledProviders.size).toBe(0); - - // References should be different - expect(schema1).not.toBe(schema2); - expect(schema1.locations).not.toBe(schema2.locations); - expect(schema1.perks).not.toBe(schema2.perks); - expect(schema1.products).not.toBe(schema2.products); - expect(schema1.enabledProviders).not.toBe(schema2.enabledProviders); - }); + it("returns object with empty locations Map", () => { + const schema = createEmptyNormalizedSchema(); + + expect(schema.locations).toBeInstanceOf(Map); + expect(schema.locations.size).toBe(0); + }); + + it("returns object with empty perks Map", () => { + const schema = createEmptyNormalizedSchema(); + + expect(schema.perks).toBeInstanceOf(Map); + expect(schema.perks.size).toBe(0); + }); + + it("returns object with empty products Map", () => { + const schema = createEmptyNormalizedSchema(); + + expect(schema.products).toBeInstanceOf(Map); + expect(schema.products.size).toBe(0); + }); + + it("returns object with empty enabledProviders Set", () => { + const schema = createEmptyNormalizedSchema(); + + expect(schema.enabledProviders).toBeInstanceOf(Set); + expect(schema.enabledProviders.size).toBe(0); + }); + + it("maps are mutable", () => { + const schema = createEmptyNormalizedSchema(); + + schema.locations.set("test-location", { + description: "Shown after onboarding", + slug: "test-location", + name: "Test Location", + }); + schema.perks.set("test-perk", { slug: "test-perk", name: "Test Perk" }); + schema.products.set("test-product", { + slug: "test-product", + name: "Test Product", + type: "subscription", + perks: [], + providers: [], + }); + + expect(schema.locations.size).toBe(1); + expect(schema.perks.size).toBe(1); + expect(schema.products.size).toBe(1); + }); + + it("set is mutable", () => { + const schema = createEmptyNormalizedSchema(); + + schema.enabledProviders.add("appleAppStore"); + schema.enabledProviders.add("googlePlay"); + + expect(schema.enabledProviders.size).toBe(2); + expect(schema.enabledProviders.has("appleAppStore")).toBe(true); + expect(schema.enabledProviders.has("googlePlay")).toBe(true); + }); + + it("each call returns a new instance", () => { + const schema1 = createEmptyNormalizedSchema(); + const schema2 = createEmptyNormalizedSchema(); + + // Modify schema1 + schema1.perks.set("perk", { slug: "perk", name: "Perk" }); + schema1.enabledProviders.add("appleAppStore"); + + // schema2 should be unaffected + expect(schema2.perks.size).toBe(0); + expect(schema2.enabledProviders.size).toBe(0); + + // References should be different + expect(schema1).not.toBe(schema2); + expect(schema1.locations).not.toBe(schema2.locations); + expect(schema1.perks).not.toBe(schema2.perks); + expect(schema1.products).not.toBe(schema2.products); + expect(schema1.enabledProviders).not.toBe(schema2.enabledProviders); + }); }); diff --git a/apps/cli/tests/domain/schema/paywall-deploy.test.ts b/apps/cli/tests/domain/schema/paywall-deploy.test.ts new file mode 100644 index 000000000..1e40085cd --- /dev/null +++ b/apps/cli/tests/domain/schema/paywall-deploy.test.ts @@ -0,0 +1,155 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + DEPLOY_MANIFEST_VERSION, + DeployManifestSchema, +} from "../../../src/domain/schema/paywall-deploy"; + +const decode = Schema.decodeUnknownSync(DeployManifestSchema); + +const hash = (char: string): string => char.repeat(64); + +const file = (path: string, char: string) => ({ + bytes: 1024, + path, + sha256: hash(char), +}); + +const artifact = (path: string, char: string, contentType: string) => ({ + ...file(path, char), + contentType, +}); + +/** A fully-populated, contract-§1-shaped manifest fixture. */ +const validManifest = () => ({ + assets: [ + artifact(".voidhash/.build/paywalls/onboarding/assets/hero-AB12CD.png", "e", "image/png"), + ], + cliVersion: "0.0.1-alpha.1", + components: [ + { + artifacts: { + panel: null, + runtime: artifact( + ".voidhash/.build/components/product-option/runtime.js", + "9", + "text/javascript; charset=utf-8", + ), + }, + contentHash: hash("0"), + id: "product-option", + manifest: artifact( + ".voidhash/.build/components/product-option/manifest.json", + "7", + "application/json", + ), + previews: [ + { + file: artifact( + ".voidhash/.build/components/product-option/previews/default.json", + "8", + "application/json", + ), + state: "default", + }, + ], + source: file(".voidhash/components/product-option.tsx", "6"), + title: "Product Option", + }, + ], + config: file("voidhash.config.ts", "d"), + createdAt: "2026-06-11T10:00:00.000Z", + paywalls: [ + { + artifacts: { + html: artifact( + ".voidhash/.build/paywalls/onboarding/index.html", + "b", + "text/html; charset=utf-8", + ), + js: artifact( + ".voidhash/.build/paywalls/onboarding/bundle.js", + "c", + "text/javascript; charset=utf-8", + ), + }, + assets: [".voidhash/.build/paywalls/onboarding/assets/hero-AB12CD.png"], + contentHash: hash("1"), + description: "Full-screen onboarding paywall.", + id: "onboarding", + products: ["yearly", "monthly"], + source: file(".voidhash/paywalls/onboarding.tsx", "a"), + title: "Onboarding", + variables: { accentColor: "#16a34a", maxRows: 3, showTrial: true }, + }, + ], + project: "dev-proj", + runtimeVersion: "0.0.1-alpha.1", + schemaVersion: DEPLOY_MANIFEST_VERSION, + team: "voidhash-dev-sro", +}); + +describe("DeployManifestSchema", () => { + it("decodes a contract-§1 manifest", () => { + const manifest = decode(validManifest()); + + expect(manifest.schemaVersion).toBe(2); + expect(manifest.paywalls[0]?.id).toBe("onboarding"); + expect(manifest.paywalls[0]?.variables).toEqual({ + accentColor: "#16a34a", + maxRows: 3, + showTrial: true, + }); + expect(manifest.components[0]?.artifacts.panel).toBeNull(); + }); + + it("accepts a component-only manifest and a panel artifact", () => { + const fixture = validManifest(); + fixture.paywalls = []; + fixture.components[0]!.artifacts.panel = artifact( + ".voidhash/.build/components/product-option/panel.js", + "f", + "text/javascript; charset=utf-8", + ) as never; + + const manifest = decode(fixture); + expect(manifest.components[0]?.artifacts.panel?.sha256).toBe(hash("f")); + }); + + it("rejects unknown schema versions", () => { + expect(() => decode({ ...validManifest(), schemaVersion: 1 })).toThrow(); + }); + + it("rejects ids that do not match the slug regex", () => { + const fixture = validManifest(); + fixture.paywalls[0]!.id = "Bad_Id"; + expect(() => decode(fixture)).toThrow(); + }); + + it("rejects non-scalar variable values", () => { + const fixture = validManifest(); + fixture.paywalls[0]!.variables = { + accentColor: { hex: "#16a34a" }, + } as never; + expect(() => decode(fixture)).toThrow(); + }); + + it("rejects malformed sha256 digests", () => { + const fixture = validManifest(); + fixture.paywalls[0]!.contentHash = "not-a-hash"; + expect(() => decode(fixture)).toThrow(); + }); + + it("rejects a manifest with no paywalls and no components", () => { + const fixture = validManifest(); + fixture.paywalls = []; + fixture.components = []; + expect(() => decode(fixture)).toThrow(); + }); + + it("rejects missing required fields", () => { + const { config: _config, ...withoutConfig } = validManifest(); + expect(() => decode(withoutConfig)).toThrow(); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-build-warnings.test.ts b/apps/cli/tests/domain/services/paywall-build-warnings.test.ts new file mode 100644 index 000000000..267a84a67 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-build-warnings.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { collectRenderErrorPlaceholderReasons } from "../../../src/domain/services/paywall-build"; + +/** A §3 preview tree wrapper, as emitted by `renderToNodeTree`. */ +const tree = (root: unknown) => ({ root, state: "default", treeVersion: 1 }); + +describe("collectRenderErrorPlaceholderReasons", () => { + it("reports a root placeholder produced by a thrown render", () => { + expect( + collectRenderErrorPlaceholderReasons( + tree({ reason: "render threw: boom", type: "placeholder" }), + ), + ).toEqual(["render threw: boom"]); + }); + + it("skips the legitimate render-returned-null placeholder", () => { + expect( + collectRenderErrorPlaceholderReasons( + tree({ reason: "render returned null", type: "placeholder" }), + ), + ).toEqual([]); + }); + + it("finds nested error placeholders and preserves order", () => { + expect( + collectRenderErrorPlaceholderReasons( + tree({ + children: [ + { style: {}, text: "ok", type: "text" }, + { + children: [ + { + reason: 'unsupported element type "div"', + type: "placeholder", + }, + { reason: "render returned null", type: "placeholder" }, + ], + style: {}, + type: "view", + }, + { reason: "render threw: late", type: "placeholder" }, + ], + style: {}, + type: "view", + }), + ), + ).toEqual(['unsupported element type "div"', "render threw: late"]); + }); + + it("returns nothing for clean trees and non-tree values", () => { + expect( + collectRenderErrorPlaceholderReasons(tree({ children: [], style: {}, type: "view" })), + ).toEqual([]); + expect(collectRenderErrorPlaceholderReasons(undefined)).toEqual([]); + expect(collectRenderErrorPlaceholderReasons("nonsense")).toEqual([]); + expect(collectRenderErrorPlaceholderReasons({ type: "slot" })).toEqual([]); + }); + + it("ignores placeholder-shaped nodes without a string reason", () => { + expect(collectRenderErrorPlaceholderReasons(tree({ type: "placeholder" }))).toEqual([]); + expect(collectRenderErrorPlaceholderReasons(tree({ reason: 42, type: "placeholder" }))).toEqual( + [], + ); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-closed-imports.test.ts b/apps/cli/tests/domain/services/paywall-closed-imports.test.ts new file mode 100644 index 000000000..0af19d135 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-closed-imports.test.ts @@ -0,0 +1,134 @@ +import { promises as fsp } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as esbuild from "esbuild"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { closedImportsPlugin } from "../../../src/domain/services/paywall-closed-imports"; + +let projectRoot: string; +let voidhashDir: string; + +/** Bare modules marked external so "allowed" imports need no node_modules. */ +const EXTERNALS = [ + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "@voidhash/paywalls", + "@voidhash/paywalls/*", +]; + +const writeSource = async (relPath: string, contents: string) => { + const abs = join(projectRoot, relPath); + await fsp.mkdir(join(abs, ".."), { recursive: true }); + await fsp.writeFile(abs, contents); + return abs; +}; + +/** Bundles `entry` with the plugin; returns esbuild error texts ([] = ok). */ +const buildErrors = async ( + entry: string, + options: esbuild.BuildOptions = {}, +): Promise => { + try { + await esbuild.build({ + bundle: true, + external: EXTERNALS, + format: "esm", + logLevel: "silent", + plugins: [closedImportsPlugin(voidhashDir)], + write: false, + ...options, + entryPoints: [entry], + }); + return []; + } catch (error) { + return ((error as esbuild.BuildFailure).errors ?? []).map((e) => e.text); + } +}; + +beforeAll(async () => { + projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-closed-imports-")); + voidhashDir = join(projectRoot, ".voidhash"); + await writeSource(".voidhash/components/helper.ts", "export const helper = 1;\n"); + await fsp.writeFile(join(projectRoot, "app-code.ts"), "export const y = 1;\n"); +}); + +afterAll(async () => { + await fsp.rm(projectRoot, { force: true, recursive: true }); +}); + +describe("closedImportsPlugin", () => { + it("allows the allowlist plus relative imports within .voidhash", async () => { + const entry = await writeSource( + ".voidhash/components/allowed.ts", + [ + 'import "react";', + 'import "react/jsx-runtime";', + 'import "react/jsx-dev-runtime";', + 'import "@voidhash/paywalls";', + 'import "@voidhash/paywalls/dom";', + 'import "@voidhash/paywalls/panel";', + 'import { helper } from "./helper";', + "export const ok = helper;", + ].join("\n"), + ); + + expect(await buildErrors(entry)).toEqual([]); + }); + + it("rejects react-dom, naming the importing file", async () => { + const entry = await writeSource( + ".voidhash/components/uses-react-dom.ts", + 'import "react-dom";\nexport {};\n', + ); + + const errors = await buildErrors(entry, { + external: [...EXTERNALS, "react-dom"], + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"react-dom"'); + expect(errors[0]).toContain("uses-react-dom.ts"); + }); + + it("rejects arbitrary packages", async () => { + const entry = await writeSource( + ".voidhash/components/uses-lodash.ts", + 'import "lodash";\nexport {};\n', + ); + + const errors = await buildErrors(entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"lodash"'); + }); + + it("rejects the Node-only @voidhash/paywalls/tree entry", async () => { + const entry = await writeSource( + ".voidhash/components/uses-tree.ts", + 'import "@voidhash/paywalls/tree";\nexport {};\n', + ); + + const errors = await buildErrors(entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"@voidhash/paywalls/tree"'); + }); + + it("rejects relative imports escaping .voidhash", async () => { + const entry = await writeSource( + ".voidhash/components/escapes.ts", + 'import "../../app-code";\nexport {};\n', + ); + + const errors = await buildErrors(entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("escapes the .voidhash directory"); + }); + + it("does not constrain imports made outside .voidhash (node_modules)", async () => { + const entry = join(projectRoot, "vendor-entry.ts"); + await fsp.writeFile(entry, 'import "react-dom";\nexport {};\n'); + + expect(await buildErrors(entry, { external: [...EXTERNALS, "react-dom"] })).toEqual([]); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-content-hash.test.ts b/apps/cli/tests/domain/services/paywall-content-hash.test.ts new file mode 100644 index 000000000..7ceb8bf08 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-content-hash.test.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + computeComponentContentHash, + computePaywallContentHash, + sha256Hex, +} from "../../../src/domain/services/paywall-build"; + +const digest = (input: string): string => createHash("sha256").update(input).digest("hex"); + +const h = sha256Hex("html"); +const j = sha256Hex("js"); +const m = sha256Hex("manifest"); +const r = sha256Hex("runtime"); +const p = sha256Hex("panel"); +const a1 = sha256Hex("asset-1"); +const a2 = sha256Hex("asset-2"); + +describe("computePaywallContentHash", () => { + it("hashes html:js:sortedAssets per contract §1.2", () => { + const sorted = [a1, a2].sort(); + expect( + computePaywallContentHash({ + assetSha256s: [a1, a2], + htmlSha256: h, + jsSha256: j, + }), + ).toBe(digest(`${h}:${j}:${sorted.join(":")}`)); + }); + + it("sorts asset hashes before joining", () => { + const forward = computePaywallContentHash({ + assetSha256s: [a1, a2], + htmlSha256: h, + jsSha256: j, + }); + const reversed = computePaywallContentHash({ + assetSha256s: [a2, a1], + htmlSha256: h, + jsSha256: j, + }); + expect(forward).toBe(reversed); + }); + + it("keeps the trailing separator when there are no assets", () => { + expect( + computePaywallContentHash({ + assetSha256s: [], + htmlSha256: h, + jsSha256: j, + }), + ).toBe(digest(`${h}:${j}:`)); + }); +}); + +describe("computeComponentContentHash", () => { + it("hashes manifest:runtime:panel:sortedPreviews per contract §1.2", () => { + const sorted = [a1, a2].sort(); + expect( + computeComponentContentHash({ + manifestSha256: m, + panelSha256: p, + previewSha256s: [a2, a1], + runtimeSha256: r, + }), + ).toBe(digest(`${m}:${r}:${p}:${sorted.join(":")}`)); + }); + + it("uses the empty string for an absent panel", () => { + const expected = digest(`${m}:${r}::${a1}`); + expect( + computeComponentContentHash({ + manifestSha256: m, + panelSha256: null, + previewSha256s: [a1], + runtimeSha256: r, + }), + ).toBe(expected); + expect( + computeComponentContentHash({ + manifestSha256: m, + previewSha256s: [a1], + runtimeSha256: r, + }), + ).toBe(expected); + }); + + it("distinguishes panel-less and panel-bearing builds", () => { + const without = computeComponentContentHash({ + manifestSha256: m, + panelSha256: null, + previewSha256s: [a1], + runtimeSha256: r, + }); + const withPanel = computeComponentContentHash({ + manifestSha256: m, + panelSha256: p, + previewSha256s: [a1], + runtimeSha256: r, + }); + expect(without).not.toBe(withPanel); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts b/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts new file mode 100644 index 000000000..cc297bce8 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts @@ -0,0 +1,255 @@ +import { createHash } from "node:crypto"; +import { promises as fsp } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect, Schema } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + type DeployManifest, + DeployManifestSchema, +} from "../../../src/domain/schema/paywall-deploy"; +import { CliConfig } from "../../../src/domain/services/cli-config"; +import { + type PaywallDeployUploadError, + type UploadPaywallDeployResult, + uploadPaywallDeploy, +} from "../../../src/domain/services/paywall-deploy-upload"; + +let projectRoot: string; +let manifest: DeployManifest; + +const sha256Hex = (data: string): string => createHash("sha256").update(data).digest("hex"); + +const FILES = { + config: { contents: "export default {};\n", path: "voidhash.config.ts" }, + html: { + contents: "\n", + path: ".voidhash/.build/paywalls/onboarding/index.html", + }, + js: { + contents: "console.log('paywall');\n", + path: ".voidhash/.build/paywalls/onboarding/bundle.js", + }, + source: { + contents: "export default null;\n", + path: ".voidhash/paywalls/onboarding.tsx", + }, +} as const; + +const hashOf = (file: { contents: string }): string => sha256Hex(file.contents); + +const fileEntry = (file: { contents: string; path: string }) => ({ + bytes: file.contents.length, + path: file.path, + sha256: hashOf(file), +}); + +const buildManifest = (): DeployManifest => + Schema.decodeUnknownSync(DeployManifestSchema)({ + assets: [], + cliVersion: "0.0.1", + components: [], + config: fileEntry(FILES.config), + createdAt: "2026-06-11T10:00:00.000Z", + paywalls: [ + { + artifacts: { + html: { + ...fileEntry(FILES.html), + contentType: "text/html; charset=utf-8", + }, + js: { + ...fileEntry(FILES.js), + contentType: "text/javascript; charset=utf-8", + }, + }, + assets: [], + contentHash: "0".repeat(64), + id: "onboarding", + products: [], + source: fileEntry(FILES.source), + title: "Onboarding", + variables: {}, + }, + ], + project: "dev-proj", + runtimeVersion: "0.0.1", + schemaVersion: 2, + team: "voidhash-dev-sro", + }); + +interface RecordedRequest { + readonly method: string; + readonly path: string; +} + +/** Scripted HTTP stub: routes requests, records calls, counts finalizes. */ +const makeStubClient = (options: { + /** `missing` returned by create-deploy. */ + createMissing: ReadonlyArray; + /** Per-attempt finalize responses (status + JSON body), consumed in order. */ + finalizeResponses: ReadonlyArray<{ status: number; body: unknown }>; + requests: RecordedRequest[]; +}): HttpClient.HttpClient => + HttpClient.make((request) => { + const path = new URL(request.url).pathname; + options.requests.push({ method: request.method, path }); + + const respond = (status: number, body: unknown) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }), + ), + ); + + if (request.method === "POST" && path === "/api/v1/paywall-deploys") { + return respond(201, { + deployId: "pw_dep_test", + missing: options.createMissing, + }); + } + if (request.method === "PUT" && path.includes("/blobs/")) { + return respond(200, {}); + } + if (request.method === "POST" && path.endsWith("/finalize")) { + const attempt = options.requests.filter( + (r) => r.method === "POST" && r.path.endsWith("/finalize"), + ).length; + const scripted = + options.finalizeResponses[attempt - 1] ?? + options.finalizeResponses[options.finalizeResponses.length - 1]; + return respond(scripted?.status ?? 500, scripted?.body ?? {}); + } + return respond(404, {}); + }); + +const cliConfigStub: typeof CliConfig.Service = { + readConfig: () => + Effect.succeed({ + api_key: "vh_sk_test", + api_url: "https://api.voidhash.test", + web_url: "https://voidhash.test", + }), + resetConfig: () => Effect.void, + writeToConfig: () => Effect.void, +}; + +const runUpload = (client: HttpClient.HttpClient): Promise => + Effect.runPromise( + uploadPaywallDeploy({ manifest, projectRoot }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(CliConfig, cliConfigStub), + ), + ); + +const runUploadError = (client: HttpClient.HttpClient): Promise => + Effect.runPromise( + uploadPaywallDeploy({ manifest, projectRoot }).pipe( + Effect.flip, + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(CliConfig, cliConfigStub), + ), + ); + +const readyFinalizeBody = { + components: [], + deployId: "pw_dep_test", + paywalls: [], + status: "ready", +}; + +beforeAll(async () => { + projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-deploy-upload-")); + for (const file of Object.values(FILES)) { + const abs = join(projectRoot, file.path); + await fsp.mkdir(join(abs, ".."), { recursive: true }); + await fsp.writeFile(abs, file.contents); + } + manifest = buildManifest(); +}); + +afterAll(async () => { + await fsp.rm(projectRoot, { force: true, recursive: true }); +}); + +describe("uploadPaywallDeploy finalize-409 retry", () => { + it("uploads the 409 missing blobs and retries finalize once", async () => { + const requests: RecordedRequest[] = []; + const result = await runUpload( + makeStubClient({ + createMissing: [hashOf(FILES.js)], + finalizeResponses: [ + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + { body: readyFinalizeBody, status: 200 }, + ], + requests, + }), + ); + + expect(result.finalize.status).toBe("ready"); + // One blob from create's missing list + one re-uploaded after the 409. + expect(result.uploadedCount).toBe(2); + const puts = requests.filter((r) => r.method === "PUT"); + expect(puts.map((r) => r.path)).toEqual([ + `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.js)}`, + `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.html)}`, + ]); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); + }); + + it("retries at most once and fails readably when finalize stays 409", async () => { + const requests: RecordedRequest[] = []; + const error = await runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [ + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + ], + requests, + }), + ); + + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(error.message).toContain("Finalizing the deploy failed"); + expect(error.message).toContain(hashOf(FILES.html)); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); + }); + + it("fails without retrying when the 409 carries no usable missing list", async () => { + const requests: RecordedRequest[] = []; + const error = await runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [{ body: { error: "incomplete" }, status: 409 }], + requests, + }), + ); + + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); + expect(requests.filter((r) => r.method === "PUT")).toHaveLength(0); + }); + + it("fails without retrying when a 409 hash is not part of the manifest", async () => { + const requests: RecordedRequest[] = []; + const error = await runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [{ body: { missing: ["f".repeat(64)] }, status: 409 }], + requests, + }), + ); + + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(error.message).toContain("f".repeat(64)); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-panel-bundle.test.ts b/apps/cli/tests/domain/services/paywall-panel-bundle.test.ts new file mode 100644 index 000000000..f61546fc5 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-panel-bundle.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + definitionHasPanel, + PANEL_SANDBOX_EXTERNALS, +} from "../../../src/domain/services/paywall-build"; + +describe("definitionHasPanel", () => { + it("is true only for a live panel FUNCTION (matching the browser pipeline)", () => { + expect(definitionHasPanel({ panel: () => null })).toBe(true); + }); + + it("is false for a present-but-non-function panel, or an absent one", () => { + // A JSX element / object is NOT a panel function — the browser sandbox's + // `definitionHasPanel` (`typeof panel === "function"`) also rejects these. + expect(definitionHasPanel({ panel: { type: "panel" } })).toBe(false); + expect(definitionHasPanel({ panel: null })).toBe(false); + expect(definitionHasPanel({ panel: undefined })).toBe(false); + expect(definitionHasPanel({})).toBe(false); + }); +}); + +describe("PANEL_SANDBOX_EXTERNALS", () => { + it("mirrors the studio panel sandbox require-shim module keys one-for-one", () => { + // Source of truth: `@voidhash/paywalls/sandbox`'s `modules` map keys (the + // specifiers the panel sandbox's require shim resolves). A panel bundle + // must leave EXACTLY these external so the shim satisfies every require and + // no second React/SDK instance is bundled. + expect([...PANEL_SANDBOX_EXTERNALS].sort()).toEqual( + [ + "@voidhash/paywalls", + "@voidhash/paywalls/jsx-dev-runtime", + "@voidhash/paywalls/jsx-runtime", + "@voidhash/paywalls/panel", + "react", + "react/jsx-dev-runtime", + "react/jsx-runtime", + ].sort(), + ); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-typecheck.test.ts b/apps/cli/tests/domain/services/paywall-typecheck.test.ts new file mode 100644 index 000000000..20d2b3771 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-typecheck.test.ts @@ -0,0 +1,101 @@ +import { promises as fsp } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect } from "effect"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + PAYWALL_ASSET_EXTENSIONS, + PaywallTypecheckError, + typecheckPaywallSources, +} from "../../../src/domain/services/paywall-typecheck"; + +let projectRoot: string; +const compilerTestTimeout = 60_000; + +const writeSource = async (relPath: string, contents: string) => { + const abs = join(projectRoot, relPath); + await fsp.mkdir(join(abs, ".."), { recursive: true }); + await fsp.writeFile(abs, contents); + return abs; +}; + +beforeAll(async () => { + projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-typecheck-")); +}); + +afterAll(async () => { + await fsp.rm(projectRoot, { force: true, recursive: true }); +}); + +describe("typecheckPaywallSources", () => { + it( + "passes a source importing a .png via the injected asset declarations", + async () => { + const entry = await writeSource( + ".voidhash/paywalls/with-asset.ts", + ['import hero from "./hero.png";', "export const heroUrl: string = hero;", ""].join("\n"), + ); + + await expect( + Effect.runPromise(typecheckPaywallSources({ files: [entry], projectRoot })), + ).resolves.toBeUndefined(); + }, + compilerTestTimeout, + ); + + it( + "covers every esbuild-supported asset extension", + async () => { + const imports = PAYWALL_ASSET_EXTENSIONS.map( + (ext, i) => `import asset${i} from "./asset.${ext}";`, + ); + const uses = PAYWALL_ASSET_EXTENSIONS.map( + (_, i) => `export const url${i}: string = asset${i};`, + ); + const entry = await writeSource( + ".voidhash/paywalls/all-assets.ts", + [...imports, ...uses, ""].join("\n"), + ); + + await expect( + Effect.runPromise(typecheckPaywallSources({ files: [entry], projectRoot })), + ).resolves.toBeUndefined(); + }, + compilerTestTimeout, + ); + + it( + "still fails a genuinely type-broken source", + async () => { + const entry = await writeSource( + ".voidhash/paywalls/broken.ts", + ['import hero from "./hero.png";', "export const broken: number = hero;", ""].join("\n"), + ); + + const error = await Effect.runPromise( + typecheckPaywallSources({ files: [entry], projectRoot }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(PaywallTypecheckError); + expect(error.message).toContain("broken.ts"); + }, + compilerTestTimeout, + ); + + it( + "still fails an import of an undeclared module kind", + async () => { + const entry = await writeSource( + ".voidhash/paywalls/bad-import.ts", + ['import data from "./data.bin";', "export const d = data;", ""].join("\n"), + ); + + const error = await Effect.runPromise( + typecheckPaywallSources({ files: [entry], projectRoot }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(PaywallTypecheckError); + }, + compilerTestTimeout, + ); +}); diff --git a/apps/cli/tests/fixtures/empty-schema.ts b/apps/cli/tests/fixtures/empty-schema.ts deleted file mode 100644 index e684fc181..000000000 --- a/apps/cli/tests/fixtures/empty-schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { schemaConfiguration } from "@voidhash/react-native/schema"; - -export const schema = schemaConfiguration({ - perks: {}, - providers: {}, -}); diff --git a/apps/cli/tests/fixtures/valid-schema.ts b/apps/cli/tests/fixtures/valid-schema.ts deleted file mode 100644 index 33b80a833..000000000 --- a/apps/cli/tests/fixtures/valid-schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - schemaConfiguration, - unlockablePerk, -} from "@voidhash/react-native/schema"; - -export const schema = schemaConfiguration({ - perks: { - allAccess: unlockablePerk("all-access", { name: "All Access" }), - premiumFeatures: unlockablePerk("premium-features", { - name: "Premium Features", - }), - }, - providers: { - appleAppStore: true, - googlePlay: true, - }, -}); - -export const monthlyPlan = schema.subscription("monthly-plan", { - name: "Monthly Plan", - perks: { allAccess: true }, - providers: { - appleAppStore: { productId: "com.example.monthly" }, - googlePlay: { productId: "monthly_subscription" }, - }, -}); - -export const yearlyPlan = schema.subscription("yearly-plan", { - name: "Yearly Plan", - perks: { allAccess: true, premiumFeatures: true }, - providers: { - appleAppStore: { productId: "com.example.yearly" }, - googlePlay: { productId: "yearly_subscription", basePlanId: "base-yearly" }, - }, -}); - -export const onboardingUpsell = schema.location("onboarding-upsell", { - description: "Shown after onboarding", - name: "Onboarding Upsell", -}); - -export const settingsPaywall = schema.location("settings-paywall", { - name: "Settings Paywall", -}); diff --git a/apps/cli/tests/helpers/schema-factories.ts b/apps/cli/tests/helpers/schema-factories.ts index d1715ff51..00ff040a3 100644 --- a/apps/cli/tests/helpers/schema-factories.ts +++ b/apps/cli/tests/helpers/schema-factories.ts @@ -1,96 +1,92 @@ import { - createEmptyNormalizedSchema, - type NormalizedPaywallLocation, - type NormalizedPerk, - type NormalizedProduct, - type NormalizedSchema, - type ProviderId, + createEmptyNormalizedSchema, + type NormalizedPaywallLocation, + type NormalizedPerk, + type NormalizedProduct, + type NormalizedSchema, + type ProviderId, } from "../../src/domain/schema/normalized-schema"; /** * Create a test perk with optional overrides */ -export function createTestPerk( - overrides: Partial = {}, -): NormalizedPerk { - return { - name: "Test Perk", - slug: "test-perk", - ...overrides, - }; +export function createTestPerk(overrides: Partial = {}): NormalizedPerk { + return { + name: "Test Perk", + slug: "test-perk", + ...overrides, + }; } /** * Create a test product with optional overrides */ -export function createTestProduct( - overrides: Partial = {}, -): NormalizedProduct { - return { - name: "Test Product", - perks: [], - providers: [], - slug: "test-product", - type: "subscription", - ...overrides, - }; +export function createTestProduct(overrides: Partial = {}): NormalizedProduct { + return { + name: "Test Product", + perks: [], + providers: [], + slug: "test-product", + type: "subscription", + ...overrides, + }; } /** * Create a test paywall location with optional overrides */ export function createTestPaywallLocation( - overrides: Partial = {}, + overrides: Partial = {}, ): NormalizedPaywallLocation { - return { - description: null, - name: "Test Location", - slug: "test-location", - ...overrides, - }; + return { + description: null, + name: "Test Location", + slug: "test-location", + ...overrides, + }; } /** * Create a test schema with optional perks, products, and providers */ export function createTestSchema( - options: { - perks?: NormalizedPerk[]; - products?: NormalizedProduct[]; - locations?: NormalizedPaywallLocation[]; - enabledProviders?: ProviderId[]; - } = {}, + options: { + perks?: NormalizedPerk[]; + products?: NormalizedProduct[]; + locations?: NormalizedPaywallLocation[]; + enabledProviders?: ProviderId[]; + } = {}, ): NormalizedSchema { - const schema = createEmptyNormalizedSchema(); + const schema = createEmptyNormalizedSchema(); - for (const location of options.locations ?? []) { - schema.locations.set(location.slug, location); - } + for (const location of options.locations ?? []) { + schema.locations.set(location.slug, location); + } - for (const perk of options.perks ?? []) { - schema.perks.set(perk.slug, perk); - } + for (const perk of options.perks ?? []) { + schema.perks.set(perk.slug, perk); + } - for (const product of options.products ?? []) { - schema.products.set(product.slug, product); - } + for (const product of options.products ?? []) { + schema.products.set(product.slug, product); + } - for (const provider of options.enabledProviders ?? []) { - schema.enabledProviders.add(provider); - } + for (const provider of options.enabledProviders ?? []) { + schema.enabledProviders.add(provider); + } - return schema; + return schema; } /** * Create a provider configuration for a product */ export function createProviderConfig( - providerId: ProviderId, - configuration: Record = {}, + providerId: ProviderId, + configuration: Record = {}, ): { providerId: ProviderId; configuration: Record } { - return { - configuration, - providerId, - }; + return { + configuration, + providerId, + }; } diff --git a/apps/cli/tests/utils/schema/changeset-builder.test.ts b/apps/cli/tests/utils/schema/changeset-builder.test.ts deleted file mode 100644 index a2165ebad..000000000 --- a/apps/cli/tests/utils/schema/changeset-builder.test.ts +++ /dev/null @@ -1,991 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildChangeset, - formatChange, - formatChangeShort, -} from "../../../src/utils/schema/changeset-builder"; -import { computeDiff } from "../../../src/utils/schema/diff"; -import { - createProviderConfig, - createTestPaywallLocation, - createTestPerk, - createTestProduct, - createTestSchema, -} from "../../helpers/schema-factories"; - -describe("buildChangeset", () => { - describe("empty diff", () => { - it("returns empty changeset for empty diff", () => { - const local = createTestSchema({}); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(0); - }); - }); - - describe("paywall location changes", () => { - it("generates create-paywall-location for new locations", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: "Shown on launch", - name: "Onboarding", - slug: "onboarding", - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "create-paywall-location", - key: "onboarding", - payload: { - description: "Shown on launch", - name: "Onboarding", - slug: "onboarding", - }, - }); - }); - - it("generates update-paywall-location for updated locations", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: null, - name: "Onboarding New", - slug: "onboarding", - }), - ], - }); - const remote = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: "Shown on launch", - name: "Onboarding Old", - slug: "onboarding", - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "update-paywall-location", - key: "onboarding", - payload: { - description: null, - name: "Onboarding New", - slug: "onboarding", - }, - }); - }); - - it("generates archive-paywall-location for remote-only locations", () => { - const local = createTestSchema({}); - const remote = createTestSchema({ - locations: [ - createTestPaywallLocation({ - name: "Onboarding", - slug: "onboarding", - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "archive-paywall-location", - key: "onboarding", - payload: { slug: "onboarding" }, - }); - }); - }); - - describe("perk changes", () => { - it("generates create-perk for new perks", () => { - const local = createTestSchema({ - perks: [createTestPerk({ slug: "new-perk", name: "New Perk" })], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "create-perk", - key: "new-perk", - payload: { name: "New Perk", slug: "new-perk" }, - }); - }); - - it("generates update-perk for updated perks", () => { - const local = createTestSchema({ - perks: [createTestPerk({ slug: "perk-1", name: "Updated Name" })], - }); - const remote = createTestSchema({ - perks: [createTestPerk({ slug: "perk-1", name: "Original Name" })], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "update-perk", - key: "perk-1", - payload: { name: "Updated Name", slug: "perk-1" }, - }); - }); - - it("generates multiple perk changes in correct order", () => { - const local = createTestSchema({ - perks: [ - createTestPerk({ slug: "new-perk", name: "New" }), - createTestPerk({ slug: "updated-perk", name: "Updated Local" }), - ], - }); - const remote = createTestSchema({ - perks: [createTestPerk({ slug: "updated-perk", name: "Updated Remote" })], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(2); - // Creates should come before updates - expect(changeset.changes[0]?.changeType).toBe("create-perk"); - expect(changeset.changes[1]?.changeType).toBe("update-perk"); - }); - }); - - describe("product creation", () => { - it("generates create-product for new products", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New Product", - perks: [], - providers: [], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const createProductChange = changeset.changes.find( - (c) => c.changeType === "create-product", - ); - expect(createProductChange).toEqual({ - changeType: "create-product", - key: "new-product", - payload: { name: "New Product", slug: "new-product" }, - }); - }); - - it("generates create-product-perk for each perk on new product", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New Product", - perks: ["perk-1", "perk-2"], - providers: [], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const productPerkChanges = changeset.changes.filter( - (c) => c.changeType === "create-product-perk", - ); - expect(productPerkChanges).toHaveLength(2); - expect(productPerkChanges).toContainEqual({ - changeType: "create-product-perk", - key: "new-product:perk-1", - payload: { perkSlug: "perk-1", productSlug: "new-product" }, - }); - expect(productPerkChanges).toContainEqual({ - changeType: "create-product-perk", - key: "new-product:perk-2", - payload: { perkSlug: "perk-2", productSlug: "new-product" }, - }); - }); - - it("generates create-payment-provider-product for each provider on new product", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New Product", - perks: [], - providers: [ - createProviderConfig("appleAppStore", { - productId: "com.app.monthly", - }), - createProviderConfig("googlePlay", { - productId: "monthly_subscription", - }), - ], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const providerChanges = changeset.changes.filter( - (c) => c.changeType === "create-payment-provider-product", - ); - expect(providerChanges).toHaveLength(2); - expect(providerChanges).toContainEqual({ - changeType: "create-payment-provider-product", - key: "new-product:appleAppStore", - payload: { - configuration: { productId: "com.app.monthly" }, - productSlug: "new-product", - providerId: "appleAppStore", - }, - }); - }); - - it("generates changes in correct order: product, then perks, then providers", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New Product", - perks: ["perk-1"], - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const changeTypes = changeset.changes.map((c) => c.changeType); - const productIndex = changeTypes.indexOf("create-product"); - const perkIndex = changeTypes.indexOf("create-product-perk"); - const providerIndex = changeTypes.indexOf("create-payment-provider-product"); - - expect(productIndex).toBeLessThan(perkIndex); - expect(perkIndex).toBeLessThan(providerIndex); - }); - }); - - describe("product updates", () => { - it("generates update-product when name changes", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Updated Name", - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Original Name", - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const updateProductChange = changeset.changes.find( - (c) => c.changeType === "update-product", - ); - expect(updateProductChange).toEqual({ - changeType: "update-product", - key: "product-1", - payload: { name: "Updated Name", slug: "product-1" }, - }); - }); - - it("does not generate update-product when only perks/providers change", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1"], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const updateProductChange = changeset.changes.find( - (c) => c.changeType === "update-product", - ); - expect(updateProductChange).toBeUndefined(); - }); - - it("generates create-product-perk for newly added perks", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1"], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const createPerkChanges = changeset.changes.filter( - (c) => c.changeType === "create-product-perk", - ); - expect(createPerkChanges).toHaveLength(1); - expect(createPerkChanges[0]).toEqual({ - changeType: "create-product-perk", - key: "product-1:perk-2", - payload: { perkSlug: "perk-2", productSlug: "product-1" }, - }); - }); - - it("does not generate delete-product-perk for removed perks", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1"], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const deleteChanges = changeset.changes.filter( - (c) => c.changeType === "delete-product-perk", - ); - expect(deleteChanges).toHaveLength(0); - }); - - it("generates create-payment-provider-product for new provider", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const createProviderChanges = changeset.changes.filter( - (c) => c.changeType === "create-payment-provider-product", - ); - expect(createProviderChanges).toHaveLength(1); - expect(createProviderChanges[0]).toEqual({ - changeType: "create-payment-provider-product", - key: "product-1:appleAppStore", - payload: { - configuration: { productId: "com.app.1" }, - productSlug: "product-1", - providerId: "appleAppStore", - }, - }); - }); - - it("generates update-payment-provider-product for changed config", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { - productId: "com.app.new", - }), - ], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { - productId: "com.app.old", - }), - ], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const updateProviderChanges = changeset.changes.filter( - (c) => c.changeType === "update-payment-provider-product", - ); - expect(updateProviderChanges).toHaveLength(1); - expect(updateProviderChanges[0]).toEqual({ - changeType: "update-payment-provider-product", - key: "product-1:appleAppStore", - payload: { - configuration: { productId: "com.app.new" }, - productSlug: "product-1", - providerId: "appleAppStore", - }, - }); - }); - - it("does not generate delete for removed providers", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const deleteChanges = changeset.changes.filter( - (c) => c.changeType === "delete-payment-provider-product", - ); - expect(deleteChanges).toHaveLength(0); - }); - }); - - describe("ordering", () => { - it("orders: create-paywall-location before create-perk", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - name: "Onboarding", - slug: "onboarding", - }), - ], - perks: [createTestPerk({ slug: "new-perk", name: "New" })], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const locationIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-paywall-location", - ); - const perkIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-perk", - ); - - expect(locationIndex).toBeLessThan(perkIndex); - }); - - it("orders: create-perk before update-perk", () => { - const local = createTestSchema({ - perks: [ - createTestPerk({ slug: "new-perk", name: "New" }), - createTestPerk({ slug: "updated-perk", name: "Updated Local" }), - ], - }); - const remote = createTestSchema({ - perks: [createTestPerk({ slug: "updated-perk", name: "Updated Remote" })], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const createIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-perk", - ); - const updateIndex = changeset.changes.findIndex( - (c) => c.changeType === "update-perk", - ); - - expect(createIndex).toBeLessThan(updateIndex); - }); - - it("orders: perk changes before product changes", () => { - const local = createTestSchema({ - perks: [createTestPerk({ slug: "new-perk", name: "New" })], - products: [createTestProduct({ slug: "new-product", name: "New" })], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const perkIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-perk", - ); - const productIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-product", - ); - - expect(perkIndex).toBeLessThan(productIndex); - }); - - it("orders: create-product before product-perk and provider changes", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New", - perks: ["perk-1"], - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const productIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-product", - ); - const perkIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-product-perk", - ); - const providerIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-payment-provider-product", - ); - - expect(productIndex).toBeLessThan(perkIndex); - expect(productIndex).toBeLessThan(providerIndex); - }); - - it("orders: archive-paywall-location after creates/updates", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - name: "Onboarding New", - slug: "onboarding", - }), - ], - perks: [createTestPerk({ slug: "new-perk", name: "New" })], - }); - const remote = createTestSchema({ - locations: [ - createTestPaywallLocation({ - name: "Legacy", - slug: "legacy", - }), - createTestPaywallLocation({ - description: "Old", - name: "Onboarding Old", - slug: "onboarding", - }), - ], - }); - const diff = computeDiff(local, remote); - const changeset = buildChangeset(diff); - - const createLocationIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-paywall-location", - ); - const updateLocationIndex = changeset.changes.findIndex( - (c) => c.changeType === "update-paywall-location", - ); - const archiveLocationIndex = changeset.changes.findIndex( - (c) => c.changeType === "archive-paywall-location", - ); - - expect(createLocationIndex).toBe(-1); - expect(updateLocationIndex).toBeGreaterThan(-1); - expect(archiveLocationIndex).toBeGreaterThan(updateLocationIndex); - }); - }); -}); - -describe("formatChange", () => { - it("formats paywall location changes correctly", () => { - expect( - formatChange({ - changeType: "create-paywall-location", - key: "onboarding", - payload: { - description: "Shown after onboarding", - name: "Onboarding", - slug: "onboarding", - }, - }), - ).toBe('+ Create paywall location: onboarding ("Onboarding")'); - - expect( - formatChange({ - changeType: "update-paywall-location", - key: "onboarding", - payload: { - description: null, - name: "Onboarding Updated", - slug: "onboarding", - }, - }), - ).toBe('~ Update paywall location: onboarding ("Onboarding Updated")'); - - expect( - formatChange({ - changeType: "archive-paywall-location", - key: "onboarding", - payload: { slug: "onboarding" }, - }), - ).toBe("- Archive paywall location: onboarding"); - }); - - it("formats create-perk correctly", () => { - const result = formatChange({ - changeType: "create-perk", - key: "all-access", - payload: { name: "All Access", slug: "all-access" }, - }); - expect(result).toBe('+ Create perk: all-access ("All Access")'); - }); - - it("formats update-perk correctly", () => { - const result = formatChange({ - changeType: "update-perk", - key: "all-access", - payload: { name: "All Access Updated", slug: "all-access" }, - }); - expect(result).toBe('~ Update perk: all-access ("All Access Updated")'); - }); - - it("formats create-product correctly", () => { - const result = formatChange({ - changeType: "create-product", - key: "monthly", - payload: { name: "Monthly Plan", slug: "monthly" }, - }); - expect(result).toBe('+ Create product: monthly ("Monthly Plan")'); - }); - - it("formats update-product correctly", () => { - const result = formatChange({ - changeType: "update-product", - key: "monthly", - payload: { name: "Monthly Plan Updated", slug: "monthly" }, - }); - expect(result).toBe('~ Update product: monthly ("Monthly Plan Updated")'); - }); - - it("formats create-product-perk correctly", () => { - const result = formatChange({ - changeType: "create-product-perk", - key: "monthly:all-access", - payload: { perkSlug: "all-access", productSlug: "monthly" }, - }); - expect(result).toBe('+ Link perk "all-access" to product "monthly"'); - }); - - it("formats delete-product-perk correctly", () => { - const result = formatChange({ - changeType: "delete-product-perk", - key: "monthly:all-access", - payload: { perkSlug: "all-access", productSlug: "monthly" }, - }); - expect(result).toBe('- Unlink perk "all-access" from product "monthly"'); - }); - - it("formats create-payment-provider-product correctly", () => { - const result = formatChange({ - changeType: "create-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - configuration: { productId: "com.app.monthly" }, - productSlug: "monthly", - providerId: "appleAppStore", - }, - }); - expect(result).toBe('+ Configure appleAppStore for product "monthly"'); - }); - - it("formats update-payment-provider-product correctly", () => { - const result = formatChange({ - changeType: "update-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - configuration: { productId: "com.app.monthly.v2" }, - productSlug: "monthly", - providerId: "appleAppStore", - }, - }); - expect(result).toBe('~ Update appleAppStore config for product "monthly"'); - }); - - it("formats delete-payment-provider-product correctly", () => { - const result = formatChange({ - changeType: "delete-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - productSlug: "monthly", - providerId: "appleAppStore", - }, - }); - expect(result).toBe('- Remove appleAppStore config from product "monthly"'); - }); - - it("formats delete-perk correctly", () => { - const result = formatChange({ - changeType: "delete-perk", - key: "all-access", - payload: { slug: "all-access" }, - }); - expect(result).toBe("- Delete perk: all-access"); - }); - - it("formats delete-product correctly", () => { - const result = formatChange({ - changeType: "delete-product", - key: "monthly", - payload: { slug: "monthly" }, - }); - expect(result).toBe("- Delete product: monthly"); - }); - - it("returns fallback for unknown change type", () => { - const result = formatChange({ - changeType: "unknown-type" as never, - key: "test", - payload: {}, - } as never); - expect(result).toBe("? Unknown change type"); - }); -}); - -describe("formatChangeShort", () => { - it("formats paywall location changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-paywall-location", - key: "onboarding", - payload: { - description: "Shown after onboarding", - name: "Onboarding", - slug: "onboarding", - }, - }), - ).toBe("PaywallLocation: onboarding"); - - expect( - formatChangeShort({ - changeType: "update-paywall-location", - key: "onboarding", - payload: { - description: null, - name: "Onboarding Updated", - slug: "onboarding", - }, - }), - ).toBe("PaywallLocation: onboarding"); - - expect( - formatChangeShort({ - changeType: "archive-paywall-location", - key: "onboarding", - payload: { slug: "onboarding" }, - }), - ).toBe("PaywallLocation: onboarding"); - }); - - it("formats perk changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-perk", - key: "all-access", - payload: { name: "All Access", slug: "all-access" }, - }), - ).toBe("Perk: all-access"); - - expect( - formatChangeShort({ - changeType: "update-perk", - key: "all-access", - payload: { name: "All Access", slug: "all-access" }, - }), - ).toBe("Perk: all-access"); - - expect( - formatChangeShort({ - changeType: "delete-perk", - key: "all-access", - payload: { slug: "all-access" }, - }), - ).toBe("Perk: all-access"); - }); - - it("formats product changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-product", - key: "monthly", - payload: { name: "Monthly", slug: "monthly" }, - }), - ).toBe("Product: monthly"); - - expect( - formatChangeShort({ - changeType: "update-product", - key: "monthly", - payload: { name: "Monthly", slug: "monthly" }, - }), - ).toBe("Product: monthly"); - - expect( - formatChangeShort({ - changeType: "delete-product", - key: "monthly", - payload: { slug: "monthly" }, - }), - ).toBe("Product: monthly"); - }); - - it("formats product-perk changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-product-perk", - key: "monthly:all-access", - payload: { perkSlug: "all-access", productSlug: "monthly" }, - }), - ).toBe("ProductPerk: monthly:all-access"); - - expect( - formatChangeShort({ - changeType: "delete-product-perk", - key: "monthly:all-access", - payload: { perkSlug: "all-access", productSlug: "monthly" }, - }), - ).toBe("ProductPerk: monthly:all-access"); - }); - - it("formats provider-product changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - configuration: {}, - productSlug: "monthly", - providerId: "appleAppStore", - }, - }), - ).toBe("ProviderProduct: monthly:appleAppStore"); - - expect( - formatChangeShort({ - changeType: "update-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - configuration: {}, - productSlug: "monthly", - providerId: "appleAppStore", - }, - }), - ).toBe("ProviderProduct: monthly:appleAppStore"); - - expect( - formatChangeShort({ - changeType: "delete-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - productSlug: "monthly", - providerId: "appleAppStore", - }, - }), - ).toBe("ProviderProduct: monthly:appleAppStore"); - }); - - it("returns Unknown for unknown change type", () => { - const result = formatChangeShort({ - changeType: "unknown-type" as never, - key: "test", - payload: {}, - } as never); - expect(result).toBe("Unknown"); - }); -}); diff --git a/apps/cli/tests/utils/schema/diff.test.ts b/apps/cli/tests/utils/schema/diff.test.ts deleted file mode 100644 index fbeb170d2..000000000 --- a/apps/cli/tests/utils/schema/diff.test.ts +++ /dev/null @@ -1,648 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { computeDiff, summarizeDiff } from "../../../src/utils/schema/diff"; -import { - createProviderConfig, - createTestPaywallLocation, - createTestPerk, - createTestProduct, - createTestSchema, -} from "../../helpers/schema-factories"; - -describe("computeDiff", () => { - describe("locations", () => { - it("returns empty diff for identical locations", () => { - const location = createTestPaywallLocation({ - slug: "onboarding", - name: "Onboarding", - }); - const local = createTestSchema({ locations: [location] }); - const remote = createTestSchema({ locations: [location] }); - - const diff = computeDiff(local, remote); - - expect(diff.locations.toCreate).toHaveLength(0); - expect(diff.locations.toUpdate).toHaveLength(0); - expect(diff.locations.remoteOnly).toHaveLength(0); - expect(diff.locations.toArchive).toHaveLength(0); - }); - - it("identifies locations to create (local only)", () => { - const localLocation = createTestPaywallLocation({ - slug: "onboarding", - name: "Onboarding", - }); - const local = createTestSchema({ locations: [localLocation] }); - const remote = createTestSchema({ locations: [] }); - - const diff = computeDiff(local, remote); - - expect(diff.locations.toCreate).toHaveLength(1); - expect(diff.locations.toCreate[0]).toEqual(localLocation); - expect(diff.locations.toUpdate).toHaveLength(0); - expect(diff.locations.remoteOnly).toHaveLength(0); - expect(diff.locations.toArchive).toHaveLength(0); - }); - - it("identifies locations to update (same slug, different metadata)", () => { - const localLocation = createTestPaywallLocation({ - description: null, - slug: "onboarding", - name: "Onboarding New", - }); - const remoteLocation = createTestPaywallLocation({ - description: "Shown after launch", - slug: "onboarding", - name: "Onboarding Old", - }); - const local = createTestSchema({ locations: [localLocation] }); - const remote = createTestSchema({ locations: [remoteLocation] }); - - const diff = computeDiff(local, remote); - - expect(diff.locations.toCreate).toHaveLength(0); - expect(diff.locations.toUpdate).toHaveLength(1); - expect(diff.locations.toUpdate[0]).toEqual({ - local: localLocation, - remote: remoteLocation, - }); - }); - - it("identifies remote-only locations to archive", () => { - const remoteLocation = createTestPaywallLocation({ - slug: "onboarding", - name: "Onboarding", - }); - const local = createTestSchema({ locations: [] }); - const remote = createTestSchema({ locations: [remoteLocation] }); - - const diff = computeDiff(local, remote); - - expect(diff.locations.remoteOnly).toHaveLength(1); - expect(diff.locations.toArchive).toHaveLength(1); - expect(diff.locations.toArchive[0]).toEqual(remoteLocation); - }); - }); - - describe("perks", () => { - it("returns empty diff for identical schemas", () => { - const perk = createTestPerk({ slug: "perk-1", name: "Perk 1" }); - const local = createTestSchema({ perks: [perk] }); - const remote = createTestSchema({ perks: [perk] }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(0); - expect(diff.perks.toUpdate).toHaveLength(0); - expect(diff.perks.remoteOnly).toHaveLength(0); - }); - - it("identifies perks to create (local only)", () => { - const localPerk = createTestPerk({ slug: "local-perk", name: "Local" }); - const local = createTestSchema({ perks: [localPerk] }); - const remote = createTestSchema({ perks: [] }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(1); - expect(diff.perks.toCreate[0]).toEqual(localPerk); - expect(diff.perks.toUpdate).toHaveLength(0); - expect(diff.perks.remoteOnly).toHaveLength(0); - }); - - it("identifies remote-only perks", () => { - const remotePerk = createTestPerk({ - slug: "remote-perk", - name: "Remote", - }); - const local = createTestSchema({ perks: [] }); - const remote = createTestSchema({ perks: [remotePerk] }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(0); - expect(diff.perks.toUpdate).toHaveLength(0); - expect(diff.perks.remoteOnly).toHaveLength(1); - expect(diff.perks.remoteOnly[0]).toEqual(remotePerk); - }); - - it("identifies perks to update (same slug, different name)", () => { - const localPerk = createTestPerk({ slug: "perk-1", name: "Updated Name" }); - const remotePerk = createTestPerk({ - slug: "perk-1", - name: "Original Name", - }); - const local = createTestSchema({ perks: [localPerk] }); - const remote = createTestSchema({ perks: [remotePerk] }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(0); - expect(diff.perks.toUpdate).toHaveLength(1); - expect(diff.perks.toUpdate[0]).toEqual({ - local: localPerk, - remote: remotePerk, - }); - expect(diff.perks.remoteOnly).toHaveLength(0); - }); - - it("handles multiple perks correctly", () => { - const sharedPerk = createTestPerk({ slug: "shared", name: "Shared" }); - const localOnlyPerk = createTestPerk({ - slug: "local-only", - name: "Local Only", - }); - const remoteOnlyPerk = createTestPerk({ - slug: "remote-only", - name: "Remote Only", - }); - const localUpdatedPerk = createTestPerk({ - slug: "updated", - name: "Updated Local", - }); - const remoteUpdatedPerk = createTestPerk({ - slug: "updated", - name: "Updated Remote", - }); - - const local = createTestSchema({ - perks: [sharedPerk, localOnlyPerk, localUpdatedPerk], - }); - const remote = createTestSchema({ - perks: [sharedPerk, remoteOnlyPerk, remoteUpdatedPerk], - }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(1); - expect(diff.perks.toCreate[0]?.slug).toBe("local-only"); - expect(diff.perks.toUpdate).toHaveLength(1); - expect(diff.perks.toUpdate[0]?.local.slug).toBe("updated"); - expect(diff.perks.remoteOnly).toHaveLength(1); - expect(diff.perks.remoteOnly[0]?.slug).toBe("remote-only"); - }); - }); - - describe("products", () => { - it("identifies products to create (local only)", () => { - const localProduct = createTestProduct({ - slug: "local-product", - name: "Local", - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(1); - expect(diff.products.toCreate[0]).toEqual(localProduct); - expect(diff.products.toUpdate).toHaveLength(0); - expect(diff.products.remoteOnly).toHaveLength(0); - }); - - it("identifies remote-only products", () => { - const remoteProduct = createTestProduct({ - slug: "remote-product", - name: "Remote", - }); - const local = createTestSchema({ products: [] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.toUpdate).toHaveLength(0); - expect(diff.products.remoteOnly).toHaveLength(1); - expect(diff.products.remoteOnly[0]).toEqual(remoteProduct); - }); - - it("identifies products to update when name differs", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Updated Name", - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Original Name", - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.toUpdate).toHaveLength(1); - expect(diff.products.toUpdate[0]).toEqual({ - local: localProduct, - remote: remoteProduct, - }); - }); - - it("identifies products to update when perks differ", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1"], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(1); - expect(diff.products.toUpdate[0]?.local.perks).toEqual([ - "perk-1", - "perk-2", - ]); - expect(diff.products.toUpdate[0]?.remote.perks).toEqual(["perk-1"]); - }); - - it("identifies products to update when providers differ", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(1); - }); - - it("identifies products to update when provider config differs", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.new" }), - ], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.old" }), - ], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(1); - }); - - it("handles products with no perks", () => { - const product = createTestProduct({ - slug: "no-perks", - name: "No Perks", - perks: [], - }); - const local = createTestSchema({ products: [product] }); - const remote = createTestSchema({ products: [product] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.toUpdate).toHaveLength(0); - }); - - it("handles products with no providers", () => { - const product = createTestProduct({ - slug: "no-providers", - name: "No Providers", - providers: [], - }); - const local = createTestSchema({ products: [product] }); - const remote = createTestSchema({ products: [product] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.toUpdate).toHaveLength(0); - }); - - it("treats products with same perks in different order as equal", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-2", "perk-1"], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(0); - }); - - it("treats products with same providers in different order as equal", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("googlePlay", { productId: "google.1" }), - createProviderConfig("appleAppStore", { productId: "apple.1" }), - ], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "apple.1" }), - createProviderConfig("googlePlay", { productId: "google.1" }), - ], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(0); - }); - }); - - describe("complex scenarios", () => { - it("handles mixed creates, updates, and remote-only items", () => { - const sharedPerk = createTestPerk({ slug: "shared", name: "Shared" }); - const localPerk = createTestPerk({ - slug: "local-perk", - name: "Local Perk", - }); - const remotePerk = createTestPerk({ - slug: "remote-perk", - name: "Remote Perk", - }); - - const sharedProduct = createTestProduct({ - slug: "shared-product", - name: "Shared", - }); - const localProduct = createTestProduct({ - slug: "local-product", - name: "Local", - }); - const localUpdatedProduct = createTestProduct({ - slug: "updated-product", - name: "Updated Local", - }); - const remoteProduct = createTestProduct({ - slug: "remote-product", - name: "Remote", - }); - const remoteUpdatedProduct = createTestProduct({ - slug: "updated-product", - name: "Updated Remote", - }); - - const local = createTestSchema({ - perks: [sharedPerk, localPerk], - products: [sharedProduct, localProduct, localUpdatedProduct], - }); - const remote = createTestSchema({ - perks: [sharedPerk, remotePerk], - products: [sharedProduct, remoteProduct, remoteUpdatedProduct], - }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(1); - expect(diff.perks.remoteOnly).toHaveLength(1); - expect(diff.products.toCreate).toHaveLength(1); - expect(diff.products.toUpdate).toHaveLength(1); - expect(diff.products.remoteOnly).toHaveLength(1); - }); - - it("handles empty local schema against populated remote", () => { - const remotePerk = createTestPerk({ - slug: "remote-perk", - name: "Remote", - }); - const remoteProduct = createTestProduct({ - slug: "remote-product", - name: "Remote", - }); - - const local = createTestSchema({}); - const remote = createTestSchema({ - perks: [remotePerk], - products: [remoteProduct], - }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(0); - expect(diff.perks.remoteOnly).toHaveLength(1); - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.remoteOnly).toHaveLength(1); - }); - - it("handles populated local schema against empty remote", () => { - const localPerk = createTestPerk({ slug: "local-perk", name: "Local" }); - const localProduct = createTestProduct({ - slug: "local-product", - name: "Local", - }); - - const local = createTestSchema({ - perks: [localPerk], - products: [localProduct], - }); - const remote = createTestSchema({}); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(1); - expect(diff.perks.remoteOnly).toHaveLength(0); - expect(diff.products.toCreate).toHaveLength(1); - expect(diff.products.remoteOnly).toHaveLength(0); - }); - }); -}); - -describe("summarizeDiff", () => { - it("returns zeros for empty diff", () => { - const local = createTestSchema({}); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.perksToCreate).toBe(0); - expect(summary.locationsToCreate).toBe(0); - expect(summary.locationsToUpdate).toBe(0); - expect(summary.locationsToArchive).toBe(0); - expect(summary.locationsRemoteOnly).toBe(0); - expect(summary.perksToUpdate).toBe(0); - expect(summary.perksRemoteOnly).toBe(0); - expect(summary.productsToCreate).toBe(0); - expect(summary.productsToUpdate).toBe(0); - expect(summary.productsRemoteOnly).toBe(0); - expect(summary.totalChanges).toBe(0); - }); - - it("correctly counts perks to create", () => { - const local = createTestSchema({ - perks: [ - createTestPerk({ slug: "perk-1", name: "Perk 1" }), - createTestPerk({ slug: "perk-2", name: "Perk 2" }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.perksToCreate).toBe(2); - }); - - it("correctly counts perks to update", () => { - const local = createTestSchema({ - perks: [createTestPerk({ slug: "perk-1", name: "Updated" })], - }); - const remote = createTestSchema({ - perks: [createTestPerk({ slug: "perk-1", name: "Original" })], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.perksToUpdate).toBe(1); - }); - - it("correctly counts remote-only perks", () => { - const local = createTestSchema({}); - const remote = createTestSchema({ - perks: [ - createTestPerk({ slug: "perk-1", name: "Perk 1" }), - createTestPerk({ slug: "perk-2", name: "Perk 2" }), - createTestPerk({ slug: "perk-3", name: "Perk 3" }), - ], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.perksRemoteOnly).toBe(3); - }); - - it("correctly counts products to create", () => { - const local = createTestSchema({ - products: [createTestProduct({ slug: "product-1", name: "Product 1" })], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.productsToCreate).toBe(1); - }); - - it("correctly counts products to update", () => { - const local = createTestSchema({ - products: [createTestProduct({ slug: "product-1", name: "Updated" })], - }); - const remote = createTestSchema({ - products: [createTestProduct({ slug: "product-1", name: "Original" })], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.productsToUpdate).toBe(1); - }); - - it("correctly counts remote-only products", () => { - const local = createTestSchema({}); - const remote = createTestSchema({ - products: [ - createTestProduct({ slug: "product-1", name: "Product 1" }), - createTestProduct({ slug: "product-2", name: "Product 2" }), - ], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.productsRemoteOnly).toBe(2); - }); - - it("totalChanges includes location archive actions derived from remote-only locations", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: null, - slug: "new-location", - name: "New Location", - }), - createTestPaywallLocation({ - description: null, - slug: "updated-location", - name: "Updated Local", - }), - ], - perks: [ - createTestPerk({ slug: "new-perk", name: "New" }), - createTestPerk({ slug: "updated-perk", name: "Updated Local" }), - ], - products: [ - createTestProduct({ slug: "new-product", name: "New" }), - createTestProduct({ slug: "updated-product", name: "Updated Local" }), - ], - }); - const remote = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: "Old description", - slug: "updated-location", - name: "Updated Remote", - }), - createTestPaywallLocation({ - slug: "remote-only-location", - name: "Remote Only", - }), - ], - perks: [ - createTestPerk({ slug: "updated-perk", name: "Updated Remote" }), - createTestPerk({ slug: "remote-only-perk", name: "Remote Only" }), - ], - products: [ - createTestProduct({ slug: "updated-product", name: "Updated Remote" }), - createTestProduct({ - slug: "remote-only-product", - name: "Remote Only", - }), - ], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - // 1 location to create + 1 location to update + 1 location to archive + - // 1 perk to create + 1 perk to update + 1 product to create + 1 product to update = 7 - expect(summary.totalChanges).toBe(7); - expect(summary.locationsRemoteOnly).toBe(1); - expect(summary.locationsToArchive).toBe(1); - expect(summary.perksRemoteOnly).toBe(1); - expect(summary.productsRemoteOnly).toBe(1); - }); -}); diff --git a/apps/cli/tests/utils/schema/local-schema-loader.test.ts b/apps/cli/tests/utils/schema/local-schema-loader.test.ts deleted file mode 100644 index 3246263a7..000000000 --- a/apps/cli/tests/utils/schema/local-schema-loader.test.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Effect, Exit, Layer } from "effect"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -import { - extractPerkSlugs, - extractProviderConfigs, - isPaywallLocationDefinition, - isPerkDefinition, - isProductDefinition, - isSchemaConfiguration, - loadLocalSchema, - SCHEMA_KIND, - SchemaKind, - slugToCamelCase, -} from "../../../src/utils/schema/local-schema-loader"; - -// Create a minimal layer for testing that only includes what we need -const TestLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer); - -describe("slugToCamelCase", () => { - it("converts hyphenated slug", () => { - expect(slugToCamelCase("all-access")).toBe("allAccess"); - }); - - it("converts underscored slug", () => { - expect(slugToCamelCase("monthly_sub")).toBe("monthlySub"); - }); - - it("handles simple slug", () => { - expect(slugToCamelCase("simple")).toBe("simple"); - }); - - it("handles multi-part slug", () => { - expect(slugToCamelCase("multi-part-slug")).toBe("multiPartSlug"); - }); - - it("handles uppercase", () => { - expect(slugToCamelCase("UPPER_CASE")).toBe("upperCase"); - }); - - it("handles mixed separators", () => { - expect(slugToCamelCase("foo-bar_baz")).toBe("fooBarBaz"); - }); - - it("handles empty string", () => { - expect(slugToCamelCase("")).toBe(""); - }); -}); - -describe("extractPerkSlugs", () => { - it("returns empty array for undefined config", () => { - const allPerks = new Map(); - expect(extractPerkSlugs(undefined, allPerks)).toEqual([]); - }); - - it("returns empty array for empty config", () => { - const allPerks = new Map(); - expect(extractPerkSlugs({}, allPerks)).toEqual([]); - }); - - it("extracts enabled perks (value=true)", () => { - const allPerks = new Map([ - ["all-access", { slug: "all-access", name: "All Access" }], - ["premium", { slug: "premium", name: "Premium" }], - ]); - const config = { allAccess: true, premium: false }; - - const result = extractPerkSlugs(config, allPerks); - - expect(result).toEqual(["all-access"]); - }); - - it("skips metadata key '_'", () => { - const allPerks = new Map([ - ["all-access", { slug: "all-access", name: "All Access" }], - ]); - const config = { - _: { perks: {} } as unknown as boolean, - allAccess: true, - }; - - const result = extractPerkSlugs(config, allPerks); - - expect(result).toEqual(["all-access"]); - }); - - it("correctly maps camelCase keys to slugs via allPerks map", () => { - const allPerks = new Map([ - ["premium-features", { slug: "premium-features", name: "Premium" }], - ["all-access", { slug: "all-access", name: "All Access" }], - ]); - const config = { premiumFeatures: true, allAccess: true }; - - const result = extractPerkSlugs(config, allPerks); - - expect(result).toContain("premium-features"); - expect(result).toContain("all-access"); - }); - - it("ignores perks not in allPerks map", () => { - const allPerks = new Map([ - ["existing-perk", { slug: "existing-perk", name: "Existing" }], - ]); - const config = { nonExistentPerk: true }; - - const result = extractPerkSlugs(config, allPerks); - - expect(result).toEqual([]); - }); -}); - -describe("extractProviderConfigs", () => { - it("returns empty array for undefined config", () => { - expect(extractProviderConfigs(undefined)).toEqual([]); - }); - - it("returns empty array for empty config", () => { - expect(extractProviderConfigs({})).toEqual([]); - }); - - it("extracts appleAppStore provider", () => { - const config = { - appleAppStore: { productId: "com.app.monthly" }, - }; - - const result = extractProviderConfigs(config); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - providerId: "appleAppStore", - configuration: { productId: "com.app.monthly" }, - }); - }); - - it("extracts googlePlay provider", () => { - const config = { - googlePlay: { productId: "monthly_subscription" }, - }; - - const result = extractProviderConfigs(config); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - providerId: "googlePlay", - configuration: { productId: "monthly_subscription" }, - }); - }); - - it("ignores unknown provider IDs", () => { - const config = { - unknownProvider: { productId: "test" }, - appleAppStore: { productId: "com.app.1" }, - }; - - const result = extractProviderConfigs(config); - - expect(result).toHaveLength(1); - expect(result[0]?.providerId).toBe("appleAppStore"); - }); - - it("skips metadata key '_'", () => { - const config = { - _: { paymentProviders: {} }, - appleAppStore: { productId: "com.app.1" }, - }; - - const result = extractProviderConfigs(config); - - expect(result).toHaveLength(1); - expect(result[0]?.providerId).toBe("appleAppStore"); - }); - - it("ignores null/undefined config values", () => { - const config = { - appleAppStore: null, - googlePlay: undefined, - }; - - const result = extractProviderConfigs(config as Record); - - expect(result).toEqual([]); - }); - - it("extracts configuration object correctly", () => { - const config = { - appleAppStore: { productId: "com.app.1", groupId: "group123" }, - }; - - const result = extractProviderConfigs(config); - - expect(result[0]?.configuration).toEqual({ - productId: "com.app.1", - groupId: "group123", - }); - }); -}); - -describe("type guards", () => { - describe("isSchemaConfiguration", () => { - it("returns true for object with correct symbol", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.SchemaConfiguration, - perks: {}, - providers: {}, - location: () => {}, - subscription: () => {}, - }; - expect(isSchemaConfiguration(obj)).toBe(true); - }); - - it("returns false for null", () => { - expect(isSchemaConfiguration(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(isSchemaConfiguration("string")).toBe(false); - expect(isSchemaConfiguration(123)).toBe(false); - expect(isSchemaConfiguration(undefined)).toBe(false); - }); - - it("returns false for object without symbol", () => { - const obj = { - perks: {}, - providers: {}, - location: () => {}, - subscription: () => {}, - }; - expect(isSchemaConfiguration(obj)).toBe(false); - }); - - it("returns false for object with wrong symbol value", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.Perk, - perks: {}, - providers: {}, - location: () => {}, - subscription: () => {}, - }; - expect(isSchemaConfiguration(obj)).toBe(false); - }); - }); - - describe("isPaywallLocationDefinition", () => { - it("returns true for object with correct symbol", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.PaywallLocation, - description: "Shown after onboarding", - name: "Onboarding", - slug: "onboarding", - }; - expect(isPaywallLocationDefinition(obj)).toBe(true); - }); - - it("returns false for null", () => { - expect(isPaywallLocationDefinition(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(isPaywallLocationDefinition("string")).toBe(false); - }); - - it("returns false for object without symbol", () => { - const obj = { name: "Onboarding", slug: "onboarding" }; - expect(isPaywallLocationDefinition(obj)).toBe(false); - }); - }); - - describe("isPerkDefinition", () => { - it("returns true for object with correct symbol", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.Perk, - slug: "test-perk", - name: "Test Perk", - }; - expect(isPerkDefinition(obj)).toBe(true); - }); - - it("returns false for null", () => { - expect(isPerkDefinition(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(isPerkDefinition("string")).toBe(false); - }); - - it("returns false for object without symbol", () => { - const obj = { slug: "test", name: "Test" }; - expect(isPerkDefinition(obj)).toBe(false); - }); - }); - - describe("isProductDefinition", () => { - it("returns true for object with correct symbol", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.Product, - type: "subscription", - slug: "test-product", - properties: { name: "Test" }, - configuration: {}, - }; - expect(isProductDefinition(obj)).toBe(true); - }); - - it("returns false for null", () => { - expect(isProductDefinition(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(isProductDefinition(123)).toBe(false); - }); - - it("returns false for object without symbol", () => { - const obj = { - type: "subscription", - slug: "test", - properties: { name: "Test" }, - configuration: {}, - }; - expect(isProductDefinition(obj)).toBe(false); - }); - }); -}); - -describe("loadLocalSchema", () => { - const fixturesPath = path.resolve(__dirname, "../../fixtures"); - - it( - "fails with LocalSchemaNotFoundError when file doesn't exist", - async () => { - await Effect.runPromise( - Effect.gen(function* () { - const result = yield* Effect.exit( - loadLocalSchema("/non/existent/path.ts"), - ); - - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = result.cause; - expect(error._tag).toBe("Fail"); - } - }).pipe(Effect.provide(TestLayer)), - ); - }, - ); - - it("extracts perks from SchemaConfiguration", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "valid-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.perks.size).toBe(2); - expect(result.perks.has("all-access")).toBe(true); - expect(result.perks.has("premium-features")).toBe(true); - - const allAccessPerk = result.perks.get("all-access"); - expect(allAccessPerk?.name).toBe("All Access"); - }).pipe(Effect.provide(TestLayer)), - ); - }); - - it("extracts enabled providers from SchemaConfiguration", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "valid-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.enabledProviders.has("appleAppStore")).toBe(true); - expect(result.enabledProviders.has("googlePlay")).toBe(true); - }).pipe(Effect.provide(TestLayer)), - ); - }); - - it("extracts products with perks and providers", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "valid-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.products.size).toBe(2); - expect(result.products.has("monthly-plan")).toBe(true); - expect(result.products.has("yearly-plan")).toBe(true); - - const monthlyPlan = result.products.get("monthly-plan"); - expect(monthlyPlan?.name).toBe("Monthly Plan"); - expect(monthlyPlan?.perks).toContain("all-access"); - expect(monthlyPlan?.providers).toHaveLength(2); - - const yearlyPlan = result.products.get("yearly-plan"); - expect(yearlyPlan?.name).toBe("Yearly Plan"); - expect(yearlyPlan?.perks).toContain("all-access"); - expect(yearlyPlan?.perks).toContain("premium-features"); - }).pipe(Effect.provide(TestLayer)), - ); - }); - - it("extracts paywall locations", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "valid-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.locations.size).toBe(2); - expect(result.locations.has("onboarding-upsell")).toBe(true); - expect(result.locations.has("settings-paywall")).toBe(true); - - const onboardingUpsell = result.locations.get("onboarding-upsell"); - expect(onboardingUpsell?.name).toBe("Onboarding Upsell"); - expect(onboardingUpsell?.description).toBe("Shown after onboarding"); - - const settingsPaywall = result.locations.get("settings-paywall"); - expect(settingsPaywall?.name).toBe("Settings Paywall"); - expect(settingsPaywall?.description).toBeNull(); - }).pipe(Effect.provide(TestLayer)), - ); - }); - - it("handles empty schema", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "empty-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.perks.size).toBe(0); - expect(result.products.size).toBe(0); - expect(result.locations.size).toBe(0); - expect(result.enabledProviders.size).toBe(0); - }).pipe(Effect.provide(TestLayer)), - ); - }); -}); diff --git a/apps/cli/vitest.unit.mts b/apps/cli/vitest.unit.mts index 995b04da6..a5126899b 100644 --- a/apps/cli/vitest.unit.mts +++ b/apps/cli/vitest.unit.mts @@ -2,10 +2,11 @@ import tsconfigPaths from "vite-tsconfig-paths"; import { defineConfig } from "vitest/config"; export default defineConfig({ - plugins: [tsconfigPaths()], - test: { - exclude: ["./node_modules/**"], - include: ["./tests/**/*.test.ts"], - reporters: ["verbose"], - }, + plugins: [tsconfigPaths()], + test: { + exclude: ["./node_modules/**"], + include: ["./tests/**/*.test.ts"], + reporters: ["verbose"], + testTimeout: 15_000, + }, }); diff --git a/apps/mimic-admin/LICENSE.md b/apps/mimic-admin/LICENSE.md new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/apps/mimic-admin/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/apps/mimic-admin/README.md b/apps/mimic-admin/README.md new file mode 100644 index 000000000..047fa76a2 --- /dev/null +++ b/apps/mimic-admin/README.md @@ -0,0 +1,3 @@ +# @voidhash/mimic-admin + +Browser-based operator console for authenticating to a Mimic host and inspecting its databases, collections, schemas, and documents. diff --git a/apps/mimic-admin/package.json b/apps/mimic-admin/package.json new file mode 100644 index 000000000..23e072d21 --- /dev/null +++ b/apps/mimic-admin/package.json @@ -0,0 +1,65 @@ +{ + "name": "@voidhash/mimic-admin", + "version": "1.0.0-beta.19", + "private": true, + "description": "Operator console for inspecting and administering Mimic databases.", + "keywords": [ + "admin", + "database", + "mimic", + "voidhash" + ], + "homepage": "https://voidhash.com/docs", + "bugs": { + "url": "https://github.com/voidhashcom/voidhash/issues" + }, + "license": "AGPL-3.0-only", + "author": "Voidhash (https://voidhash.com)", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "apps/mimic-admin" + }, + "type": "module", + "scripts": { + "dev": "vp dev --config vite.config.ts", + "build": "vp build --config vite.config.ts", + "preview": "vp preview --config vite.config.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.8", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", + "@tanstack/react-query": "^5.80.7", + "@tanstack/react-query-devtools": "^5.80.7", + "@tanstack/react-router": "1.163.3", + "@voidhash/mimic-core": "workspace:*", + "@voidhash/mimic-server": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.513.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "sonner": "^2.0.3", + "tailwind-merge": "^3.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.8", + "@types/react": "19.1.17", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "^4.5.2", + "tailwindcss": "^4.1.8", + "tw-animate-css": "^1.3.4", + "typescript": "6.0.3", + "vite": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/apps/mimic-admin/src/components/app-sidebar.tsx b/apps/mimic-admin/src/components/app-sidebar.tsx new file mode 100644 index 000000000..ba2431180 --- /dev/null +++ b/apps/mimic-admin/src/components/app-sidebar.tsx @@ -0,0 +1,126 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { Activity, Database, FileText, LogOut, Users } from "lucide-react"; + +import { useAuth } from "@/components/auth-context"; +import { useDatabase } from "@/components/database-context"; +import { useMimicSdk } from "@/components/sdk-context"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { collectionsQuery, databasesQuery } from "@/lib/queries"; + +export function AppSidebar() { + const { credentials, logout } = useAuth(); + const sdk = useMimicSdk(); + const { selectedDatabaseId, setSelectedDatabaseId } = useDatabase(); + const matchRoute = useMatchRoute(); + + const { data: databases } = useQuery(databasesQuery(sdk)); + const { data: collections } = useQuery(collectionsQuery(sdk, selectedDatabaseId ?? "")); + + const navItems = [ + { to: "/databases" as const, label: "Databases", icon: Database }, + { to: "/users" as const, label: "Users", icon: Users }, + { to: "/observability" as const, label: "Observability", icon: Activity }, + ]; + + return ( +
+
+

Mimic Admin

+ +
+ + + +
+ +
+ + + + {selectedDatabaseId && collections && collections.length > 0 && ( + <> + +
+ Collections +
+ +
+ {collections.map((col) => { + const isActive = !!matchRoute({ + to: "/collections/$collectionId", + params: { collectionId: col.id }, + }); + return ( + + + {col.name} + + ); + })} +
+
+ + )} + +
+
+
{credentials.serverUrl}
+
{credentials.username}
+
+
+
+ ); +} diff --git a/apps/mimic-admin/src/components/auth-context.tsx b/apps/mimic-admin/src/components/auth-context.tsx new file mode 100644 index 000000000..c2d837b23 --- /dev/null +++ b/apps/mimic-admin/src/components/auth-context.tsx @@ -0,0 +1,34 @@ +import { createContext, useCallback, useContext } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { type Credentials, clearCredentials, getCredentials } from "@/lib/auth"; + +interface AuthContextValue { + credentials: Credentials; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ + children, + credentials, +}: { + children: React.ReactNode; + credentials: Credentials; +}) { + const navigate = useNavigate(); + const logout = useCallback(() => { + clearCredentials(); + navigate({ to: "/login" }); + }, [navigate]); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} diff --git a/apps/mimic-admin/src/components/database-context.tsx b/apps/mimic-admin/src/components/database-context.tsx new file mode 100644 index 000000000..7d5ac4672 --- /dev/null +++ b/apps/mimic-admin/src/components/database-context.tsx @@ -0,0 +1,47 @@ +import { createContext, useCallback, useContext, useState } from "react"; + +interface DatabaseContextValue { + selectedDatabaseId: string | null; + setSelectedDatabaseId: (id: string | null) => void; +} + +const DatabaseContext = createContext(null); + +const STORAGE_KEY = "mimic-admin-selected-database"; + +export function DatabaseProvider({ children }: { children: React.ReactNode }) { + const [selectedDatabaseId, setSelectedDatabaseIdState] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + }); + + const setSelectedDatabaseId = useCallback((id: string | null) => { + setSelectedDatabaseIdState(id); + try { + if (id) { + localStorage.setItem(STORAGE_KEY, id); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } catch { + // ignore storage errors + } + }, []); + + return ( + + {children} + + ); +} + +export function useDatabase(): DatabaseContextValue { + const ctx = useContext(DatabaseContext); + if (!ctx) { + throw new Error("useDatabase must be used within a DatabaseProvider"); + } + return ctx; +} diff --git a/apps/mimic-admin/src/components/sdk-context.tsx b/apps/mimic-admin/src/components/sdk-context.tsx new file mode 100644 index 000000000..26bba88de --- /dev/null +++ b/apps/mimic-admin/src/components/sdk-context.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useMemo } from "react"; +import { MimicSDK } from "@voidhash/mimic-server"; + +import type { Credentials } from "@/lib/auth"; + +const SdkContext = createContext(null); + +/** + * Builds a single `MimicSDK` instance per (serverUrl, username, password) + * tuple. The instance lives for the lifetime of the page — we deliberately + * do NOT call `sdk.dispose()` from a `useEffect` cleanup. React StrictMode + * runs cleanup-then-setup once on mount in dev to surface lifecycle bugs; + * disposing the runtime there closes the scope while `useMemo` still holds + * the same SDK reference, which would abort any in-flight RPC requests + * (visible as HTTP 499 / "All fibers interrupted" on the server). + * + * The runtime backs an HTTP `RpcClient.Protocol` (fetch-based, no persistent + * connections), so there's nothing urgent to clean up — the browser tears + * down on navigation/unload anyway. + */ +export function MimicSdkProvider({ + credentials, + children, +}: { + credentials: Credentials; + children: React.ReactNode; +}) { + const sdk = useMemo( + () => + new MimicSDK({ + url: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + }), + [credentials.serverUrl, credentials.username, credentials.password], + ); + + return {children}; +} + +export function useMimicSdk(): MimicSDK { + const sdk = useContext(SdkContext); + if (!sdk) { + throw new Error("useMimicSdk must be used within a MimicSdkProvider"); + } + return sdk; +} diff --git a/apps/mimic-admin/src/components/ui/alert-dialog.tsx b/apps/mimic-admin/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..ec8159f22 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/alert-dialog.tsx @@ -0,0 +1,111 @@ +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const AlertDialog = AlertDialogPrimitive.Root; +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/apps/mimic-admin/src/components/ui/badge.tsx b/apps/mimic-admin/src/components/ui/badge.tsx new file mode 100644 index 000000000..4fd9bde2e --- /dev/null +++ b/apps/mimic-admin/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow", + secondary: + "border-transparent bg-secondary text-secondary-foreground", + destructive: + "border-transparent bg-destructive text-white shadow", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/apps/mimic-admin/src/components/ui/button.tsx b/apps/mimic-admin/src/components/ui/button.tsx new file mode 100644 index 000000000..8c9fd8eb7 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: ButtonProps) { + const Comp = asChild ? Slot : "button"; + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/mimic-admin/src/components/ui/card.tsx b/apps/mimic-admin/src/components/ui/card.tsx new file mode 100644 index 000000000..15c7b8945 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/card.tsx @@ -0,0 +1,66 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardTitle({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardDescription({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardContent({ + className, + ...props +}: React.HTMLAttributes) { + return
; +} + +function CardFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/mimic-admin/src/components/ui/dialog.tsx b/apps/mimic-admin/src/components/ui/dialog.tsx new file mode 100644 index 000000000..56c4e8295 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/dialog.tsx @@ -0,0 +1,102 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ); +} + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/mimic-admin/src/components/ui/dropdown-menu.tsx b/apps/mimic-admin/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..4e7d0e8ef --- /dev/null +++ b/apps/mimic-admin/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,197 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/mimic-admin/src/components/ui/input.tsx b/apps/mimic-admin/src/components/ui/input.tsx new file mode 100644 index 000000000..aae2067cd --- /dev/null +++ b/apps/mimic-admin/src/components/ui/input.tsx @@ -0,0 +1,18 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/mimic-admin/src/components/ui/label.tsx b/apps/mimic-admin/src/components/ui/label.tsx new file mode 100644 index 000000000..896ce10c7 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +function Label({ + className, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/mimic-admin/src/components/ui/scroll-area.tsx b/apps/mimic-admin/src/components/ui/scroll-area.tsx new file mode 100644 index 000000000..5ab5448c5 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { ScrollArea, ScrollBar }; diff --git a/apps/mimic-admin/src/components/ui/select.tsx b/apps/mimic-admin/src/components/ui/select.tsx new file mode 100644 index 000000000..14b72c172 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/mimic-admin/src/components/ui/separator.tsx b/apps/mimic-admin/src/components/ui/separator.tsx new file mode 100644 index 000000000..2bb74e0ae --- /dev/null +++ b/apps/mimic-admin/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/mimic-admin/src/components/ui/sonner.tsx b/apps/mimic-admin/src/components/ui/sonner.tsx new file mode 100644 index 000000000..0cd8b368b --- /dev/null +++ b/apps/mimic-admin/src/components/ui/sonner.tsx @@ -0,0 +1,20 @@ +import { Toaster as Sonner } from "sonner"; + +function Toaster() { + return ( + + ); +} + +export { Toaster }; diff --git a/apps/mimic-admin/src/components/ui/table.tsx b/apps/mimic-admin/src/components/ui/table.tsx new file mode 100644 index 000000000..52f0da435 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/table.tsx @@ -0,0 +1,119 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Table({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ + + ); +} + +function TableHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ; +} + +function TableBody({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ); +} + +function TableFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( + tr]:last:border-b-0", + className, + )} + {...props} + /> + ); +} + +function TableRow({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ); +} + +function TableHead({ + className, + ...props +}: React.ThHTMLAttributes) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> + ); +} + +function TableCell({ + className, + ...props +}: React.TdHTMLAttributes) { + return ( + [role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> + ); +} + +function TableCaption({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/apps/mimic-admin/src/components/ui/tabs.tsx b/apps/mimic-admin/src/components/ui/tabs.tsx new file mode 100644 index 000000000..7ecdbbdc7 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/mimic-admin/src/components/ui/textarea.tsx b/apps/mimic-admin/src/components/ui/textarea.tsx new file mode 100644 index 000000000..94459a387 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/textarea.tsx @@ -0,0 +1,17 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +