diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74ae04d91..1cf6d18de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: outputs: dist_tag: ${{ steps.dist-tag.outputs.dist_tag }} env: - PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci server + PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 @@ -194,6 +194,22 @@ jobs: else npm publish "$RUNNER_TEMP/tarballs/ci.tgz" --provenance --access public --tag "$DIST_TAG" fi + - name: Publish @stim-cli/transport + run: | + version=$(node -p "require('$RUNNER_TEMP/tarballs/transport.package.json').version") + if npm view "@stim-cli/transport@$version" version >/dev/null 2>&1; then + echo "@stim-cli/transport@$version is already published" + else + npm publish "$RUNNER_TEMP/tarballs/transport.tgz" --provenance --access public --tag "$DIST_TAG" + fi + - name: Publish @stim-cli/transport-tailscale + run: | + version=$(node -p "require('$RUNNER_TEMP/tarballs/transport-tailscale.package.json').version") + if npm view "@stim-cli/transport-tailscale@$version" version >/dev/null 2>&1; then + echo "@stim-cli/transport-tailscale@$version is already published" + else + npm publish "$RUNNER_TEMP/tarballs/transport-tailscale.tgz" --provenance --access public --tag "$DIST_TAG" + fi - name: Publish @stim-cli/server run: | version=$(node -p "require('$RUNNER_TEMP/tarballs/server.package.json').version") @@ -215,7 +231,7 @@ jobs: permissions: contents: read env: - PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci server + PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server DIST_TAG: ${{ needs.publish.outputs.dist_tag }} npm_config_prefer_online: 'true' steps: diff --git a/README.md b/README.md index 9eddb536a..203964538 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ stim guide settings - [`@stim-cli/core`](./packages/core) contains shared internal cache contracts. - [`@stim-cli/server`](./packages/server) serves Stim status to paired read-only clients, such as the phone app, over Tailscale. +- [`@stim-cli/transport`](./packages/transport) is the contract for the + transports that carry clients to stim-server, and + [`@stim-cli/transport-tailscale`](./packages/transport-tailscale) is its + Tailscale transport. ## Development diff --git a/RELEASE.md b/RELEASE.md index a293a2460..fd9f19cc6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -20,6 +20,8 @@ packages/cache @stim-cli/cache cache provider contra packages/cache-s3 @stim-cli/cache-s3 optional S3-compatible cache provider packages/expo-build-cache @stim-cli/expo-build-cache Expo build cache provider packages/metro @stim-cli/metro shared Metro transform cache + log reporter +packages/transport @stim-cli/transport transport contract for stim-server +packages/transport-tailscale @stim-cli/transport-tailscale Tailscale transport for stim-server packages/server @stim-cli/server stim-server, read-only status for paired clients ``` @@ -82,7 +84,7 @@ Every package name must already exist on npm, because trusted publishing can only be configured for an existing package: ```bash -for p in @stim-cli/core @stim-cli/cache @stim-cli/cache-s3 @stim-cli/metro @stim-cli/expo-build-cache stim @stim-cli/ci @stim-cli/server; do +for p in @stim-cli/core @stim-cli/cache @stim-cli/cache-s3 @stim-cli/metro @stim-cli/expo-build-cache stim @stim-cli/ci @stim-cli/transport @stim-cli/transport-tailscale @stim-cli/server; do npm view "$p" name >/dev/null || echo "$p: first publication required" done ``` @@ -190,7 +192,7 @@ preparation, `git status --short` may show only the draft ```bash out=$(mktemp -d) - for p in core cache cache-s3 metro expo-build-cache stim-cli ci server; do + for p in core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server; do tgz=$(cd "packages/$p" && pnpm pack --pack-destination "$out" | tail -1) echo "== $p ($(tar -tzf "$tgz" | wc -l | tr -d ' ') files)" tar -tzf "$tgz" | grep -E 'README|LICENSE' diff --git a/apps/desktop/scripts/server-runtime.mjs b/apps/desktop/scripts/server-runtime.mjs index eaef9af78..5bfad62dd 100644 --- a/apps/desktop/scripts/server-runtime.mjs +++ b/apps/desktop/scripts/server-runtime.mjs @@ -22,7 +22,7 @@ const NODE_SHA256 = { arm64: '6239d4cf92d864487ec8cd3615038f7b67e7f58b77b21cd2f09ea9fbd68065fe', x64: '0ae5a24c24bb7d015cd816c5036b3f90f2945aa872fcf54e58da054753b3a299', }; -const PACKAGES = ['core', 'cache', 'metro', 'stim-cli', 'server']; +const PACKAGES = ['core', 'cache', 'metro', 'stim-cli', 'transport', 'transport-tailscale', 'server']; const usage = 'usage: node scripts/server-runtime.mjs [--universal]'; const args = process.argv.slice(2); diff --git a/docs/release-recovery.md b/docs/release-recovery.md index c59f36666..d9090f4cc 100644 --- a/docs/release-recovery.md +++ b/docs/release-recovery.md @@ -62,6 +62,8 @@ pnpm --filter @stim-cli/cache-s3 publish --access public --tag --otp pnpm --filter @stim-cli/metro publish --access public --tag --otp pnpm --filter @stim-cli/expo-build-cache publish --access public --tag --otp pnpm --filter stim publish --access public --tag --otp +pnpm --filter @stim-cli/transport publish --access public --tag --otp +pnpm --filter @stim-cli/transport-tailscale publish --access public --tag --otp pnpm --filter @stim-cli/server publish --access public --tag --otp ``` diff --git a/docs/specs/2026-10-13-pluggable-transports-design.md b/docs/specs/2026-10-13-pluggable-transports-design.md new file mode 100644 index 000000000..167dc9b61 --- /dev/null +++ b/docs/specs/2026-10-13-pluggable-transports-design.md @@ -0,0 +1,140 @@ +# Pluggable transports + +Date: 2026-10-13. Status: accepted. + +## Summary + +stim-server reaches phones and other machines only through Tailscale. This +design splits that into a transport contract, `@stim-cli/transport`, and one +package per backend: `@stim-cli/transport-tailscale`, `@stim-cli/transport-local`, +`@stim-cli/transport-ngrok`, and `@stim-cli/transport-cloudflared`. It follows +the cache provider contract in `@stim-cli/cache`: a versioned module, a bag of +optional capabilities, a loader that never throws, and a contract test kit. + +`stim` and `@stim-cli/server` depend on every first-party transport for now, +and Stim Desktop bundles them. They may become optional installs later. + +## What Tailscale does today + +Stim uses Tailscale for four separate things. The contract keeps them +separate, because the other backends provide only some of them. + +| Role | Today | Contract capability | +| --------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| Exposure and TLS | `tailscale serve` on a tailnet-only HTTPS port | `expose`, `setup` | +| Peer identity | `tailscale whois` gives the node a device token is bound to | `identify` | +| Discovery and pinning | `tailscale status --json` lists peer machines; a name that moves to another node is refused | `discover`, `resolve` | +| Bind addresses | The server also listens on the Tailscale addresses; the hosted Metro gateway trusts WireGuard source addresses | `bindAddresses`, `traits.privateNetwork` | + +The pairing core does not depend on the transport and does not change: the QR +payload, `hello` with a pairing token and then a device token, the token +registry, and grant and revoke. + +## Decisions + +- Transports are machine settings, not project settings. stim-server is one + process per machine and ships inside Stim Desktop, so transports resolve from + the server's own dependencies. A cache provider, by contrast, resolves from + the project that names it. `network.transports` is an ordered list, so one + server can answer a simulator on loopback and a phone on the tailnet. +- Stim adds its own peer identity, so every transport gets a second factor + beside the device token. At pairing the client sends a public key. Later + `hello` calls answer a server challenge with that key, and the server proves + the key the QR code names. A transport identity, such as a Tailscale node, + remains an extra check where the transport has one. +- Identity comes from the transport that owns the listener, never from request + headers. `tailscale serve`, ngrok, and cloudflared all proxy from loopback with + `X-Forwarded-For`, so the header cannot tell them apart. +- ngrok and cloudflared decrypt traffic at their edge. This reverses the + "Transport alternatives" note of the stim-server design: these backends ship, + but only when the user turns them on, and Desktop and the docs say that the + provider can read the traffic. Application-layer encryption may follow. +- Transports without `privateNetwork` cannot carry hosted Metro, whose gateway + trusts source addresses, until hosted Metro moves onto the authenticated + WebSocket. +- Metro reach (`metro.tunnel`) moves onto the same plugins, through + `expose({ purpose: 'metro' })`, after the server side is done. + +## Contract + +```ts +export const apiVersion = 1; +export const id: string; +export const optionsSchema: JSONSchema; // phase 3: x-stim annotations, rendered by Desktop +export function createTransport(input: { + env: NodeJS.ProcessEnv; + options: Record; +}): Transport | Promise; + +interface Transport { + id: string; + traits: { privateNetwork: boolean; tls: 'end-to-end' | 'edge' | 'none'; peerIdentity: boolean }; + bindAddresses?(): string[]; + watch?(listener: () => void): () => void; + identify?(peer: { address: string; request: IncomingMessage }): Promise; + close?(): void; + // Later phases: + status?(): Promise; + setup?(input: { port: number; action: string }): Promise; + expose?(input: { port: number; purpose: 'server' | 'metro' }): Promise; + discover?(): Promise; + resolve?(target: { machine: string; peerId: string }): Promise; +} +``` + +Phase 1 ships the members above the comment. The others are added as optional +members while `apiVersion` stays 1, the way `@stim-cli/cache` added `files`. +In phase 1, `address` is still the `X-Forwarded-For` peer that `tailscale serve` +sets, and stim-server binds an identity to the transport that returned it. Phase 2 +gives each listener a single transport, so only the transport that owns the +connection is asked. + +`TransportStatus` is a list of checks, each with a state (`waiting`, +`working`, `ok`, `problem`), a title, an optional detail, and an optional +remedy (a command to copy, a URL to open, or a `setup` action). Desktop already +models pairing steps this way, so it renders any transport's checks without +transport-specific code. + +## Apps + +Stim Desktop is a Swift app and cannot load plugins, so the server serializes +what the apps need: + +- `/health` gains `transports`, one entry per configured transport with its + state, endpoint, and traits. The `tailscale` and `route` fields stay while + older Desktop builds are in use. +- `transport.status`, `transport.setup`, and `transport.discover` replace + Desktop's own `tailscale status --json` reads and the `route.setup` call. +- Each transport's `optionsSchema` joins `settings.schema.json` under + `network.options.`, so the existing settings editor renders it. Cache + providers get the same optional `optionsSchema`. +- Desktop shows the generic checks list by default. The Tailscale screens keep + their custom content (app links, app or CLI install, Funnel warning) as an + override keyed on the transport id. +- The QR payload moves to `v: 2` with an `endpoints` list, each entry naming its + transport, plus the server key. The mobile app tries the endpoints in order + and stops relying on the `.ts.net` suffix to decide whether to trust a host. + Error and onboarding copy becomes per transport. + +## Credentials + +Machine credentials store `nodeId` and `dnsName`. They become +`{ transport, peerId, address }`, and records without a transport are read as +`tailscale`. Machine names in settings accept an optional `:` +prefix; no prefix means `tailscale`. + +## Phases + +1. Add `@stim-cli/transport` and move Tailscale into + `@stim-cli/transport-tailscale`, with the server reaching it only through the + contract. No behavior change; the existing server, setup, registry, and + hosted relay tests are the check. +2. Add `@stim-cli/transport-local` (loopback, and LAN with a self-signed + certificate pinned by the QR code), the `v: 2` QR payload, and the key-based + peer identity. +3. Serve `/health.transports`, `transport.status`, `transport.setup`, and + `transport.discover`, and move Desktop and the mobile app onto them. +4. Add `@stim-cli/transport-ngrok` and `@stim-cli/transport-cloudflared` for + pairing and paired connections. +5. Move Metro reach onto the transports, support remote machines over + transports without discovery, and carry hosted Metro over the WebSocket. diff --git a/package.json b/package.json index 9511a24f1..e2dd3df81 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ ], "type": "module", "scripts": { - "typecheck": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/cache/tsconfig.json && tsc --noEmit -p packages/cache-s3/tsconfig.json && tsc --noEmit -p packages/stim-cli/tsconfig.json && tsc --noEmit -p packages/metro/tsconfig.json && tsc --noEmit -p packages/expo-build-cache/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ci/tsconfig.json && tsc --noEmit -p .github/actions/stim-ci/tsconfig.json", + "typecheck": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/cache/tsconfig.json && tsc --noEmit -p packages/cache-s3/tsconfig.json && tsc --noEmit -p packages/stim-cli/tsconfig.json && tsc --noEmit -p packages/metro/tsconfig.json && tsc --noEmit -p packages/expo-build-cache/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/transport/tsconfig.json && tsc --noEmit -p packages/transport-tailscale/tsconfig.json && tsc --noEmit -p packages/ci/tsconfig.json && tsc --noEmit -p .github/actions/stim-ci/tsconfig.json", "lint": "oxlint . && node scripts/type-assertion-ratchet.mjs", "lint:fix": "oxlint --fix .", "format": "oxfmt .", diff --git a/packages/server/README.md b/packages/server/README.md index 26bc7ba83..1577014de 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1135,14 +1135,16 @@ the job to it. `--release ` takes an exact version from the public npm registry (`https://registry.npmjs.org/`, never a registry `.npmrc` names), and npm checks each package against the registry's integrity. `--from ` installs the `.tgz` packages in that directory, such as `pnpm pack` output of -the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim` and -`@stim-cli/server` packages of a checkout, and records each file's sha256. +the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim`, +`@stim-cli/transport`, `@stim-cli/transport-tailscale` and `@stim-cli/server` +packages of a checkout, and records each file's sha256. Either way, `npm audit signatures` must verify the registry signature of every package that came from the registry, and the provenance attestation of each one that has it, or the update stops before it touches the job. Install scripts never run. Each install goes to `~/Library/Application Support/Stim/services/