Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Build context for apps/nehemiah/Dockerfile (repository root). Allow only
# what the npm workspace install and the control-plane build need.
*
!package.json
!package-lock.json
!.npmrc
!apps/nehemiah
!apps/web/package.json
!packages
**/node_modules
**/dist
**/.turbo
**/test
**/*.test.ts
129 changes: 129 additions & 0 deletions .github/workflows/deploy-cloudflare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Deploy Cloudflare

# Everything that runs on Cloudflare, via Alchemy (apps/web/alchemy.run.ts):
# main -> stage `prod` (site, control plane and gateway containers, edge
# Worker, Access, R2); pull requests from this repository -> stage `pr-<n>`
# (site only, workers.dev), destroyed when the pull request closes.
# See docs/cloudflare.md.

on:
push:
branches: [main]
paths:
- "apps/web/**"
- "apps/nehemiah/**"
- "gateway/**"
- "packages/typescript-config/**"
- "package.json"
- "package-lock.json"
- ".dockerignore"
- ".github/workflows/deploy-cloudflare.yml"
pull_request:
types: [opened, reopened, synchronize, closed]
paths:
- "apps/web/**"
- "packages/typescript-config/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/deploy-cloudflare.yml"

permissions:
contents: read

concurrency:
group: deploy-cloudflare-${{ github.ref }}
cancel-in-progress: false

env:
STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'prod' }}

jobs:
deploy:
name: alchemy deploy
# Forks cannot read the deploy secrets, so previews are limited to branches
# in this repository.
if: >-
github.event.action != 'closed' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
cache: npm
- run: npm ci
# The control-plane image never migrates; apply migrations with the
# migration role before the new containers roll out.
- name: Apply control-plane migrations
if: env.STAGE == 'prod'
run: npm run build -w @nehemiah/nehemiah && npm run migrate -w @nehemiah/nehemiah
env:
NODE_ENV: production
DATABASE_URL: ${{ secrets.DATABASE_URL }}
MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }}
- name: Deploy stage ${{ env.STAGE }}
run: npm run deploy -w web -- --stage "$STAGE" --yes
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# Site settings, forwarded to the site Worker only when non-empty.
PUBLIC_NEHEMIAH_URL: ${{ vars.PUBLIC_NEHEMIAH_URL }}
PRIVATE_NEHEMIAH_URL: ${{ vars.PRIVATE_NEHEMIAH_URL }}
PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.PUBLIC_CLERK_PUBLISHABLE_KEY }}
PUBLIC_CLERK_FRONTEND_API: ${{ vars.PUBLIC_CLERK_FRONTEND_API }}
STATUS_CONTROL_PLANE_URL: ${{ vars.STATUS_CONTROL_PLANE_URL }}
STATUS_GATEWAY_URL: ${{ vars.STATUS_GATEWAY_URL }}
PUBLIC_SUPPORT_URL: ${{ vars.PUBLIC_SUPPORT_URL }}
PUBLIC_SUPPORT_EMAIL: ${{ vars.PUBLIC_SUPPORT_EMAIL }}
# Edge and container settings (prod stage only; defaults in apps/web/.env.example).
SITE_DOMAIN: ${{ vars.SITE_DOMAIN }}
API_HOSTNAME: ${{ vars.API_HOSTNAME }}
GATEWAY_HOSTNAME: ${{ vars.GATEWAY_HOSTNAME }}
PREVIEW_BASE_DOMAIN: ${{ vars.PREVIEW_BASE_DOMAIN }}
PREVIEW_ZONE_NAME: ${{ vars.PREVIEW_ZONE_NAME }}
HOST_TUNNEL_DOMAIN: ${{ vars.HOST_TUNNEL_DOMAIN }}
FLEET_ACCESS_TOKEN_ID: ${{ vars.FLEET_ACCESS_TOKEN_ID }}
R2_BUCKET: ${{ vars.R2_BUCKET }}
NEHEMIAH_SERVICE_VERSION: ${{ vars.NEHEMIAH_SERVICE_VERSION }}
NEHEMIAH_OTEL_ENDPOINT: ${{ vars.NEHEMIAH_OTEL_ENDPOINT }}
NEHEMIAH_DEFAULT_REGION: ${{ vars.NEHEMIAH_DEFAULT_REGION }}
NEHEMIAH_HOST_CIDRS: ${{ vars.NEHEMIAH_HOST_CIDRS }}
CLERK_ISSUER: ${{ vars.CLERK_ISSUER }}
CLERK_AUDIENCE: ${{ vars.CLERK_AUDIENCE }}
# Container secrets (prod stage only).
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEHEMIAH_HOST_CREDENTIAL_KEY: ${{ secrets.NEHEMIAH_HOST_CREDENTIAL_KEY }}
NEHEMIAH_GATEWAY_TOKEN: ${{ secrets.NEHEMIAH_GATEWAY_TOKEN }}
NEHEMIAH_GATEWAY_SECRET: ${{ secrets.NEHEMIAH_GATEWAY_SECRET }}
NEHEMIAH_DEVICE_CODE_PEPPER: ${{ secrets.NEHEMIAH_DEVICE_CODE_PEPPER }}
NEHEMIAH_OTEL_AUTHORIZATION: ${{ secrets.NEHEMIAH_OTEL_AUTHORIZATION }}
GATEWAY_OTEL_AUTHORIZATION: ${{ secrets.GATEWAY_OTEL_AUTHORIZATION }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
Comment on lines +66 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Protect Production Secrets

Same-repository pull requests can execute branch-controlled dependency and deployment code while production Cloudflare and application secrets are present in the job environment. The workflow permits same-repository PRs, checks out their merge commit, runs dependency lifecycle code, and invokes the checked-out deployment script with Cloudflare credentials and additional application secrets. A contributor or compromised branch could disclose those credentials or use them to alter production infrastructure. Use isolated preview credentials for pull requests, or require an approval-protected deployment that runs only reviewed, protected code.

How this was verified: The checked-out pull-request code path and its injected production secret environment were confirmed by an executed configuration check.

Artifacts

Evidence from the check

  • The executed shell script reads the selected Git revision and asserts the PR trigger, same-repository gate, unchecked checkout ref, executable deploy path, and injected Cloudflare credentials; it shows the vulnerable source configuration.

Command output from the check

  • Captured output from executing the supplied check against `HEAD^` (37ce7a8), exiting 0 and showing the same PR-controlled deployment path with injected secrets.

Command output from the check

  • Captured output from executing the supplied check against `HEAD` (5444898), exiting 0 and showing the same PR-controlled deployment path with injected secrets.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/deploy-cloudflare.yml
Line: 66-103

Comment:
**Protect Production Secrets**

Same-repository pull requests can execute branch-controlled dependency and deployment code while production Cloudflare and application secrets are present in the job environment. The workflow permits same-repository PRs, checks out their merge commit, runs dependency lifecycle code, and invokes the checked-out deployment script with Cloudflare credentials and additional application secrets. A contributor or compromised branch could disclose those credentials or use them to alter production infrastructure. Use isolated preview credentials for pull requests, or require an approval-protected deployment that runs only reviewed, protected code.

**How this was verified:** The checked-out pull-request code path and its injected production secret environment were confirmed by an executed configuration check.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex


cleanup:
name: alchemy destroy (closed preview)
if: >-
github.event_name == 'pull_request' &&
github.event.action == 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
cache: npm
- run: npm ci
- name: Refuse to destroy prod
run: |
if [ "$STAGE" = "prod" ]; then
echo "refusing to destroy the prod stage from a cleanup job" >&2
exit 1
fi
- name: Destroy stage ${{ env.STAGE }}
run: npm run destroy -w web -- --stage "$STAGE" --yes
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ node_modules
# Build output (per-workspace outputs are also ignored in each app)
.output
.vercel
.alchemy
.netlify
.wrangler
.svelte-kit
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ build
.svelte-kit
.turbo
.vercel
.alchemy
coverage
package-lock.json
*.min.js
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ npm run dev -w web

Full REST + WebSocket API in the [docs](https://boringcomputers.com/docs).

The site itself deploys to Cloudflare Workers with [Alchemy](https://alchemy.run)
(`apps/web/alchemy.run.ts`), together with the edge in front of the managed
cloud: the control plane and gateway as Cloudflare Containers behind an edge
Worker, Access, DNS, and R2. See
[`docs/cloudflare.md`](docs/cloudflare.md).

**From any AI** — an MCP server
([`nehemiah-mcp`](packages/mcp)) lets Claude Desktop, Cursor, and other
agents spin up and drive your computers as a tool:
Expand Down Expand Up @@ -111,11 +117,12 @@ hosts are provisioned from a signed release via the managed runbook
A [Turborepo](https://turbo.build/repo) monorepo (npm workspaces):

```
apps/web/ the site — SvelteKit
apps/web/ the site — SvelteKit; alchemy.run.ts deploys it + the Cloudflare edge
nehemiahd/ the host daemon — Go, runs the microVMs
packages/sdk/ nehemiah-sdk — Effect-native TypeScript client
packages/mcp/ nehemiah-mcp — MCP server
infra/latitude/ managed-host provisioning (provision/teardown), image builds, networking
infra/cloudflare/ cloudflared unit for each host's tunnel (ADR 0005)
```

```sh
Expand Down
9 changes: 9 additions & 0 deletions apps/nehemiah/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/nehemiah
NEHEMIAH_HOST_CREDENTIAL_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
NEHEMIAH_HOST_CIDRS=127.0.0.0/8,10.0.0.0/8,fd00::/8
NEHEMIAH_HOST_PORT=8080
# How host internal APIs are reached (docs/nehemiah/adr/0005). `overlay`
# (default) dials the WireGuard address directly. `cloudflare-tunnel` keeps
# that address as the host identity but dials the host's own Cloudflare Tunnel
# at https://<address label>.<NEHEMIAH_HOST_TUNNEL_DOMAIN> with the Access
# service token; 10.64.0.7 becomes 10-64-0-7.hosts.example.com.
# NEHEMIAH_HOST_TRANSPORT=cloudflare-tunnel
# NEHEMIAH_HOST_TUNNEL_DOMAIN=hosts.example.com
# NEHEMIAH_HOST_ACCESS_CLIENT_ID=replace-with-access-service-token-client-id
# NEHEMIAH_HOST_ACCESS_CLIENT_SECRET=replace-with-access-service-token-client-secret
NEHEMIAH_GATEWAY_TOKEN=replace-me
NEHEMIAH_GATEWAY_SECRET=replace-me-too
# Generate 32 random bytes, base64 encode them, and keep this pepper distinct.
Expand Down
38 changes: 38 additions & 0 deletions apps/nehemiah/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
#
# Control plane (apps/nehemiah) image for Cloudflare Containers. The build
# context is the repository root so the npm workspace lockfile resolves; see
# docs/cloudflare.md. The runtime is Debian-based because the lockfile pins the
# glibc build of @node-rs/argon2.
#
# Migrations are not run here: the deploy workflow applies them with the
# migration role before the new image rolls out.

FROM node:24.19.0-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df AS build
WORKDIR /src
COPY package.json package-lock.json .npmrc ./
COPY apps/nehemiah/package.json apps/nehemiah/
COPY apps/web/package.json apps/web/
COPY packages ./packages
RUN npm ci --workspace @nehemiah/nehemiah --no-audit --no-fund
COPY apps/nehemiah ./apps/nehemiah
RUN npm run build --workspace @nehemiah/nehemiah

FROM node:24.19.0-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df AS runtime-deps
WORKDIR /src
COPY package.json package-lock.json .npmrc ./
COPY apps/nehemiah/package.json apps/nehemiah/
COPY apps/web/package.json apps/web/
COPY packages ./packages
RUN npm ci --workspace @nehemiah/nehemiah --omit=dev --no-audit --no-fund \
&& npm cache clean --force

FROM node:24.19.0-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df
ENV NODE_ENV=production
WORKDIR /srv/nehemiah
COPY --from=runtime-deps /src ./
COPY --from=build /src/apps/nehemiah/dist ./apps/nehemiah/dist
COPY apps/nehemiah/src/db/migrations ./apps/nehemiah/src/db/migrations
USER node
EXPOSE 8081
CMD ["node", "apps/nehemiah/dist/main.js"]
112 changes: 112 additions & 0 deletions apps/nehemiah/src/clients/host-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { isIP } from 'node:net';
import { Redacted } from 'effect';

/**
* How the control plane reaches a host's internal API.
*
* - `overlay` dials the host's stored WireGuard address directly (ADR 0002).
* - `cloudflare-tunnel` keeps that address as the host's identity but reaches
* it through the host's own Cloudflare Tunnel, published under a hostname
* derived from the address and guarded by a Cloudflare Access service token
* (ADR 0005). Nothing about how hosts are stored or validated changes.
*/
export type HostTransport =
| { readonly kind: 'overlay'; readonly port: number }
| {
readonly kind: 'cloudflare-tunnel';
/** DNS suffix hosts publish their tunnels under, e.g. `hosts.example.com`. */
readonly domain: string;
readonly accessClientId: string;
readonly accessClientSecret: Redacted.Redacted<string>;
};

export const overlayTransport = (port = 8080): HostTransport => ({ kind: 'overlay', port });

export class HostTransportError extends Error {}

const dnsName = /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/;
/** Access service-token credentials are opaque; require printable, unpadded ASCII. */
const accessCredential = /^[\x21-\x7e]{8,512}$/;

export const validateHostTransport = (transport: HostTransport): HostTransport => {
if (transport.kind === 'overlay') {
if (!Number.isSafeInteger(transport.port) || transport.port < 1 || transport.port > 65_535) {
throw new HostTransportError('host port must be between 1 and 65535');
}
return transport;
}
if (
!dnsName.test(transport.domain) ||
!transport.domain.includes('.') ||
transport.domain.includes('..')
) {
throw new HostTransportError(
'host tunnel domain must be a DNS suffix such as hosts.example.com'
);
}
if (
!accessCredential.test(transport.accessClientId) ||
!accessCredential.test(Redacted.value(transport.accessClientSecret))
) {
throw new HostTransportError('host tunnel transport requires an Access service token');
}
return transport;
};

/** Strip the IPv4-mapped prefix so both address families map like the gateway does. */
const unmap = (address: string): string => {
const mapped = address.toLowerCase().match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
return mapped ? mapped[1] : address.toLowerCase();
};

/** Expand an IPv6 address to eight zero-padded four-digit groups. */
const expandIPv6 = (address: string): string[] => {
let value = address;
const embedded = value.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (embedded) {
const [, head, a, b, c, d] = embedded;
const octets = [a, b, c, d].map(Number);
value = `${head}${(((octets[0] ?? 0) << 8) | (octets[1] ?? 0)).toString(16)}:${(((octets[2] ?? 0) << 8) | (octets[3] ?? 0)).toString(16)}`;
}
const [left = '', right] = value.split('::');
const head = left ? left.split(':') : [];
const tail = right ? right.split(':') : [];
const groups =
right === undefined
? head
: [...head, ...Array.from({ length: 8 - head.length - tail.length }, () => '0'), ...tail];
return groups.map((group) => group.padStart(4, '0'));
};

/**
* The DNS label a host's tunnel is published under: dotted IPv4 with dashes
* (`10.64.0.7` → `10-64-0-7`), or the fully expanded IPv6 groups joined with
* dashes. Identical to the gateway's `hostTunnelLabel` and to what operators
* configure in cloudflared on the host, so the three never disagree.
*/
export const hostTunnelLabel = (address: string): string => {
const value = unmap(address);
const version = isIP(value);
if (version === 4) return value.replaceAll('.', '-');
if (version === 6) return expandIPv6(value).join('-');
throw new HostTransportError('invalid host address');
};

/** Origin (no trailing slash) for a host's internal API under the transport. */
export const hostBaseUrl = (address: string, transport: HostTransport): string => {
const version = isIP(address);
if (version === 0) throw new HostTransportError('invalid host address');
if (transport.kind === 'overlay') {
return `http://${version === 6 ? `[${address}]` : address}:${transport.port}`;
}
return `https://${hostTunnelLabel(address)}.${transport.domain}`;
};

/** Extra request headers the transport needs; empty for the overlay. */
export const hostTransportHeaders = (transport: HostTransport): ReadonlyArray<[string, string]> =>
transport.kind === 'cloudflare-tunnel'
? [
['cf-access-client-id', transport.accessClientId],
['cf-access-client-secret', Redacted.value(transport.accessClientSecret)]
]
: [];
Loading
Loading