diff --git a/.changeset/staging-host-secrets.md b/.changeset/staging-host-secrets.md new file mode 100644 index 00000000..57ae3e11 --- /dev/null +++ b/.changeset/staging-host-secrets.md @@ -0,0 +1,17 @@ +--- +"everything-dev": minor +--- + +Add `bos deploy` command, host secrets, and staging environment support + +- **New `bos deploy` command**: Publishes config to FastKV and triggers Railway redeploy in one step. Reads service name from `ci.railway.service` in `bos.config.json`. Uses `RAILWAY_TOKEN` (environment-scoped) instead of deployment IDs. + +- **New `ci` config section**: `bos.config.json` now accepts `ci.railway.service` for Railway integration. Child projects inherit this via extends. + +- **Staging environment support**: `BOS_ENV=staging` or `--env staging` enables staging mode. `staging.domain` overrides `domain`, FastKV publishes under the staging gateway key, and runtime sets `env = "staging"`. + +- **Host secrets**: Added `secrets` array to `app.host` for tenant-related environment variables (`TENANT_WHITELIST`, `ALLOW_OVERRIDE`, `ALLOW_UNTRUSTED_SSR`, `CSP_STRICT`). Validated during `bos start` and surfaced in `bos infra`. + +- **Workflow simplification**: Replaced `publish.yml` with `deploy.yml`. `release.yml` now only handles npm package releases. `staging.yml` uses `bos deploy --env staging`. All workflows use `railway redeploy` via CLI instead of raw GraphQL API calls. + +- **Removed**: `RAILWAY_PRODUCTION_SERVICE_ID` and `RAILWAY_STAGING_SERVICE_ID` variables — replaced by environment-scoped `RAILWAY_TOKEN` secrets. \ No newline at end of file diff --git a/.changeset/tenant-ssr-integrity-gate.md b/.changeset/tenant-ssr-integrity-gate.md new file mode 100644 index 00000000..fea5de7c --- /dev/null +++ b/.changeset/tenant-ssr-integrity-gate.md @@ -0,0 +1,10 @@ +--- +"everything-dev": patch +"host": patch +--- + +Require ssrIntegrity for tenant SSR — prevent no-cache-per-request MF instance creation + +Tenant SSR now requires both `ssrUrl` and `ssrIntegrity` to be present. Previously, a whitelisted tenant with `ssrUrl` but no `ssrIntegrity` would bypass the router module cache (`shouldCacheRouterModule` returns false without `ssrIntegrity`), causing a new Module Federation instance to be created on every SSR request — the same pattern that caused the production SSR failure. + +Also fixes pre-existing typecheck errors in host test files (Effect Either narrowing, FederationError type annotation). diff --git a/.env.example b/.env.example index 5396bdfe..98afd68b 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ # app.host CORS_ORIGIN=http://localhost:3000 +TENANT_WHITELIST= +ALLOW_OVERRIDE= +ALLOW_UNTRUSTED_SSR= +CSP_STRICT= # app.api API_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db diff --git a/.github/templates/workflows/deploy.yml b/.github/templates/workflows/deploy.yml new file mode 100644 index 00000000..15db6f8d --- /dev/null +++ b/.github/templates/workflows/deploy.yml @@ -0,0 +1,62 @@ +name: Deploy + +on: + workflow_run: + workflows: [CI] + types: [completed] + workflow_dispatch: + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + +jobs: + deploy: + name: Deploy Production + if: > + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + ) + runs-on: ubuntu-latest + env: + BOS_INSTALL_NEAR_CLI: "true" + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + ZE_SERVER_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} + ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.2.20" + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build every-plugin + run: bun run --cwd packages/every-plugin build + + - name: Run postinstall + run: bun run postinstall + + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy + run: bos deploy + + - name: Commit and push bos.config.json updates + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bos.config.json + git diff --cached --quiet || git commit -m "chore: update deployment URLs [skip ci]" + git push \ No newline at end of file diff --git a/.github/templates/workflows/publish.yml b/.github/templates/workflows/publish.yml deleted file mode 100644 index 3a5eef02..00000000 --- a/.github/templates/workflows/publish.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Publish Config - -on: - push: - branches: - - main - paths: - - "bos.config.json" - workflow_dispatch: - inputs: - deploy: - description: "Build/deploy all workspaces before publish" - required: false - default: false - type: boolean - -permissions: - contents: write - -jobs: - publish: - if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]') - runs-on: ubuntu-latest - env: - BOS_INSTALL_NEAR_CLI: "true" - NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} - ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} - ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: "1.2.20" - - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts - - - name: Run postinstall - run: bun run postinstall - - - name: Publish app - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.deploy }}" = "true" ]; then - bun packages/everything-dev/src/cli.ts publish --deploy - else - bun packages/everything-dev/src/cli.ts publish - fi - - - name: Commit bos.config.json updates - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 - with: - commit_message: "chore: update deployment URLs [skip ci]" - file_pattern: "**/bos.config.json" diff --git a/.github/templates/workflows/staging.yml b/.github/templates/workflows/staging.yml new file mode 100644 index 00000000..48c03c20 --- /dev/null +++ b/.github/templates/workflows/staging.yml @@ -0,0 +1,56 @@ +name: Staging + +on: + push: + branches: + - staging + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + deploy-staging: + name: Deploy Staging + runs-on: ubuntu-latest + env: + BOS_INSTALL_NEAR_CLI: "true" + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + ZE_SERVER_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} + ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_STAGING_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.2.20" + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build every-plugin + run: bun run --cwd packages/every-plugin build + + - name: Run postinstall + run: bun run postinstall + + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy + run: bos deploy --env staging + + - name: Commit and push bos.config.json updates + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bos.config.json + git diff --cached --quiet || git commit -m "chore: update staging deployment URLs [skip ci]" + git push \ No newline at end of file diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 26195f9e..0d034841 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -8,7 +8,6 @@ This repository uses the following production-facing workflows: - `Docker` — Docker build and push after successful `CI` on `main` - `Release` — changeset versioning and npm publish for framework packages - `Publish` — app deploy (Zephyr CDN + FastKV config publish) -- `Preview` — PR preview comments via Railway The key design: `CI` is the validation workflow. `Release`, `Publish`, and `Docker` run as standalone workflows after successful `CI` runs on `main` via `workflow_run`. Docker remains decoupled so long image builds do not delay release or publish. @@ -74,16 +73,6 @@ The key design: `CI` is the validation workflow. `Release`, `Publish`, and `Dock **Secrets:** `NEAR_PRIVATE_KEY`, `ZEPHYR_AUTH_TOKEN`, and `ZEPHYR_USER_EMAIL` come directly from repository secrets. NEAR for FastKV publish, Zephyr for CDN deploy. -### Preview (`preview.yml`) - -**Trigger:** `pull_request` close events for cleanup, plus `workflow_run` after successful PR CI. - -**Purpose:** Publish the Railway preview URL as a PR comment. - -**Security:** Uses `workflow_run` only after successful internal PR CI, so repository secrets are not exposed to forked PRs. - -**Configuration:** Set `RAILWAY_TOKEN` and `RAILWAY_PROJECT_ID` as GitHub Actions secrets. Optionally set `RAILWAY_SERVICE_NAME` as a repository variable. - ## Docker Image Architecture Docker images are built in `docker.yml`. The image uses a multi-stage build: @@ -129,7 +118,7 @@ npm packages are published using **Trusted Publishing** (OpenID Connect), which | Variable | Where | Purpose | |----------|-------|---------| | `NEAR_PRIVATE_KEY` | Publish | NEAR key for FastKV config publish | -| `ZEPHYR_AUTH_TOKEN` | Publish (as `ZE_SECRET_TOKEN`) | Zephyr Cloud auth for CDN deploy | +| `ZEPHYR_AUTH_TOKEN` | Publish (as `ZE_SERVER_TOKEN`) | Zephyr Cloud auth for CDN deploy | | `ZEPHYR_USER_EMAIL` | Publish (as `ZE_USER_EMAIL`) | Zephyr Cloud user email | | `BOS_INSTALL_NEAR_CLI` | Release, Publish | Ensures NEAR CLI is available | | `GITHUB_TOKEN` | Release, Publish | Changesets PR creation, GitHub releases | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..41a9df88 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,62 @@ +name: Deploy + +on: + workflow_run: + workflows: [CI] + types: [completed] + workflow_dispatch: + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + +jobs: + deploy: + name: Deploy Production + if: > + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + ) + runs-on: ubuntu-latest + env: + BOS_INSTALL_NEAR_CLI: "true" + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + ZE_SERVER_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} + ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.2.20" + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build every-plugin + run: bun run --cwd packages/every-plugin build + + - name: Run postinstall + run: bun run postinstall + + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy + run: bun packages/everything-dev/src/cli.ts deploy + + - name: Commit and push bos.config.json updates + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bos.config.json + git diff --cached --quiet || git commit -m "chore: update deployment URLs [skip ci]" + git push \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 50965846..f7b945d3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,7 +24,7 @@ jobs: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' + (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'staging') ) steps: - name: Checkout code @@ -65,6 +65,7 @@ jobs: images: ghcr.io/${{ env.REPO }} tags: | type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=staging,enable=${{ github.ref == 'refs/heads/staging' }} type=ref,event=branch type=sha diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml deleted file mode 100644 index afa1e875..00000000 --- a/.github/workflows/preview.yml +++ /dev/null @@ -1,278 +0,0 @@ -name: Preview URL - -on: - pull_request: - types: [closed] - workflow_run: - workflows: [CI] - types: [completed] - -concurrency: - group: preview-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.run_id }} - cancel-in-progress: true - -jobs: - publish-preview-url: - name: Publish Railway preview URL - if: > - github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.head_repository.full_name == github.repository && - github.event.workflow_run.pull_requests[0] != null - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - pull-requests: read - steps: - - name: Publish preview URL comment - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - env: - RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }} - RAILWAY_SERVICE_NAME: ${{ vars.RAILWAY_SERVICE_NAME }} - RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} - with: - script: | - const marker = ''; - const pr = context.payload.workflow_run.pull_requests[0]; - const railwayToken = process.env.RAILWAY_TOKEN?.trim(); - const railwayProjectId = process.env.RAILWAY_PROJECT_ID?.trim(); - const preferredServiceName = process.env.RAILWAY_SERVICE_NAME?.trim()?.toLowerCase(); - - const listComments = async () => { - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - per_page: 100, - }); - - return comments.filter((comment) => - comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker), - ); - }; - - const upsertComment = async (body) => { - const comments = await listComments(); - if (comments[0]) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: comments[0].id, - body, - }); - return; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body, - }); - }; - - const railwayRequest = async (query, variables) => { - const response = await fetch('https://backboard.railway.com/graphql/v2', { - method: 'POST', - headers: { - Authorization: `Bearer ${railwayToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query, variables }), - }); - - if (!response.ok) { - throw new Error(`Railway API request failed with ${response.status}`); - } - - const payload = await response.json(); - if (payload.errors?.length) { - throw new Error(payload.errors.map((error) => error.message).join('; ')); - } - - return payload.data; - }; - - if (!railwayToken || !railwayProjectId) { - core.info('Skipping Railway preview lookup because RAILWAY_TOKEN or RAILWAY_PROJECT_ID is not configured.'); - return; - } - - const projectQuery = ` - query Project($id: String!) { - project(id: $id) { - services { - edges { - node { - id - name - } - } - } - } - } - `; - - const environmentsQuery = ` - query Environments($projectId: String!) { - environments(projectId: $projectId, isEphemeral: true) { - edges { - node { - id - name - createdAt - } - } - } - } - `; - - const domainsQuery = ` - query Domains($projectId: String!, $environmentId: String!, $serviceId: String!) { - domains(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) { - serviceDomains { - domain - targetPort - } - } - } - `; - - const projectData = await railwayRequest(projectQuery, { id: railwayProjectId }); - const services = projectData.project?.services?.edges?.map((edge) => edge.node) ?? []; - - if (services.length === 0) { - core.warning(`No Railway services found for project ${railwayProjectId}.`); - return; - } - - const prPattern = new RegExp(`(^|[-\\s])pr[-\\s]?${pr.number}($|[-\\s])`, 'i'); - const preferredNames = ['host', 'app', 'web', 'frontend']; - - const pickPreviewEntry = (entries) => { - if (entries.length === 0) { - return null; - } - - if (preferredServiceName) { - const exactMatch = entries.find((entry) => entry.serviceName.toLowerCase() === preferredServiceName); - if (exactMatch) { - return exactMatch; - } - } - - if (entries.length === 1) { - return entries[0]; - } - - for (const name of preferredNames) { - const preferred = entries.find((entry) => entry.serviceName.toLowerCase() === name); - if (preferred) { - return preferred; - } - } - - for (const name of preferredNames) { - const preferred = entries.find((entry) => entry.serviceName.toLowerCase().includes(name)); - if (preferred) { - return preferred; - } - } - - return [...entries].sort((left, right) => left.serviceName.localeCompare(right.serviceName))[0]; - }; - - let resolvedEnvironment = null; - let resolvedEntry = null; - - for (let attempt = 1; attempt <= 20; attempt += 1) { - const environmentsData = await railwayRequest(environmentsQuery, { projectId: railwayProjectId }); - const environments = environmentsData.environments?.edges?.map((edge) => edge.node) ?? []; - const matchingEnvironments = environments - .filter((environment) => prPattern.test(environment.name)) - .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); - - if (matchingEnvironments[0]) { - resolvedEnvironment = matchingEnvironments[0]; - const domainEntries = []; - - for (const service of services) { - const domainsData = await railwayRequest(domainsQuery, { - projectId: railwayProjectId, - environmentId: resolvedEnvironment.id, - serviceId: service.id, - }); - - const serviceDomains = domainsData.domains?.serviceDomains ?? []; - for (const serviceDomain of serviceDomains) { - domainEntries.push({ - domain: serviceDomain.domain, - serviceName: service.name, - targetPort: serviceDomain.targetPort, - }); - } - } - - resolvedEntry = pickPreviewEntry(domainEntries); - if (resolvedEntry) { - break; - } - } - - await new Promise((resolve) => setTimeout(resolve, 15000)); - } - - if (!resolvedEnvironment || !resolvedEntry) { - await upsertComment([ - marker, - '### Preview URL', - '', - 'Railway did not expose a preview URL for this PR yet.', - '', - 'Checks:', - '- PR Environments are enabled in Railway', - '- The base service uses a Railway-provided domain', - '- The base service is image-backed and tracks `ghcr.io/nearbuilders/everything-dev:latest`', - ].join('\n')); - return; - } - - await upsertComment([ - marker, - '### Preview URL', - '', - `**Service:** \`${resolvedEntry.serviceName}\``, - `**Environment:** \`${resolvedEnvironment.name}\``, - `**URL:** https://${resolvedEntry.domain}`, - ].join('\n')); - - cleanup-preview-comment: - name: Remove preview URL comment - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - name: Delete preview URL comment - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - with: - script: | - const marker = ''; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - - for (const comment of comments) { - if (comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker)) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: comment.id, - }); - } - } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 22eebf6b..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Publish Config - -on: - push: - branches: - - main - paths: - - "bos.config.json" - workflow_dispatch: - inputs: - deploy: - description: "Build/deploy all workspaces before publish" - required: false - default: false - type: boolean - -permissions: - contents: write - -jobs: - publish: - if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]') - runs-on: ubuntu-latest - env: - BOS_INSTALL_NEAR_CLI: "true" - NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} - ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} - ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: "1.2.20" - - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts - - - name: Build every-plugin - run: bun run --cwd packages/every-plugin build - - - name: Run postinstall - run: bun run postinstall - - - name: Publish app - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.deploy }}" = "true" ]; then - bun packages/everything-dev/src/cli.ts publish --deploy - else - bun packages/everything-dev/src/cli.ts publish - fi - - - name: Commit bos.config.json updates - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 - with: - commit_message: "chore: update deployment URLs [skip ci]" - file_pattern: "**/bos.config.json" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfef56b2..e63172be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,6 @@ jobs: github.event.workflow_run.head_branch == 'main' ) env: - BOS_INSTALL_NEAR_CLI: "true" TARGET_REF: ${{ inputs.ref != '' && inputs.ref || (github.event_name == 'workflow_run' && github.event.workflow_run.head_sha) || github.sha }} outputs: should_publish: ${{ steps.release_mode.outputs.should_publish }} @@ -171,18 +170,3 @@ jobs: --notes "$NOTES" \ --target main done - - - name: Publish runtime config - if: steps.release_mode.outputs.should_publish == 'true' - env: - NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} - ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} - ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} - run: bun packages/everything-dev/src/cli.ts publish --deploy - - - name: Commit bos.config.json updates - if: steps.release_mode.outputs.should_publish == 'true' - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 - with: - commit_message: "chore: update deployment URLs [skip ci]" - file_pattern: "**/bos.config.json" diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml new file mode 100644 index 00000000..6f552b4e --- /dev/null +++ b/.github/workflows/staging.yml @@ -0,0 +1,57 @@ +name: Staging + +on: + push: + branches: + - staging + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + packages: write + +jobs: + deploy-staging: + name: Deploy Staging + runs-on: ubuntu-latest + env: + BOS_INSTALL_NEAR_CLI: "true" + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + ZE_SERVER_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} + ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_STAGING_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.2.20" + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build every-plugin + run: bun run --cwd packages/every-plugin build + + - name: Run postinstall + run: bun run postinstall + + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy + run: bun packages/everything-dev/src/cli.ts deploy --env staging + + - name: Commit and push bos.config.json updates + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bos.config.json + git diff --cached --quiet || git commit -m "chore: update staging deployment URLs [skip ci]" + git push \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fe61153d..021c4263 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,8 @@ RUN mkdir -p .bos/generated .bos/logs && \ ENV NODE_ENV=production ENV PORT=3000 ENV HOST=0.0.0.0 +# BOS_ENV: set to "staging" to enable staging mode (uses staging domain for BOS_GATEWAY) +# Defaults to "production" if unset. EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ diff --git a/bos.config.json b/bos.config.json index a5229464..7f22bf04 100644 --- a/bos.config.json +++ b/bos.config.json @@ -3,27 +3,39 @@ "domain": "dev.everything.dev", "title": "everything.dev", "description": "Open runtime for apps on NEAR, composed from published config and loaded through a shared host, UI, and API runtime.", - "testnet": "dev.allthethings.testnet", "staging": { - "domain": "staging.dev.everything.dev" + "domain": "dev.everything.dev" }, "repository": "https://github.com/nearbuilders/everything-dev", + "ci": { + "railway": { + "service": "app" + } + }, "app": { "host": { "development": "local:host", - "production": "https://elliot-braem-3974-host-everything-dev-nearbuilder-8513ab298-ze.zephyrcloud.app", - "integrity": "sha384-cJSFftKsfmRX3ezuW4BP/VFK+VSzhTSXqu0eLDgHKUmOohuDPkWt7RMvDd2egijq" + "production": "https://elliot-braem-4045-host-everything-dev-nearbuilder-01e450db6-ze.zephyrcloud.app", + "integrity": "sha384-VePB/4eD30TF7NdAvE77Osb/PxXlD1w1hlq5/Lg0RQNvtJHzWitrWx3NeE5Gals7", + "secrets": [ + "CORS_ORIGIN", + "TENANT_WHITELIST", + "ALLOW_OVERRIDE", + "ALLOW_UNTRUSTED_SSR", + "CSP_STRICT" + ] }, "ui": { "development": "local:ui", - "production": "https://elliot-braem-3968-ui-everything-dev-nearbuilders-6cd4c405b-ze.zephyrcloud.app", - "ssr": "https://elliot-braem-3969-ui-everything-dev-nearbuilders-e369c17ce-ze.zephyrcloud.app", - "integrity": "sha384-ni2kNqkd7aFlf9eSxChty8Tds1g5Wz31FAkW1DiMDLcSkbQJ9peFXH9mwSp92qYD" + "production": "https://elliot-braem-4039-ui-everything-dev-nearbuilders-d777168c1-ze.zephyrcloud.app", + "ssr": "https://elliot-braem-4040-ui-everything-dev-nearbuilders-9cfb51ed1-ze.zephyrcloud.app", + "integrity": "sha384-NCoP0zJCaRS87lV2gJ1M9zmjpr519hYSwrtPdii0RCaDsqOSSBuB062oqsy/4De6", + "ssrIntegrity": "sha384-L89MZTh4DIom207yPznV7vt3q3+Iq+OsjTsfMxRZSHg5G9ZRGBPOjtFXN7LJcDuS" }, "api": { "development": "local:api", - "production": "https://elliot-braem-3970-api-everything-dev-nearbuilders-7d41ad764-ze.zephyrcloud.app", - "integrity": "sha384-f/HuutBh3+h3xiBvJvJu4HDAp2jhSLDR0OLjSjhLjg595qGOQLBu2oYO/u+vjdzq", + "production": "https://elliot-braem-4041-api-everything-dev-nearbuilders-bce8ccd89-ze.zephyrcloud.app", + "integrity": "sha384-1QVKgOG2z5Ouz3Ui3Bj4KeGgZfCBJgxWmwv89Yle85EJ3NcD2Q9iYSC/jrKsX/Y7", "secrets": [ "API_DATABASE_URL" ] @@ -35,8 +47,8 @@ "plugins": { "apps": { "development": "local:plugins/apps", - "production": "https://elliot-braem-3971-everything-dev-apps-plugin-ever-91dca9a92-ze.zephyrcloud.app", - "integrity": "sha384-zfHOSR0mbcu4l8PkZqli4BlDlnfhw5HhgtP2jGiys90/qKKAh74/l7LYI+7j6r9L", + "production": "https://elliot-braem-4042-everything-dev-apps-plugin-ever-abce5d268-ze.zephyrcloud.app", + "integrity": "sha384-HxmWBkKKPYgtN3s0/2qAlXMThQbp0Al/KcaS1OxabGtGevjMzOIZARm+YnHTIUPd", "variables": { "registryNamespace": "dev.everything.near" }, @@ -54,8 +66,8 @@ }, "projects": { "development": "local:plugins/projects", - "production": "https://elliot-braem-3972-everything-dev-projects-plugin--bcdb70e9a-ze.zephyrcloud.app", - "integrity": "sha384-oSAXUrJT2x5NfuEcbPS9GvmzC8E0INdCarPSQxFJ4SwxKfyCQEoWGkFTl7Ja86z/", + "production": "https://elliot-braem-4043-everything-dev-projects-plugin--d2753e967-ze.zephyrcloud.app", + "integrity": "sha384-HbSmv7VBbuGtpfpRQHvx40VSoPIJw81bMIwnd+6jX3U9PpyAMhlhS2eRHQKXxU0y", "secrets": [ "PROJECTS_DATABASE_URL" ], @@ -71,8 +83,8 @@ }, "settings": { "development": "local:plugins/settings", - "production": "https://elliot-braem-3973-every-plugin-settings-everythin-6b4774e41-ze.zephyrcloud.app", - "integrity": "sha384-f0n7itNtGKV74WX+KSu7anEpB0MEQcFxhZw2rJbHEWIWPzvLnNf/tEGX/7Hp2haY", + "production": "https://elliot-braem-4044-every-plugin-settings-everythin-40b3adce8-ze.zephyrcloud.app", + "integrity": "sha384-VQHIcTRu11j/TD6w0mAkbKKcxgqUuofVzlXD2xgsksXo4lRLCAPPrQAuLKCxej1q", "routes": [ "ui/src/routes/_layout/_authenticated/settings.tsx" ], diff --git a/host/.env.example b/host/.env.example index 79a57c7c..1283ab78 100644 --- a/host/.env.example +++ b/host/.env.example @@ -1,2 +1,9 @@ -API_DATABASE_URL= -API_DATABASE_AUTH_TOKEN= +# app.host +CORS_ORIGIN=http://localhost:3000 +TENANT_WHITELIST= +ALLOW_OVERRIDE= +ALLOW_UNTRUSTED_SSR= +CSP_STRICT= + +# app.api +API_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db \ No newline at end of file diff --git a/host/src/services/errors.ts b/host/src/services/errors.ts index 70161513..22e8dc7e 100644 --- a/host/src/services/errors.ts +++ b/host/src/services/errors.ts @@ -4,10 +4,22 @@ export class FederationError extends Data.TaggedError("FederationError")<{ readonly remoteName: string; readonly remoteUrl?: string; readonly cause?: unknown; -}> {} +}> { + get message() { + const raw = this.cause instanceof FederationError ? this.cause.cause : this.cause; + const detail = raw instanceof Error ? raw.message : String(raw ?? ""); + return `Failed to load ${this.remoteName}${this.remoteUrl ? ` from ${this.remoteUrl}` : ""}: ${detail}`; + } +} export class PluginError extends Data.TaggedError("PluginError")<{ readonly pluginName?: string; readonly pluginUrl?: string; readonly cause?: unknown; -}> {} +}> { + get message() { + const raw = this.cause instanceof PluginError ? this.cause.cause : this.cause; + const detail = raw instanceof Error ? raw.message : String(raw ?? ""); + return `Plugin ${this.pluginName ?? "unknown"}${this.pluginUrl ? ` at ${this.pluginUrl}` : ""} failed: ${detail}`; + } +} diff --git a/host/src/services/tenant-runtime.ts b/host/src/services/tenant-runtime.ts index 4fe63253..983bff8e 100644 --- a/host/src/services/tenant-runtime.ts +++ b/host/src/services/tenant-runtime.ts @@ -506,7 +506,10 @@ export async function resolveRequestRuntime( } } - const ssrAllowed = Boolean(effectiveConfig.ui.ssrUrl) && isSsrAllowed(tenantAccountId); + const ssrAllowed = + Boolean(effectiveConfig.ui.ssrUrl) && + Boolean(effectiveConfig.ui.ssrIntegrity) && + isSsrAllowed(tenantAccountId); return { config: ssrAllowed diff --git a/host/tests/helpers/bundled-host.ts b/host/tests/helpers/bundled-host.ts new file mode 100644 index 00000000..df27d2dc --- /dev/null +++ b/host/tests/helpers/bundled-host.ts @@ -0,0 +1,135 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { createInstance } from "@module-federation/enhanced/runtime"; +import { setGlobalFederationInstance } from "@module-federation/runtime-core"; +import { patchManifestFetchForSsrPublicPath } from "everything-dev/mf"; +import type { RuntimeConfig } from "../../src/services/config"; +import { getAvailablePort } from "./ports"; +import { startStaticDistServer } from "./static-dist-server"; +import { loadHostTestEnv } from "./test-env"; + +interface ServerHandle { + ready: Promise; + shutdown: () => Promise; +} + +interface HostRemoteModule { + runServer: (input: { config: RuntimeConfig }) => ServerHandle; +} + +export interface BundledHostRuntime { + baseUrl: string; + hostAssetsUrl: string; + uiAssetsUrl: string; + stop: () => Promise; +} + +export interface BundledHostUrls { + baseUrl: string; + hostAssetsUrl: string; + uiAssetsUrl: string; +} + +const workspaceRoot = path.resolve(import.meta.dirname, "../../.."); +const hostDir = path.join(workspaceRoot, "host"); +const uiDir = path.join(workspaceRoot, "ui"); +let buildReady = false; + +loadHostTestEnv(workspaceRoot); + +function ensureBuild(cwd: string) { + const result = spawnSync("bun", ["run", "build"], { + cwd, + stdio: "inherit", + env: { ...process.env }, + }); + + if (result.status !== 0) { + throw new Error(`Build failed for ${cwd} (exit ${result.status ?? "unknown"})`); + } +} + +function ensureBuilds() { + if (buildReady) return; + + if (!existsSync(path.join(hostDir, "dist", "remoteEntry.js"))) { + ensureBuild(hostDir); + } + if (!existsSync(path.join(uiDir, "dist", "remoteEntry.server.js"))) { + ensureBuild(uiDir); + } + buildReady = true; +} + +let instanceCounter = 0; + +async function loadBundledHostModule(hostAssetsUrl: string): Promise { + instanceCounter++; + const remoteName = `host_${instanceCounter}`; + const mf = createInstance({ name: `bundled-host-${instanceCounter}`, remotes: [] }); + setGlobalFederationInstance(mf as any); + patchManifestFetchForSsrPublicPath(mf as any); + + const entryUrl = `${hostAssetsUrl}/mf-manifest.json`; + (mf as any).registerRemotes([{ name: remoteName, entry: entryUrl, alias: "host" }]); + + const hostModule = (await (mf as any).loadRemote( + `${remoteName}/Server`, + )) as HostRemoteModule | null; + if (!hostModule?.runServer) { + throw new Error("Bundled host module did not export runServer"); + } + + return hostModule; +} + +export async function startBundledHost( + buildConfig: (urls: BundledHostUrls) => RuntimeConfig, +): Promise { + ensureBuilds(); + + const hostAssetsServer = await startStaticDistServer(path.join(hostDir, "dist")); + const uiAssetsServer = await startStaticDistServer(path.join(uiDir, "dist")); + const port = await getAvailablePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const runtimeConfig = buildConfig({ + baseUrl, + hostAssetsUrl: hostAssetsServer.baseUrl, + uiAssetsUrl: uiAssetsServer.baseUrl, + }); + + const previousNodeEnv = process.env.NODE_ENV; + const previousHost = process.env.HOST; + const previousPort = process.env.PORT; + process.env.NODE_ENV = "production"; + process.env.HOST = "127.0.0.1"; + process.env.PORT = String(port); + + let serverHandle: ServerHandle | null = null; + + try { + const hostModule = await loadBundledHostModule(hostAssetsServer.baseUrl); + serverHandle = hostModule.runServer({ config: runtimeConfig }); + await serverHandle.ready; + } catch (error) { + await Promise.allSettled([hostAssetsServer.stop(), uiAssetsServer.stop()]); + process.env.NODE_ENV = previousNodeEnv; + process.env.HOST = previousHost; + process.env.PORT = previousPort; + throw error; + } + + return { + baseUrl, + hostAssetsUrl: hostAssetsServer.baseUrl, + uiAssetsUrl: uiAssetsServer.baseUrl, + stop: async () => { + await serverHandle?.shutdown(); + await Promise.allSettled([hostAssetsServer.stop(), uiAssetsServer.stop()]); + process.env.NODE_ENV = previousNodeEnv; + process.env.HOST = previousHost; + process.env.PORT = previousPort; + }, + }; +} diff --git a/host/tests/helpers/bundled-ssr-module.ts b/host/tests/helpers/bundled-ssr-module.ts new file mode 100644 index 00000000..62171db3 --- /dev/null +++ b/host/tests/helpers/bundled-ssr-module.ts @@ -0,0 +1,96 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { createInstance } from "@module-federation/enhanced/runtime"; +import { setGlobalFederationInstance } from "@module-federation/runtime-core"; +import { patchManifestFetchForSsrPublicPath } from "everything-dev/mf"; +import type { RouterModule } from "../../src/types"; +import { type StaticDistServer, startStaticDistServer } from "./static-dist-server"; +import { loadHostTestEnv } from "./test-env"; + +const workspaceRoot = path.resolve(import.meta.dirname, "../../.."); +const uiDir = path.join(workspaceRoot, "ui"); +let buildReady = false; + +loadHostTestEnv(workspaceRoot); + +function ensureUiServerBuild() { + if (buildReady) return; + + const serverEntry = path.join(uiDir, "dist", "remoteEntry.server.js"); + if (existsSync(serverEntry)) { + buildReady = true; + return; + } + + const result = spawnSync("bun", ["run", "build"], { + cwd: uiDir, + stdio: "inherit", + env: { ...process.env, BUILD_TARGET: "server" }, + }); + + if (result.status !== 0) { + throw new Error(`UI server build failed (exit ${result.status ?? "unknown"})`); + } + + buildReady = true; +} + +let activeSsrLoader: { + uiServer: StaticDistServer; + mf: ReturnType; +} | null = null; + +export async function loadBundledRouterModule(): Promise<{ + routerModule: RouterModule; + assetsUrl: string; + cleanup: () => Promise; +}> { + ensureUiServerBuild(); + + if (activeSsrLoader) { + const mod = await (activeSsrLoader.mf as any).loadRemote("ui/Router", { from: "build" }); + return { + routerModule: mod.default as RouterModule, + assetsUrl: activeSsrLoader.uiServer.baseUrl, + cleanup: async () => {}, + }; + } + + const uiServer = await startStaticDistServer(path.join(uiDir, "dist")); + + const mf = createInstance({ + name: "ssr-test-host", + remotes: [ + { + name: "ui", + entry: `${uiServer.baseUrl}/mf-manifest.json`, + alias: "ui", + }, + ], + }); + setGlobalFederationInstance(mf as any); + patchManifestFetchForSsrPublicPath(mf as any); + + const mod = await (mf as any).loadRemote("ui/Router", { from: "build" }); + if (!mod?.default) { + await uiServer.stop(); + throw new Error("Bundled UI remote did not export Router module"); + } + + activeSsrLoader = { uiServer, mf }; + + return { + routerModule: mod.default as RouterModule, + assetsUrl: uiServer.baseUrl, + cleanup: async () => { + if (activeSsrLoader) { + await activeSsrLoader.uiServer.stop(); + if ((mf as any).getInstance) { + setGlobalFederationInstance(undefined as any); + } + activeSsrLoader = null; + } + }, + }; +} diff --git a/host/tests/helpers/runtime-config.ts b/host/tests/helpers/runtime-config.ts index 8fa122a9..a1c0cd4e 100644 --- a/host/tests/helpers/runtime-config.ts +++ b/host/tests/helpers/runtime-config.ts @@ -61,6 +61,14 @@ export function buildTestClientRuntimeConfig(config: RuntimeConfig): Partial Promise; +} + +const MIME_TYPES: Record = { + ".css": "text/css", + ".html": "text/html", + ".ico": "image/x-icon", + ".js": "application/javascript", + ".json": "application/json", + ".mjs": "application/javascript", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain", + ".webmanifest": "application/manifest+json", +}; + +function getContentType(filePath: string) { + return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +export async function startStaticDistServer(rootDir: string): Promise { + const port = await getAvailablePort(); + const normalizedRoot = path.resolve(rootDir); + + const server = createServer((req, res) => { + const rawPath = (req.url ?? "/").split("?")[0] ?? "/"; + const relativePath = rawPath === "/" ? "/index.html" : rawPath; + const filePath = path.resolve(normalizedRoot, `.${relativePath}`); + + if (!filePath.startsWith(normalizedRoot)) { + res.statusCode = 403; + res.end("forbidden"); + return; + } + + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + res.statusCode = 404; + res.end("not found"); + return; + } + + res.statusCode = 200; + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("content-type", getContentType(filePath)); + res.end(readFileSync(filePath)); + }); + + await new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); + + return { + baseUrl: `http://127.0.0.1:${port}`, + stop: async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} diff --git a/host/tests/integration/runtime-remote.test.ts b/host/tests/integration/runtime-remote.test.ts index afa7347c..c5b885bc 100644 --- a/host/tests/integration/runtime-remote.test.ts +++ b/host/tests/integration/runtime-remote.test.ts @@ -73,6 +73,9 @@ for (const scenario of scenarios) { if (scenario.ssr) { expect(html).toContain('
'); expect(html).toContain("manifest.json"); + expect(html).toContain('