diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..d367935
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,34 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "studio-api",
+ "runtimeExecutable": "dotnet",
+ "runtimeArgs": [
+ "run",
+ "--project",
+ "src/backend/Tap.Studio/Tap.Studio.csproj",
+ "-p:SkipTapUiBuild=true",
+ "--",
+ "--Studio:WorkspaceRoot=samples/sample-workspace",
+ "--Studio:Port=5298"
+ ],
+ "port": 5298
+ },
+ {
+ "name": "studio-ui",
+ "runtimeExecutable": "env",
+ "runtimeArgs": [
+ "STUDIO_API_URL=http://localhost:5298",
+ "scripts/studio-ui-dev.sh"
+ ],
+ "port": 5297
+ },
+ {
+ "name": "studio-aspire",
+ "runtimeExecutable": "sh",
+ "runtimeArgs": ["-c", "echo 'attaching to aspire-managed studio on 55446'; while :; do sleep 3600; done"],
+ "port": 55446
+ }
+ ]
+}
diff --git a/.claude/skills/mantine/SKILL.md b/.claude/skills/mantine/SKILL.md
new file mode 100644
index 0000000..46abd32
--- /dev/null
+++ b/.claude/skills/mantine/SKILL.md
@@ -0,0 +1,179 @@
+---
+name: mantine
+description: "Mantine v9 React component library cheat sheet for the Tap Studio UI (src/ui-studio/). USE WHEN: writing or editing React components under src/ui-studio/, asking about Mantine v9 props, color-scheme, theme tokens, AppShell, forms, modals, notifications, tabs, badges, layout. DO NOT USE FOR: the standalone Tap Inspector UI (src/ui-inspector/) which uses its own CSS, or for non-React code. INVOKES: file edits, build commands. The Studio UI is Mantine-only — drop in raw
/CSS only when there's no Mantine equivalent."
+---
+
+# Mantine v9 skill (Tap Studio)
+
+The Tap Studio UI (`src/ui-studio/`) is built entirely on **Mantine 9.2.1** + **@tabler/icons-react**. There is no Tailwind, no shadcn, no base-ui — only Mantine. Custom CSS modules are reserved for the variable-input painted overlay and a couple of helper styles. Everything else uses Mantine components and theme tokens.
+
+This skill is the working reference: idiomatic patterns we already use, v9-specific gotchas, and where to look for more.
+
+## When to invoke
+
+Invoke this skill any time the work touches `src/ui-studio/src/**` and involves form layouts, dialogs, tab bars, color-scheme behaviour, or new component scaffolding. For pure data/state work (Zustand store, API client, TypeScript types) you don't need it.
+
+## Quick links
+
+- **Mantine docs**: https://mantine.dev/getting-started/
+- **Component index**: https://mantine.dev/core/anchor/ (replace `anchor` with the component name)
+- **Tabler icons**: https://tabler.io/icons (search → import `IconXxx` from `@tabler/icons-react`)
+- **v8→v9 changelog**: https://mantine.dev/changelog/9-0-0/
+- **Theme object types**: `node_modules/@mantine/core/lib/core/MantineProvider/theme/...`
+- **Reference app**: `/Users/p7e/code/philbir/dreamr/src/ui-inspector` — uses Mantine 8 but most patterns are stable.
+
+## Live docs via the Context7 MCP
+
+The repo ships a project-scoped MCP at `/.mcp.json` that registers **Context7**. Use it to pull current prop signatures + code snippets without opening a browser:
+
+1. `mcp__context7__resolve-library-id` with `libraryName: "mantine"` → returns `/mantinedev/mantine` (and version variants).
+2. `mcp__context7__get-library-docs` with that ID + a focused `topic` (e.g. `"Collapse component"`, `"TagsInput onChange"`, `"AppShell Header"`).
+
+Context7 is the canonical source for v9 props (when in doubt about `expanded` vs `in`, ask it first). For Tabler icons, `mcp__context7__resolve-library-id` with `"@tabler/icons-react"` works the same way.
+
+## Files that pin our setup
+
+| File | Role |
+|---|---|
+| `src/ui-studio/src/main.tsx` | `MantineProvider` + `ColorSchemeScript` + `Notifications` + `ModalsProvider`. |
+| `src/ui-studio/src/theme.ts` | Custom `tap` color tuple + `defaultRadius` + component defaults. |
+| `src/ui-studio/postcss.config.cjs` | `postcss-preset-mantine` + breakpoint variables. |
+| `src/ui-studio/src/styles/index.css` | The *only* hand-rolled global CSS (method colors + mono font stack). |
+| `src/ui-studio/src/workspace/useTheme.ts` | Thin wrapper over `useMantineColorScheme`. |
+
+## v9-specific gotchas you will hit
+
+1. **`Collapse` uses `expanded`, not `in`.** v8 used `in`; v9 renamed it.
+ ```tsx
+
…
+ ```
+2. **`MultiSelect` cannot create new tags** — for chip-style inputs (auth scopes, tags) use **`TagsInput`**.
+3. **`Select` defaults to `allowDeselect={true}`.** For "must-pick-one" fields, set `allowDeselect={false}` so users can't click the selected option to clear it.
+4. **`Select` accepts string or `{value,label}[]`** — when passing `readonly` arrays (e.g. tuples), cast: `data={METHODS as unknown as string[]}`.
+5. **`Tabs` value is `string | null`** — keep state as `useState
('default-tab')`.
+6. **`Modal.onClose` runs on any close (Esc, backdrop, X)** — your cleanup logic goes there, not on the "Cancel" button.
+7. **`Notifications`** is a *component*, not a hook — mount it once at the root (already done in `main.tsx`). Use `notifications.show({…})` to fire.
+8. **`useDisclosure`** returns `[opened, { open, close, toggle }]` — destructure the second element by name, not index.
+9. **CSS variables are prefixed `--mantine-color-*`** (e.g. `--mantine-color-tap-6`). The `c` prop accepts shortcut names: `c="tap"`, `c="dimmed"`, `c="red"`.
+10. **``** is inline by default — use `` for multi-line code blocks.
+11. **The `loading` prop** on `Button` shows a spinner and disables the button — don't double-disable manually.
+
+## Idiomatic patterns from our codebase
+
+### Forms — typed local state, no `@mantine/form`
+
+We don't use `@mantine/form` (yet). Editors keep `spec` and `savedSpec` via `useState` and PUT a typed DTO to the server. See `src/editors/AuthEditor.tsx`. The pattern:
+
+```tsx
+const [spec, setSpec] = useState(null)
+const [savedSpec, setSavedSpec] = useState(null)
+const dirty = useMemo(() => JSON.stringify(spec) !== JSON.stringify(savedSpec), [spec, savedSpec])
+
+function update(key: K, value: AuthSpec[K]) {
+ setSpec((cur) => cur ? { ...cur, [key]: value } : cur)
+}
+```
+
+If a future editor needs validation, switch to `useForm()` from `@mantine/form` — but be consistent within the editor.
+
+### Layout primitives we lean on
+
+- **``** — vertical form fields, dialog bodies, panel content.
+- **``** — inline row of controls (toolbar, header right side).
+- **``** — two side-by-side inputs (e.g. Name + Type).
+- **``** — the kind picker cards in CreateNewDialog.
+- **``** with `header / navbar / main` — the top-level shell in `App.tsx`.
+- **``** wherever content might overflow. Set `flex={1}` for vertical fill.
+- **`` + ` }>`** — every editor's section navigation.
+
+### Color usage
+
+- Brand: `color="tap"` (primary color is set in the theme).
+- Per-kind chips in editor headers (`EditorShell`):
+ - Request → `tap`
+ - API → `teal`
+ - Auth → `orange`
+ - Environment → `grape`
+ - Workspace → `tap`
+- Action states: `green` (success), `red` (error/destructive), `yellow` (warn/secret), `gray` (muted).
+- Don't use raw hex codes in component code — pick from theme colors or use `c="dimmed"`.
+
+### Indicating dirty / counts on tabs
+
+```tsx
+
+ Headers {count > 0 && {count} }
+
+```
+
+### Modals via the provider
+
+Use the imperative API only when there's no useful body — for actual forms (Add Workspace, Create New) prefer `` directly. Confirmation dialogs:
+
+```tsx
+modals.openConfirmModal({
+ title: 'Delete request?',
+ children: This can't be undone. ,
+ labels: { confirm: 'Delete', cancel: 'Keep' },
+ confirmProps: { color: 'red' },
+ onConfirm: () => doDelete(),
+})
+```
+
+### Notifications
+
+```tsx
+import { notifications } from '@mantine/notifications'
+notifications.show({ title: 'Saved', message: 'Request updated.', color: 'green', autoClose: 2500 })
+notifications.show({ title: 'Save failed', message: e.message, color: 'red' })
+```
+
+### Tabler icons
+
+- Always import from `@tabler/icons-react`: `import { IconSend, IconLock } from '@tabler/icons-react'`.
+- Default `size={14}` inside `Tabs.Tab` and toolbar buttons; `size={16}` for header icons; `size={20}` for empty-state hero icons.
+- Stroke width is `2` by default. Use `stroke={1.5}` for big/decorative icons so they don't look heavy.
+
+### Variable-token highlighting
+
+`src/editors/VariableInput.tsx` wraps `TextInput` with a painted overlay that colorizes `{{var}}` and `${{secret}}` tokens. Use it anywhere a value can carry templates (URLs, auth fields, env values). Plain `TextInput` is fine for non-templated fields like `Name`.
+
+## Migration anti-patterns (don't do these)
+
+- ❌ Importing from `@base-ui-components/react` — that package has been removed.
+- ❌ Inlining CSS in `style={{}}` for layout — use ``, ``, or `mb="md"` props. Inline `style` is OK for one-off width tweaks and Mantine CSS-variable references (e.g. `color: 'var(--mantine-color-tap-6)'`).
+- ❌ Creating new `.module.css` files — Mantine's `styles` prop + `style` prop cover 95% of cases; the remaining 5% goes through theme overrides in `theme.ts`.
+- ❌ Using `document.documentElement.dataset.theme` — Mantine owns the color scheme via `useMantineColorScheme`.
+- ❌ Wrapping every input in a manual `` — Mantine's input components have a `label` prop.
+
+## Adding a new component to a panel
+
+1. Find the closest Mantine match at https://mantine.dev/core/.
+2. Import via `import { ComponentName } from '@mantine/core'` (named exports only).
+3. Default to **`size="sm"`** unless the component is a hero/empty-state.
+4. If you need a layout other than `Stack`/`Group`, reach for `` with `display`/`style` rather than a new CSS module.
+5. Wire icons through `@tabler/icons-react` (`leftSection`, `rightSection`, `icon` props).
+
+## Color scheme & SSR-flash prevention
+
+- ` ` is the FIRST child of `` in `main.tsx`. It injects a tiny pre-mount script that reads `localStorage.mantine-color-scheme-value` and sets `data-mantine-color-scheme` on `` before React paints. **Don't move it.**
+- The theme button calls `useMantineColorScheme().setColorScheme('light'|'dark'|'auto')`.
+
+## Theme extension cookbook
+
+Add component-wide defaults in `theme.ts` rather than passing the same props at every call site. Example: `Button` defaults to `size: 'sm'` there. To change Mantine's spacing scale or add a new color, see https://mantine.dev/theming/theme-object/.
+
+## Workflow: building a new editor or panel
+
+1. Spec the props (typed object client-side, server emits YAML — see `Specs/` on the backend).
+2. Start with `` — gives you save/discard/error-bar/dirty-dot for free.
+3. Put tabs inside via `` + `` + `` per section.
+4. Forms: vertical ``; per-field `` or ``.
+5. For tabular data: import `KvTable` from `./KvTable` (the local Mantine-styled version).
+6. For OAuth-style execute panels: pass `rightPane={ }` — `EditorShell` allocates 520px for it.
+
+## Don't forget
+
+- After editing CSS modules or theme tokens, run `yarn build` from `src/ui-studio/` to catch type errors fast.
+- The Studio AppHost (`samples/Studio.AppHost`) runs Vite via the JavaScript hosting integration. Hot-reload works without restarting the AppHost as long as `vite.config.ts` is unchanged.
+- When in doubt about a v9-vs-v8 prop name, check `/tmp/mantine-check/node_modules/@mantine/core/lib/components//.d.ts` — copy the package locally with `npm i --no-save @mantine/core@9.2.1` in a scratch dir if needed.
diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml
new file mode 100644
index 0000000..8485ed6
--- /dev/null
+++ b/.github/workflows/desktop.yml
@@ -0,0 +1,259 @@
+name: Build desktop app
+
+on:
+ push:
+ # Plain SemVer tags, no `v` prefix — `0.1.0`, `0.2.0-alpha.4`, etc.
+ tags: ["[0-9]+.[0-9]+.[0-9]+*"]
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+env:
+ # Windows runners default `run:` blocks to PowerShell, where corepack's
+ # download prompt hangs waiting on stdin. Force non-interactive corepack.
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
+
+jobs:
+ build:
+ permissions:
+ contents: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # macOS: arm64 only. macos-13 (Intel) runners are deprecated/scarce
+ # and Rosetta runs the arm64 binary fine on Intel Macs. Add a native
+ # x86_64 row if/when we ship a non-Rosetta build.
+ - { runner: macos-14, target: aarch64-apple-darwin, bundles: "app,dmg" }
+ # Linux: deb only — AppImage bundling via linuxdeploy is fragile in
+ # CI and not our primary distribution channel.
+ - { runner: ubuntu-22.04, target: x86_64-unknown-linux-gnu, bundles: "deb" }
+ - { runner: windows-latest, target: x86_64-pc-windows-msvc, bundles: "msi,nsis" }
+
+ runs-on: ${{ matrix.runner }}
+ name: ${{ matrix.target }}
+ # Fail fast if a step hangs (Windows UI build stalled ~70m under PowerShell)
+ # instead of eating the full 6h default.
+ timeout-minutes: 30
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - run: corepack enable
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Install Linux deps
+ if: startsWith(matrix.runner, 'ubuntu-')
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libwebkit2gtk-4.1-dev \
+ libgtk-3-dev \
+ librsvg2-dev \
+ libayatana-appindicator3-dev \
+ patchelf
+
+ # src/ui-studio uses yarn 4 (Berry); install at the desktop dir level so the
+ # @tauri-apps/cli is in scope for tauri-action's bundling step.
+ - name: Install desktop deps
+ working-directory: src/desktop
+ run: yarn install --immutable
+
+ # Defensive sync: rewrite src/desktop/package.json,
+ # src-tauri/tauri.conf.json, src-tauri/Cargo.toml, and src-tauri/Cargo.lock
+ # to the tag version so the bundled installers' filenames and Info.plist
+ # always match the tag — even when a release PR forgets to bump them.
+ - name: Sync desktop version from tag
+ if: github.ref_type == 'tag'
+ shell: bash
+ run: node src/desktop/scripts/sync-version.mjs "${GITHUB_REF_NAME}"
+
+ # Build the SPA, then publish the sidecar + stage wwwroot for the target
+ # triple. compile-server.mjs is cross-platform (no bash) so the Windows
+ # runner can invoke it without WSL.
+ - name: Build Studio UI
+ working-directory: src/ui-studio
+ shell: bash
+ run: |
+ corepack enable
+ yarn install --immutable
+ yarn build
+
+ - name: Compile sidecar
+ run: node src/desktop/scripts/compile-server.mjs ${{ matrix.target }}
+
+ # Notarization needs the App Store Connect .p8 private key on disk.
+ # The secret holds the base64-encoded file; decode and export the path so
+ # tauri-action picks it up. No-op on non-macOS runners and when the
+ # secret isn't set.
+ - name: Decode App Store Connect API key (macOS only)
+ if: startsWith(matrix.runner, 'macos-')
+ env:
+ APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
+ run: |
+ if [ -n "$APPLE_API_KEY_BASE64" ]; then
+ mkdir -p "$HOME/.private_keys"
+ echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$HOME/.private_keys/AuthKey.p8"
+ echo "APPLE_API_KEY_PATH=$HOME/.private_keys/AuthKey.p8" >> "$GITHUB_ENV"
+ fi
+
+ # The MSI/WiX bundler can only encode a numeric-only pre-release identifier
+ # in ProductVersion (e.g. 0.5.0-1) and rejects 0.5.0-beta.1 outright. Drop
+ # MSI for pre-release tags — NSIS handles SemVer pre-releases and is what the
+ # Windows updater consumes anyway.
+ - name: Resolve bundles for this tag
+ id: bundles
+ shell: bash
+ run: |
+ B="${{ matrix.bundles }}"
+ if [[ "${{ matrix.target }}" == "x86_64-pc-windows-msvc" && "${GITHUB_REF_NAME}" == *-* ]]; then
+ B="nsis"
+ echo "Pre-release tag ${GITHUB_REF_NAME}: building nsis only (skipping msi)."
+ fi
+ echo "value=$B" >> "$GITHUB_OUTPUT"
+
+ - uses: tauri-apps/tauri-action@v0
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # macOS code-signing + notarization. Tauri-action only acts on these
+ # when bundling for macOS targets; on other runners they resolve to
+ # empty strings and are ignored. Until the secrets are populated on
+ # GitHub the build falls back to ad-hoc signing (suitable for local
+ # dev; users get a Gatekeeper prompt on first launch).
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ # Notarization via App Store Connect API key (preferred over
+ # app-specific passwords). Set ALL of APPLE_API_* together.
+ # APPLE_API_KEY_PATH is exported by the decode step above.
+ APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+ APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
+ # Updater artifact signing. Generate the keypair once with:
+ # npx @tauri-apps/cli signer generate -w ~/.tauri/tap-studio.key
+ # Store the private key in TAURI_SIGNING_PRIVATE_KEY and the password
+ # (if any) in TAURI_SIGNING_PRIVATE_KEY_PASSWORD. tauri-action will
+ # sign every installable artifact; the publish-updater job composes
+ # latest.json from the resulting .sig files.
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ with:
+ projectPath: src/desktop
+ tagName: ${{ github.ref_name }}
+ releaseName: "Tap Studio ${{ github.ref_name }}"
+ # Publish immediately so artifacts are downloadable from Releases.
+ # Tags with a dash (e.g. 0.2.0-alpha.3) are marked as pre-releases.
+ releaseDraft: false
+ prerelease: ${{ contains(github.ref_name, '-') }}
+ # tauri-action's own latest.json emitter trips over the asset rename
+ # it does on upload, so the manifest is skipped and .sig files never
+ # get attached. We compose latest.json ourselves in publish-updater.
+ includeUpdaterJson: false
+ args: --target ${{ matrix.target }} --bundles ${{ steps.bundles.outputs.value }}
+
+ # tauri-action uploads the user-facing bundles (.dmg / .msi / .exe /
+ # .deb / .app.tar.gz) but not the .sig files needed for the updater
+ # manifest. Upload them here, renaming so each sig matches the
+ # release-asset name tauri-action picked.
+ - name: Attach updater signatures to release
+ if: github.ref_type == 'tag'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ TARGET: ${{ matrix.target }}
+ VERSION: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+ BUNDLE="src/desktop/src-tauri/target/${TARGET}/release/bundle"
+ case "${TARGET}" in
+ aarch64-apple-darwin)
+ cp "${BUNDLE}/macos/Tap Studio.app.tar.gz.sig" \
+ "Tap-Studio_aarch64.app.tar.gz.sig"
+ gh release upload "${VERSION}" \
+ "Tap-Studio_aarch64.app.tar.gz.sig" --clobber
+ ;;
+ x86_64-pc-windows-msvc)
+ # With createUpdaterArtifacts on, tauri-action uploads the NSIS
+ # installer (_x64-setup.exe) AND its .exe.sig itself — the Windows
+ # updater installs from the .exe directly (there's no .nsis.zip in
+ # Tauri v2). Nothing to attach here.
+ echo "Windows updater .exe + .sig uploaded by tauri-action — skipping."
+ ;;
+ x86_64-unknown-linux-gnu)
+ echo "Linux ships .deb only — no updater bundle, skipping."
+ ;;
+ esac
+
+ # Compose latest.json from the per-platform sigs the matrix attached and
+ # publish it to the release. The updater client at
+ # github.com/philbir/tap/releases/latest/download/latest.json reads this.
+ publish-updater:
+ needs: build
+ if: github.ref_type == 'tag'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Generate and upload latest.json
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ VERSION: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+ mkdir -p sigs
+ gh release download "${VERSION}" --pattern '*.sig' --dir sigs
+
+ BASE="https://github.com/${GH_REPO}/releases/download/${VERSION}"
+ PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+ NOTES=$(gh release view "${VERSION}" --json body --jq '.body // ""')
+
+ PLATFORMS='{}'
+
+ MAC_SIG_PATH="sigs/Tap-Studio_aarch64.app.tar.gz.sig"
+ if [ -f "${MAC_SIG_PATH}" ]; then
+ MAC_SIG=$(cat "${MAC_SIG_PATH}")
+ PLATFORMS=$(jq -n \
+ --arg sig "${MAC_SIG}" \
+ --arg url "${BASE}/Tap-Studio_aarch64.app.tar.gz" \
+ '{ "darwin-aarch64": { signature: $sig, url: $url } }')
+ fi
+
+ # Tauri v2's NSIS updater artifact is the setup .exe + its .exe.sig
+ # (uploaded by tauri-action). Glob it so we don't hardcode the product
+ # name, and point the manifest URL at the .exe.
+ WIN_SIG_PATH=$(ls sigs/*_x64-setup.exe.sig 2>/dev/null | head -1 || true)
+ if [ -n "${WIN_SIG_PATH}" ] && [ -f "${WIN_SIG_PATH}" ]; then
+ WIN_SIG=$(cat "${WIN_SIG_PATH}")
+ WIN_EXE=$(basename "${WIN_SIG_PATH}" .sig)
+ PLATFORMS=$(echo "${PLATFORMS}" | jq \
+ --arg sig "${WIN_SIG}" \
+ --arg url "${BASE}/${WIN_EXE}" \
+ '. + { "windows-x86_64": { signature: $sig, url: $url } }')
+ fi
+
+ jq -n \
+ --arg version "${VERSION}" \
+ --arg notes "${NOTES}" \
+ --arg pub_date "${PUB_DATE}" \
+ --argjson platforms "${PLATFORMS}" \
+ '{ version: $version, notes: $notes, pub_date: $pub_date, platforms: $platforms }' \
+ > latest.json
+
+ echo "--- latest.json ---"
+ cat latest.json
+ gh release upload "${VERSION}" latest.json --clobber
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index b6282fc..a04a714 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -50,7 +50,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
- file: src/Tap.Server/Dockerfile
+ file: src/backend/Tap.Server/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml
index 27bd27a..cb2c0d3 100644
--- a/.github/workflows/release-binaries.yml
+++ b/.github/workflows/release-binaries.yml
@@ -79,7 +79,7 @@ jobs:
- name: Publish self-contained single-file
shell: bash
run: |
- dotnet publish src/Tap.Cli/Tap.Cli.csproj \
+ dotnet publish src/backend/Tap.Cli/Tap.Cli.csproj \
-c Release \
-r ${{ matrix.rid }} \
--self-contained true \
diff --git a/.gitignore b/.gitignore
index acd4fed..9b8ee31 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ obj/
# Node / Vite
node_modules/
dist/
+.vite/
**/.yarn/*
!**/.yarn/patches
!**/.yarn/plugins
@@ -17,9 +18,23 @@ dist/
.pnp.*
*.tsbuildinfo
-# Bundled UI artifacts (regenerated by Tap.Server build target from ui/dist)
-src/Tap.Server/wwwroot/*
-!src/Tap.Server/wwwroot/.gitkeep
+# Bundled UI artifacts (regenerated by Tap.Server build target from src/ui-inspector/dist)
+src/backend/Tap.Server/wwwroot/*
+!src/backend/Tap.Server/wwwroot/.gitkeep
+
+# Bundled UI artifacts (regenerated by Tap.Studio build target from src/ui-studio/dist)
+src/backend/Tap.Studio/wwwroot/*
+!src/backend/Tap.Studio/wwwroot/.gitkeep
+
+# Tauri shell — Rust build output + sidecar binaries (regenerated by
+# scripts/build-desktop.sh). The src-tauri/gen dir is regenerated on every
+# build by tauri-build (schemas, capabilities snapshot).
+src/desktop/src-tauri/target/
+src/desktop/src-tauri/gen/
+# ACL permissions regenerated by build.rs (AppManifest.commands) on every build.
+src/desktop/src-tauri/permissions/autogenerated/
+src/desktop/src-tauri/binaries/*
+!src/desktop/src-tauri/binaries/.gitkeep
# OS
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..a1270c9
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-mcp",
+ "//": "Project-scoped MCP servers for the Tap repo. Auto-loaded by Claude Code when this directory is opened. Each contributor is prompted once to approve the server before it runs.",
+ "mcpServers": {
+ "//": "Context7 surfaces up-to-date docs + code snippets for any library indexed at context7.com. Used here so the Mantine v9 skill can fetch authoritative prop signatures + examples on demand. No API key needed.",
+ "context7": {
+ "command": "npx",
+ "args": ["-y", "@upstash/context7-mcp@latest"]
+ }
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
index ddb270b..9fb92cb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,9 +14,9 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
### UI
- `ui/` is yarn 4.9.1 (Berry) — use `yarn`, not `npm`. Scripts: `yarn dev` (Vite, port 5197), `yarn build` (`tsc -b && vite build`), `yarn preview`.
-- `Tap.Server.csproj` has a `BuildTapUi` MSBuild target that runs `yarn install` + `yarn build` and copies `ui/dist/**` into `src/Tap.Server/wwwroot/` on every server build. Set `-p:SkipTapUiBuild=true` to skip when iterating on C# only.
-- For UI hot-reload against a running AppHost: `cd ui && yarn dev`. Vite proxies `/api` → `VITE_INSPECTOR_API_URL` (default `http://localhost:5198`); set that env var to whatever UI port the AppHost allocated.
-- `src/Tap.Server/wwwroot/*` is gitignored (regenerated artifact) — never hand-edit it.
+- `Tap.Server.csproj` has a `BuildTapUi` MSBuild target that runs `yarn install` + `yarn build` and copies `src/ui-inspector/dist/**` into `src/backend/Tap.Server/wwwroot/` on every server build. Set `-p:SkipTapUiBuild=true` to skip when iterating on C# only.
+- For UI hot-reload against a running AppHost: `cd src/ui-inspector && yarn dev`. Vite proxies `/api` → `VITE_INSPECTOR_API_URL` (default `http://localhost:5198`); set that env var to whatever UI port the AppHost allocated.
+- `src/backend/Tap.Server/wwwroot/*` is gitignored (regenerated artifact) — never hand-edit it.
## Architecture
diff --git a/CLAUDE.md b/CLAUDE.md
index b097d4e..c648407 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,6 +2,11 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+## Developer flow
+
+- **Frontend changes (`src/ui-inspector/`, `src/ui-studio/`)**: always verify in a browser before reporting done. Use the Claude Preview / Chrome MCP to load the dev server, exercise the changed feature, and check the console for errors. Type-check + build passing is not enough — the UI must actually render and behave correctly.
+- **Backend changes (`Tap.Hosting`, `Tap.Server`, `Tap.Studio`, AppHost)**: use the Aspire CLI to rebuild and restart only the affected resource rather than restarting the whole AppHost. Prefer `aspire` over `dotnet run`/manual kill loops — it keeps tunnels, daemons, and sibling resources warm and gives faster turnaround.
+
## Build, run, test
- SDK is pinned in `global.json` to .NET 10 (`10.0.201`). Targets `net10.0` everywhere.
@@ -13,17 +18,28 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
### UI
-- `ui/` is yarn 4.9.1 (Berry) — use `yarn`, not `npm`. Scripts: `yarn dev` (Vite, port 5197), `yarn build` (`tsc -b && vite build`), `yarn preview`.
-- `Tap.Server.csproj` has a `BuildTapUi` MSBuild target that runs `yarn install` + `yarn build` and copies `ui/dist/**` into `src/Tap.Server/wwwroot/` on every server build. Set `-p:SkipTapUiBuild=true` to skip when iterating on C# only.
-- For UI hot-reload against a running AppHost: `cd ui && yarn dev`. Vite proxies `/api` → `VITE_INSPECTOR_API_URL` (default `http://localhost:5198`); set that env var to whatever UI port the AppHost allocated.
-- `src/Tap.Server/wwwroot/*` is gitignored (regenerated artifact) — never hand-edit it.
+- **Two UIs.** `src/ui-inspector/` is the inspector (vanilla CSS modules + base-ui primitives, embedded in Tap.Server). `src/ui-studio/` is the workbench (Mantine v9.2.1 + Tabler icons + Zustand). They share zero runtime code.
+- Both are yarn 4.9.1 (Berry) — use `yarn`, not `npm`. Scripts: `yarn dev` / `yarn build` / `yarn preview`. Inspector dev port is 5197; Studio dev port is 5297.
+- `Tap.Server.csproj` has a `BuildTapUi` MSBuild target that runs `yarn install` + `yarn build` and copies `src/ui-inspector/dist/**` into `src/backend/Tap.Server/wwwroot/` on every server build. Set `-p:SkipTapUiBuild=true` to skip when iterating on C# only.
+- For inspector hot-reload against a running AppHost: `cd src/ui-inspector && yarn dev`. Vite proxies `/api` → `VITE_INSPECTOR_API_URL` (default `http://localhost:5198`).
+- For Studio hot-reload: `cd src/ui-studio && yarn dev` against the Studio.AppHost (`VITE_STUDIO_API_URL`, default `http://localhost:5298`).
+- `src/backend/Tap.Server/wwwroot/*` is gitignored (regenerated artifact) — never hand-edit it.
+
+### Studio UI conventions (`src/ui-studio/`)
+
+- **Mantine-only**. The component library is `@mantine/core` v9.2.1 plus `@mantine/hooks`, `@mantine/modals`, `@mantine/notifications`, `@mantine/form`. Do not introduce other UI libs. Icons come from `@tabler/icons-react`. The provider stack lives in `src/main.tsx`; theme in `src/theme.ts`.
+- **Skill**: `.claude/skills/mantine/SKILL.md` documents the v9 gotchas, our conventions, color tokens, and the per-kind editor recipe. Load it whenever editing `src/ui-studio/`.
+- **Live docs MCP**: `.mcp.json` registers Context7 (`@upstash/context7-mcp`). Use `mcp__context7__resolve-library-id` + `get-library-docs` for authoritative v9 prop signatures.
+- **State**: Zustand store at `src/store/index.ts`. Global slices (`info / tree / envs / collections / auths / knownWorkspaces / tabs / activeEnvByRoot / generation`) live there; editor-local form state stays in `useState`. UI state (open tabs + per-workspace active env) is persisted to localStorage via `persist` middleware.
+- **Editor pattern**: every editor maintains a typed `spec` + `savedSpec` and PUTs to `/api/{kind}/spec` on save. The server (in `src/backend/Tap.Studio/Specs/`) is the sole producer of canonical YAML — clients never assemble YAML strings. Dirty = `JSON.stringify(spec) !== JSON.stringify(savedSpec)`. Source tab is read-only.
+- **No CSS modules in editors.** Spacing via Mantine props (`mb="md"`, `gap="xs"`), layout via `` / `` / ``. The one exception is `VariableInput.module.css` (painted-overlay token highlighter).
## Architecture
Tap is two NuGet-style packages plus a sample, glued together by Aspire:
- **`Tap.Hosting`** (library, namespace `Aspire.Hosting`) — extension methods consumers call from their own AppHost. Primary surface: `AddTap`, `AddTapContainer`, `WithTap`, `tap.WithTunnel(name, configure)`, `tap.WithQuickTunnel`, `tap.WithTailscaleFunnel`, `WithExistingTunnel`, `WithApiManagedTunnel`, `WithDynamicHostname`, `WithSystemDaemon`/`WithEphemeralDaemon`/`WithFunnelPort` (Tailscale). Low-level escape hatches still public: `AddCloudflaredTunnel`, `AddTailscaleFunnel`, `WithCloudflareTunnel`, `WithTailscaleFunnel(target, tunnel)`. No runtime; pure AppHost wiring.
-- **Tunnel abstraction** lives in `src/Tap.Hosting/Tunnels/` (`TapTunnelResource` base, `TapTunnelAnnotation`, `TapTunnelIngress`). `CloudflaredTunnelResource` (multi-host) and `TailscaleFunnelResource` (single endpoint) both inherit `TapTunnelResource`. `TapHandle.AttachedTunnel` is the base type; `WithTap` dispatches to provider-specific attach via `is`-check on the runtime type.
+- **Tunnel abstraction** lives in `src/backend/Tap.Hosting/Tunnels/` (`TapTunnelResource` base, `TapTunnelAnnotation`, `TapTunnelIngress`). `CloudflaredTunnelResource` (multi-host) and `TailscaleFunnelResource` (single endpoint) both inherit `TapTunnelResource`. `TapHandle.AttachedTunnel` is the base type; `WithTap` dispatches to provider-specific attach via `is`-check on the runtime type.
- **`Tap.Server`** (`Microsoft.NET.Sdk.Web`) — standalone ASP.NET Core app: YARP reverse proxy + capture middleware + SSE feed + bundled React UI in `wwwroot`. Reads its config from `Inspector:*` and `Cloudflare:*` env vars set by the AppHost.
- **`ui/`** — Vite + React 19 + TypeScript source for the inspector UI. Built into `Tap.Server/wwwroot` at build time; not a separate runtime artifact.
- **`samples/Sample.AppHost`** — exercises eight scenarios in parallel: standalone, Cloudflare quick / existing-dashboard / API-managed / dynamic-hostname tunnels, and Tailscale Serve in three flavors (system daemon, ephemeral userspace process, ephemeral Docker container). Each scenario is gated on the relevant user-secrets being present (`Cloudflare:*` for CF modes; `Tailscale:UseSystem=true` for system-Tailscale; `Tailscale:AuthKey` for ephemeral; pair with `Tailscale:UseDocker=true` for the container variant). `samples/Sample.Api` is the trivial upstream. Filter providers via `--scenarios cloudflare|tailscale|all` (default all).
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d5b57e7..0047a37 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,18 +1,32 @@
true
- 13.3.0
- 10.0.7
+ true
+ 13.4.6
+ 10.0.9
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index a5512af..95a6272 100644
--- a/README.md
+++ b/README.md
@@ -466,9 +466,9 @@ Both entry points use the same `Tap.Server` host internally. The CLI builds `Tap
Consumer AppHost projects must reference both `Tap.Hosting` and `Tap.Server`. `Tap.Server` supplies the generated `Projects.Tap_Server` metadata type used by `AddTap()`; `Tap.Hosting` should be referenced with `IsAspireProjectResource="false"` because it is a library, not a launchable resource.
```xml
-
-
+
```
## Configuration
@@ -524,10 +524,10 @@ dotnet build Tap.slnx
dotnet run --project samples/Sample.AppHost
```
-UI source lives in `ui/` and is built into `src/Tap.Server/wwwroot/` during server builds:
+UI source lives in `src/ui-inspector/` and is built into `src/backend/Tap.Server/wwwroot/` during server builds:
```bash
-cd ui
+cd src/ui-inspector
yarn
yarn dev
yarn build
@@ -563,14 +563,18 @@ For the deeper technical background, see [docs/ARCHITECTURE.md](docs/ARCHITECTUR
## Layout
```text
-assets/ README logo and hero assets
-docs/ Technical documentation
-src/Tap.Core/ Shared auth and Cloudflare/cloudflared primitives
-src/Tap.Hosting/ Aspire integration and lifecycle hook
-src/Tap.Server/ Capture server, YARP proxy, SSE API, bundled UI host
-src/Tap.Cli/ CLI host for the inspector server
-ui/ Vite + React inspector source
-samples/ Sample AppHost and upstream API
+assets/ README logo and hero assets
+docs/ Technical documentation
+src/backend/Tap.Core/ Shared auth and Cloudflare/cloudflared primitives
+src/backend/Tap.Hosting/ Aspire integration and lifecycle hook
+src/backend/Tap.Server/ Capture server, YARP proxy, SSE API, bundled UI host
+src/backend/Tap.Cli/ CLI host for the inspector server
+src/backend/Tap.Studio/ Studio workbench backend (REST + SSE)
+src/backend/Tap.Workspace/ Workspace + spec model shared by Studio
+src/ui-inspector/ Vite + React inspector source
+src/ui-studio/ Vite + React Studio workbench source
+src/desktop/ Tauri desktop shell for Studio
+samples/ Sample AppHosts and upstream APIs
```
## License
diff --git a/Tap.slnx b/Tap.slnx
index 07295cc..c94faf0 100644
--- a/Tap.slnx
+++ b/Tap.slnx
@@ -1,12 +1,16 @@
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/aspire.config.json b/aspire.config.json
new file mode 100644
index 0000000..dbd2dca
--- /dev/null
+++ b/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "samples/Studio.AppHost/Studio.AppHost.csproj"
+ }
+}
\ No newline at end of file
diff --git a/assets/tap-studio-apple-touch-icon.png b/assets/tap-studio-apple-touch-icon.png
new file mode 100644
index 0000000..f8ad1d8
Binary files /dev/null and b/assets/tap-studio-apple-touch-icon.png differ
diff --git a/assets/tap-studio-hero.png b/assets/tap-studio-hero.png
new file mode 100644
index 0000000..983b7d3
Binary files /dev/null and b/assets/tap-studio-hero.png differ
diff --git a/assets/tap-studio-icon-512.png b/assets/tap-studio-icon-512.png
new file mode 100644
index 0000000..70d531b
Binary files /dev/null and b/assets/tap-studio-icon-512.png differ
diff --git a/assets/tap-studio-icon.svg b/assets/tap-studio-icon.svg
new file mode 100644
index 0000000..b7f087c
--- /dev/null
+++ b/assets/tap-studio-icon.svg
@@ -0,0 +1,24 @@
+
+ Tap Studio icon
+ A simple tap-shaped mark with a flowing request stream.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/tap-studio-architecture.excalidraw b/docs/tap-studio-architecture.excalidraw
new file mode 100644
index 0000000..882dea7
--- /dev/null
+++ b/docs/tap-studio-architecture.excalidraw
@@ -0,0 +1,1410 @@
+{
+ "type": "excalidraw",
+ "version": 2,
+ "source": "https://excalidraw.com",
+ "elements": [
+ {
+ "type": "text",
+ "id": "title_main",
+ "x": 80, "y": 30,
+ "width": 700, "height": 36,
+ "text": "Tap Studio — Architecture",
+ "originalText": "Tap Studio — Architecture",
+ "fontSize": 30, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100001, "version": 1, "versionNonce": 100002,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "title_sub",
+ "x": 80, "y": 72,
+ "width": 1000, "height": 22,
+ "text": "Runtime shells → Server → Satellites · FS storage distributed via Git · Workspace as security boundary",
+ "originalText": "Runtime shells → Server → Satellites · FS storage distributed via Git · Workspace as security boundary",
+ "fontSize": 16, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100003, "version": 1, "versionNonce": 100004,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "text",
+ "id": "sec1_title",
+ "x": 80, "y": 130,
+ "width": 400, "height": 26,
+ "text": "1 · Runtime topology",
+ "originalText": "1 · Runtime topology",
+ "fontSize": 20, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100005, "version": 1, "versionNonce": 100006,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "text",
+ "id": "shells_label",
+ "x": 80, "y": 175,
+ "width": 220, "height": 20,
+ "text": "Client shells (React UI host)",
+ "originalText": "Client shells (React UI host)",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100007, "version": 1, "versionNonce": 100008,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "desktop_box",
+ "x": 80, "y": 210, "width": 240, "height": 110,
+ "strokeColor": "#c2410c", "backgroundColor": "#fed7aa",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100009, "version": 1, "versionNonce": 100010,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "desktop_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "desktop_text",
+ "x": 100, "y": 226,
+ "width": 200, "height": 80,
+ "text": "Tauri Desktop\n\nNative shell\nembeds React UI",
+ "originalText": "Tauri Desktop\n\nNative shell\nembeds React UI",
+ "fontSize": 15, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#374151", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100011, "version": 1, "versionNonce": 100012,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "desktop_box", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "standalone_box",
+ "x": 80, "y": 350, "width": 240, "height": 110,
+ "strokeColor": "#c2410c", "backgroundColor": "#fed7aa",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100013, "version": 1, "versionNonce": 100014,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "standalone_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "standalone_text",
+ "x": 100, "y": 366,
+ "width": 200, "height": 80,
+ "text": "Standalone (local)\n\nUI + Server both on\nlocalhost (single user)",
+ "originalText": "Standalone (local)\n\nUI + Server both on\nlocalhost (single user)",
+ "fontSize": 15, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#374151", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100015, "version": 1, "versionNonce": 100016,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "standalone_box", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "web_box",
+ "x": 80, "y": 490, "width": 240, "height": 110,
+ "strokeColor": "#c2410c", "backgroundColor": "#fed7aa",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100017, "version": 1, "versionNonce": 100018,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "web_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "web_text",
+ "x": 100, "y": 506,
+ "width": 200, "height": 80,
+ "text": "Web Browser\n\nReact UI served\nfrom hosted Server",
+ "originalText": "Web Browser\n\nReact UI served\nfrom hosted Server",
+ "fontSize": 15, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#374151", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100019, "version": 1, "versionNonce": 100020,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "web_box", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "server_box",
+ "x": 540, "y": 350, "width": 280, "height": 130,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#3b82f6",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100021, "version": 1, "versionNonce": 100022,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [
+ {"id": "server_text", "type": "text"},
+ {"id": "arrow_desktop_server", "type": "arrow"},
+ {"id": "arrow_standalone_server", "type": "arrow"},
+ {"id": "arrow_web_server", "type": "arrow"},
+ {"id": "arrow_server_sat1", "type": "arrow"},
+ {"id": "arrow_server_sat2", "type": "arrow"},
+ {"id": "arrow_server_sat3", "type": "arrow"},
+ {"id": "arrow_server_storage", "type": "arrow"}
+ ],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "server_text",
+ "x": 560, "y": 378,
+ "width": 240, "height": 80,
+ "text": "Tap Server\n\nASP.NET WebApp\nREST API + SSE",
+ "originalText": "Tap Server\n\nASP.NET WebApp\nREST API + SSE",
+ "fontSize": 17, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#ffffff", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100023, "version": 1, "versionNonce": 100024,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "server_box", "lineHeight": 1.25
+ },
+
+ {
+ "type": "arrow",
+ "id": "arrow_desktop_server",
+ "x": 320, "y": 265, "width": 220, "height": 145,
+ "strokeColor": "#c2410c", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100025, "version": 1, "versionNonce": 100026,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [110, 0], [110, 145], [220, 145]],
+ "startBinding": {"elementId": "desktop_box", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "server_box", "focus": -0.3, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+ {
+ "type": "arrow",
+ "id": "arrow_standalone_server",
+ "x": 320, "y": 405, "width": 220, "height": 10,
+ "strokeColor": "#c2410c", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100027, "version": 1, "versionNonce": 100028,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [220, 10]],
+ "startBinding": {"elementId": "standalone_box", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "server_box", "focus": 0, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+ {
+ "type": "arrow",
+ "id": "arrow_web_server",
+ "x": 320, "y": 545, "width": 220, "height": -130,
+ "strokeColor": "#c2410c", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100029, "version": 1, "versionNonce": 100030,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [110, 0], [110, -130], [220, -130]],
+ "startBinding": {"elementId": "web_box", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "server_box", "focus": 0.3, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+
+ {
+ "type": "text",
+ "id": "rest_label",
+ "x": 360, "y": 320,
+ "width": 180, "height": 22,
+ "text": "REST / HTTP",
+ "originalText": "REST / HTTP",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#3b82f6", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100031, "version": 1, "versionNonce": 100032,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "sat1_box",
+ "x": 1080, "y": 220, "width": 220, "height": 90,
+ "strokeColor": "#047857", "backgroundColor": "#a7f3d0",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100033, "version": 1, "versionNonce": 100034,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "sat1_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "sat1_text",
+ "x": 1100, "y": 236,
+ "width": 180, "height": 60,
+ "text": "Satellite A\n.NET · private network",
+ "originalText": "Satellite A\n.NET · private network",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#047857", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100035, "version": 1, "versionNonce": 100036,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "sat1_box", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "sat2_box",
+ "x": 1080, "y": 370, "width": 220, "height": 90,
+ "strokeColor": "#047857", "backgroundColor": "#a7f3d0",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100037, "version": 1, "versionNonce": 100038,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "sat2_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "sat2_text",
+ "x": 1100, "y": 386,
+ "width": 180, "height": 60,
+ "text": "Satellite B\n.NET · private network",
+ "originalText": "Satellite B\n.NET · private network",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#047857", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100039, "version": 1, "versionNonce": 100040,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "sat2_box", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "sat3_box",
+ "x": 1080, "y": 520, "width": 220, "height": 90,
+ "strokeColor": "#047857", "backgroundColor": "#a7f3d0",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100041, "version": 1, "versionNonce": 100042,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "sat3_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "sat3_text",
+ "x": 1100, "y": 536,
+ "width": 180, "height": 60,
+ "text": "Satellite C\n.NET · private network",
+ "originalText": "Satellite C\n.NET · private network",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#047857", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100043, "version": 1, "versionNonce": 100044,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "sat3_box", "lineHeight": 1.25
+ },
+
+ {
+ "type": "arrow",
+ "id": "arrow_server_sat1",
+ "x": 820, "y": 380, "width": 260, "height": -115,
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100045, "version": 1, "versionNonce": 100046,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [130, 0], [130, -115], [260, -115]],
+ "startBinding": {"elementId": "server_box", "focus": -0.3, "gap": 2},
+ "endBinding": {"elementId": "sat1_box", "focus": 0, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+ {
+ "type": "arrow",
+ "id": "arrow_server_sat2",
+ "x": 820, "y": 415, "width": 260, "height": 0,
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100047, "version": 1, "versionNonce": 100048,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [260, 0]],
+ "startBinding": {"elementId": "server_box", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "sat2_box", "focus": 0, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+ {
+ "type": "arrow",
+ "id": "arrow_server_sat3",
+ "x": 820, "y": 450, "width": 260, "height": 115,
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100049, "version": 1, "versionNonce": 100050,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [130, 0], [130, 115], [260, 115]],
+ "startBinding": {"elementId": "server_box", "focus": 0.3, "gap": 2},
+ "endBinding": {"elementId": "sat3_box", "focus": 0, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+ {
+ "type": "text",
+ "id": "grpc_label",
+ "x": 860, "y": 320,
+ "width": 220, "height": 22,
+ "text": "gRPC (primary)",
+ "originalText": "gRPC (primary)",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#047857", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100051, "version": 1, "versionNonce": 100052,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "sse_label",
+ "x": 860, "y": 470,
+ "width": 220, "height": 22,
+ "text": "SSE (fallback)",
+ "originalText": "SSE (fallback)",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100053, "version": 1, "versionNonce": 100054,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "text",
+ "id": "sat_purpose",
+ "x": 1080, "y": 175,
+ "width": 240, "height": 20,
+ "text": "Cross-network reach",
+ "originalText": "Cross-network reach",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 100055, "version": 1, "versionNonce": 100056,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "line",
+ "id": "sec_divider_1",
+ "x": 80, "y": 670, "width": 1300, "height": 0,
+ "strokeColor": "#cbd5e1", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200001, "version": 1, "versionNonce": 200002,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [1300, 0]]
+ },
+ {
+ "type": "text",
+ "id": "sec2_title",
+ "x": 80, "y": 700,
+ "width": 600, "height": 26,
+ "text": "2 · Storage — FS only, Git-distributed",
+ "originalText": "2 · Storage — FS only, Git-distributed",
+ "fontSize": 20, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200003, "version": 1, "versionNonce": 200004,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "sec2_sub",
+ "x": 80, "y": 730,
+ "width": 800, "height": 22,
+ "text": "No database. Local indexing may layer on later for richer search.",
+ "originalText": "No database. Local indexing may layer on later for richer search.",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200005, "version": 1, "versionNonce": 200006,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "store_local",
+ "x": 80, "y": 780, "width": 280, "height": 130,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#93c5fd",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200007, "version": 1, "versionNonce": 200008,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "store_local_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "store_local_text",
+ "x": 100, "y": 800,
+ "width": 240, "height": 90,
+ "text": "Local FS\n\nStandalone / Desktop\nUser's filesystem",
+ "originalText": "Local FS\n\nStandalone / Desktop\nUser's filesystem",
+ "fontSize": 15, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200009, "version": 1, "versionNonce": 200010,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "store_local", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "store_cloud",
+ "x": 400, "y": 780, "width": 280, "height": 130,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#93c5fd",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200011, "version": 1, "versionNonce": 200012,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "store_cloud_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "store_cloud_text",
+ "x": 420, "y": 800,
+ "width": 240, "height": 90,
+ "text": "Cloud / Mounted FS\n\nHosted server uses\nAzure Files · S3 · Docker volumes",
+ "originalText": "Cloud / Mounted FS\n\nHosted server uses\nAzure Files · S3 · Docker volumes",
+ "fontSize": 15, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200013, "version": 1, "versionNonce": 200014,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "store_cloud", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "store_git",
+ "x": 720, "y": 780, "width": 280, "height": 130,
+ "strokeColor": "#6d28d9", "backgroundColor": "#ddd6fe",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200015, "version": 1, "versionNonce": 200016,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "store_git_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "store_git_text",
+ "x": 740, "y": 800,
+ "width": 240, "height": 90,
+ "text": "Git distribution\n\nShare workspaces centrally\nnative GitHub integration",
+ "originalText": "Git distribution\n\nShare workspaces centrally\nnative GitHub integration",
+ "fontSize": 15, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#6d28d9", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200017, "version": 1, "versionNonce": 200018,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "store_git", "lineHeight": 1.25
+ },
+
+ {
+ "type": "arrow",
+ "id": "arrow_local_git",
+ "x": 360, "y": 845, "width": 40, "height": 0,
+ "strokeColor": "#6d28d9", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200019, "version": 1, "versionNonce": 200020,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [40, 0]],
+ "startBinding": {"elementId": "store_local", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "store_cloud", "focus": 0, "gap": 2},
+ "startArrowhead": "arrow", "endArrowhead": "arrow"
+ },
+ {
+ "type": "arrow",
+ "id": "arrow_cloud_git",
+ "x": 680, "y": 845, "width": 40, "height": 0,
+ "strokeColor": "#6d28d9", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200021, "version": 1, "versionNonce": 200022,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [40, 0]],
+ "startBinding": {"elementId": "store_cloud", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "store_git", "focus": 0, "gap": 2},
+ "startArrowhead": "arrow", "endArrowhead": "arrow"
+ },
+ {
+ "type": "text",
+ "id": "git_sync_label",
+ "x": 360, "y": 925,
+ "width": 400, "height": 20,
+ "text": "git push / pull — same FS layout everywhere",
+ "originalText": "git push / pull — same FS layout everywhere",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "top",
+ "strokeColor": "#6d28d9", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200023, "version": 1, "versionNonce": 200024,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "arrow",
+ "id": "arrow_server_storage",
+ "x": 680, "y": 480, "width": -460, "height": 300,
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "dotted",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200025, "version": 1, "versionNonce": 200026,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [0, 150], [-460, 150], [-460, 300]],
+ "startBinding": {"elementId": "server_box", "focus": 0.5, "gap": 2},
+ "endBinding": {"elementId": "store_cloud", "focus": 0, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+ {
+ "type": "text",
+ "id": "server_reads_label",
+ "x": 700, "y": 600,
+ "width": 240, "height": 20,
+ "text": "Server reads/writes FS",
+ "originalText": "Server reads/writes FS",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 200027, "version": 1, "versionNonce": 200028,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "line",
+ "id": "sec_divider_2",
+ "x": 80, "y": 1000, "width": 1300, "height": 0,
+ "strokeColor": "#cbd5e1", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300001, "version": 1, "versionNonce": 300002,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [1300, 0]]
+ },
+ {
+ "type": "text",
+ "id": "sec3_title",
+ "x": 80, "y": 1030,
+ "width": 600, "height": 26,
+ "text": "3 · Workspace structure",
+ "originalText": "3 · Workspace structure",
+ "fontSize": 20, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300003, "version": 1, "versionNonce": 300004,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "sec3_sub",
+ "x": 80, "y": 1060,
+ "width": 700, "height": 22,
+ "text": "Root folder = security boundary. May live inside a code repo as /tap.",
+ "originalText": "Root folder = security boundary. May live inside a code repo as /tap.",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300005, "version": 1, "versionNonce": 300006,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "ws_root",
+ "x": 80, "y": 1110, "width": 320, "height": 80,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#3b82f6",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300007, "version": 1, "versionNonce": 300008,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "ws_root_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "ws_root_text",
+ "x": 100, "y": 1125,
+ "width": 280, "height": 50,
+ "text": "/workspace/\nsecurity boundary",
+ "originalText": "/workspace/\nsecurity boundary",
+ "fontSize": 17, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#ffffff", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300009, "version": 1, "versionNonce": 300010,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "ws_root", "lineHeight": 1.25
+ },
+
+ {
+ "type": "line",
+ "id": "tree_trunk",
+ "x": 130, "y": 1210, "width": 0, "height": 290,
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300011, "version": 1, "versionNonce": 300012,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [0, 290]]
+ },
+
+ {
+ "type": "line",
+ "id": "branch_apis",
+ "x": 130, "y": 1240, "width": 30, "height": 0,
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300013, "version": 1, "versionNonce": 300014,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [30, 0]]
+ },
+ {
+ "type": "text",
+ "id": "node_apis",
+ "x": 170, "y": 1226,
+ "width": 360, "height": 28,
+ "text": "apis/ — folders allowed",
+ "originalText": "apis/ — folders allowed",
+ "fontSize": 17, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300015, "version": 1, "versionNonce": 300016,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "line",
+ "id": "sub_apis_trunk",
+ "x": 180, "y": 1260, "width": 0, "height": 90,
+ "strokeColor": "#94a3b8", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300017, "version": 1, "versionNonce": 300018,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [0, 90]]
+ },
+ {
+ "type": "line",
+ "id": "sub_api_branch",
+ "x": 180, "y": 1290, "width": 30, "height": 0,
+ "strokeColor": "#94a3b8", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300019, "version": 1, "versionNonce": 300020,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [30, 0]]
+ },
+ {
+ "type": "text",
+ "id": "node_api",
+ "x": 220, "y": 1276,
+ "width": 360, "height": 24,
+ "text": "api.json — base data (one Api)",
+ "originalText": "api.json — base data (one Api)",
+ "fontSize": 15, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#3b82f6", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300021, "version": 1, "versionNonce": 300022,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "line",
+ "id": "sub_requests_branch",
+ "x": 180, "y": 1340, "width": 30, "height": 0,
+ "strokeColor": "#94a3b8", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300023, "version": 1, "versionNonce": 300024,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [30, 0]]
+ },
+ {
+ "type": "text",
+ "id": "node_requests",
+ "x": 220, "y": 1326,
+ "width": 420, "height": 24,
+ "text": "requests/ — HTTP specs · folders allowed",
+ "originalText": "requests/ — HTTP specs · folders allowed",
+ "fontSize": 15, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#3b82f6", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300025, "version": 1, "versionNonce": 300026,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "line",
+ "id": "branch_auth",
+ "x": 130, "y": 1400, "width": 30, "height": 0,
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300027, "version": 1, "versionNonce": 300028,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [30, 0]]
+ },
+ {
+ "type": "text",
+ "id": "node_auth",
+ "x": 170, "y": 1386,
+ "width": 420, "height": 28,
+ "text": "auth/ — profiles (flat, no folders)",
+ "originalText": "auth/ — profiles (flat, no folders)",
+ "fontSize": 17, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300029, "version": 1, "versionNonce": 300030,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "line",
+ "id": "branch_envs",
+ "x": 130, "y": 1480, "width": 30, "height": 0,
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300031, "version": 1, "versionNonce": 300032,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [30, 0]]
+ },
+ {
+ "type": "text",
+ "id": "node_envs",
+ "x": 170, "y": 1466,
+ "width": 460, "height": 28,
+ "text": "environments/ — KV variable stores",
+ "originalText": "environments/ — KV variable stores",
+ "fontSize": 17, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300033, "version": 1, "versionNonce": 300034,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "repo_callout",
+ "x": 80, "y": 1540, "width": 600, "height": 80,
+ "strokeColor": "#6d28d9", "backgroundColor": "#ddd6fe",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300035, "version": 1, "versionNonce": 300036,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "repo_callout_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "repo_callout_text",
+ "x": 100, "y": 1555,
+ "width": 560, "height": 50,
+ "text": "Code repo · /tap/ ← workspace lives here\nWorkspace need not be the git root — it can be any subfolder.",
+ "originalText": "Code repo · /tap/ ← workspace lives here\nWorkspace need not be the git root — it can be any subfolder.",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#6d28d9", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 300037, "version": 1, "versionNonce": 300038,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "repo_callout", "lineHeight": 1.25
+ },
+
+ {
+ "type": "text",
+ "id": "sec4_title",
+ "x": 780, "y": 1030,
+ "width": 400, "height": 26,
+ "text": "4 · Variable scoping",
+ "originalText": "4 · Variable scoping",
+ "fontSize": 20, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400001, "version": 1, "versionNonce": 400002,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "sec4_sub",
+ "x": 780, "y": 1060,
+ "width": 500, "height": 22,
+ "text": "More specific overrides less specific.",
+ "originalText": "More specific overrides less specific.",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400003, "version": 1, "versionNonce": 400004,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "var_global",
+ "x": 780, "y": 1110, "width": 340, "height": 58,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#dbeafe",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400005, "version": 1, "versionNonce": 400006,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [
+ {"id": "var_global_text", "type": "text"},
+ {"id": "arrow_var_g_w", "type": "arrow"}
+ ],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "var_global_text",
+ "x": 800, "y": 1124,
+ "width": 300, "height": 30,
+ "text": "Global — server $HOME",
+ "originalText": "Global — server $HOME",
+ "fontSize": 16, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400007, "version": 1, "versionNonce": 400008,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "var_global", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "var_workspace",
+ "x": 790, "y": 1200, "width": 330, "height": 58,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#93c5fd",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400009, "version": 1, "versionNonce": 400010,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [
+ {"id": "var_workspace_text", "type": "text"},
+ {"id": "arrow_var_g_w", "type": "arrow"},
+ {"id": "arrow_var_w_a", "type": "arrow"}
+ ],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "var_workspace_text",
+ "x": 810, "y": 1214,
+ "width": 290, "height": 30,
+ "text": "Workspace",
+ "originalText": "Workspace",
+ "fontSize": 16, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400011, "version": 1, "versionNonce": 400012,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "var_workspace", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "var_api",
+ "x": 800, "y": 1290, "width": 320, "height": 58,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#60a5fa",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400013, "version": 1, "versionNonce": 400014,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [
+ {"id": "var_api_text", "type": "text"},
+ {"id": "arrow_var_w_a", "type": "arrow"},
+ {"id": "arrow_var_a_s", "type": "arrow"}
+ ],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "var_api_text",
+ "x": 820, "y": 1304,
+ "width": 280, "height": 30,
+ "text": "Api",
+ "originalText": "Api",
+ "fontSize": 16, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#ffffff", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400015, "version": 1, "versionNonce": 400016,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "var_api", "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "var_stage",
+ "x": 810, "y": 1380, "width": 310, "height": 64,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#3b82f6",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400017, "version": 1, "versionNonce": 400018,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [
+ {"id": "var_stage_text", "type": "text"},
+ {"id": "arrow_var_a_s", "type": "arrow"}
+ ],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "var_stage_text",
+ "x": 830, "y": 1394,
+ "width": 270, "height": 36,
+ "text": "Api Stage ← wins",
+ "originalText": "Api Stage ← wins",
+ "fontSize": 17, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#ffffff", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400019, "version": 1, "versionNonce": 400020,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "var_stage", "lineHeight": 1.25
+ },
+
+ {
+ "type": "arrow",
+ "id": "arrow_var_g_w",
+ "x": 955, "y": 1170, "width": 0, "height": 28,
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400021, "version": 1, "versionNonce": 400022,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [0, 28]],
+ "startBinding": {"elementId": "var_global", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "var_workspace", "focus": 0, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+ {
+ "type": "arrow",
+ "id": "arrow_var_w_a",
+ "x": 955, "y": 1260, "width": 0, "height": 28,
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400023, "version": 1, "versionNonce": 400024,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [0, 28]],
+ "startBinding": {"elementId": "var_workspace", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "var_api", "focus": 0, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+ {
+ "type": "arrow",
+ "id": "arrow_var_a_s",
+ "x": 955, "y": 1350, "width": 0, "height": 28,
+ "strokeColor": "#1e3a5f", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400025, "version": 1, "versionNonce": 400026,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false,
+ "points": [[0, 0], [0, 28]],
+ "startBinding": {"elementId": "var_api", "focus": 0, "gap": 2},
+ "endBinding": {"elementId": "var_stage", "focus": 0, "gap": 2},
+ "startArrowhead": null, "endArrowhead": "arrow"
+ },
+
+ {
+ "type": "text",
+ "id": "var_axis_label",
+ "x": 1150, "y": 1110,
+ "width": 200, "height": 22,
+ "text": "less specific",
+ "originalText": "less specific",
+ "fontSize": 12, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#94a3b8", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400027, "version": 1, "versionNonce": 400028,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "var_axis_label2",
+ "x": 1150, "y": 1410,
+ "width": 200, "height": 22,
+ "text": "more specific",
+ "originalText": "more specific",
+ "fontSize": 12, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400029, "version": 1, "versionNonce": 400030,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "rectangle",
+ "id": "var_consumers",
+ "x": 780, "y": 1480, "width": 340, "height": 70,
+ "strokeColor": "#64748b", "backgroundColor": "#f1f5f9",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "dashed",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400031, "version": 1, "versionNonce": 400032,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "var_consumers_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "var_consumers_text",
+ "x": 800, "y": 1495,
+ "width": 300, "height": 40,
+ "text": "consumed by\nRequests · Auth profiles",
+ "originalText": "consumed by\nRequests · Auth profiles",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 400033, "version": 1, "versionNonce": 400034,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "var_consumers", "lineHeight": 1.25
+ },
+
+ {
+ "type": "text",
+ "id": "sec5_title",
+ "x": 1180, "y": 1030,
+ "width": 400, "height": 26,
+ "text": "5 · App header & navigation",
+ "originalText": "5 · App header & navigation",
+ "fontSize": 20, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500001, "version": 1, "versionNonce": 500002,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "sec5_sub",
+ "x": 1180, "y": 1060,
+ "width": 400, "height": 22,
+ "text": "Workspace switcher · LEFT · Environment switcher · RIGHT",
+ "originalText": "Workspace switcher · LEFT · Environment switcher · RIGHT",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500003, "version": 1, "versionNonce": 500004,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "nav_topbar",
+ "x": 1180, "y": 1110, "width": 380, "height": 58,
+ "strokeColor": "#cbd5e1", "backgroundColor": "#f8fafc",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500005, "version": 1, "versionNonce": 500006,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": null,
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "rectangle",
+ "id": "nav_ws_switch",
+ "x": 1192, "y": 1124, "width": 130, "height": 30,
+ "strokeColor": "#1e3a5f", "backgroundColor": "#3b82f6",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500007, "version": 1, "versionNonce": 500008,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "nav_ws_switch_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "nav_ws_switch_text",
+ "x": 1202, "y": 1130,
+ "width": 110, "height": 20,
+ "text": "▾ billing-tap",
+ "originalText": "▾ billing-tap",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#ffffff", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500009, "version": 1, "versionNonce": 500010,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "nav_ws_switch", "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "nav_brand_text",
+ "x": 1335, "y": 1130,
+ "width": 90, "height": 20,
+ "text": "Tap Studio",
+ "originalText": "Tap Studio",
+ "fontSize": 14, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#1e40af", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500011, "version": 1, "versionNonce": 500012,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+ {
+ "type": "rectangle",
+ "id": "nav_env_switch",
+ "x": 1430, "y": 1124, "width": 120, "height": 30,
+ "strokeColor": "#047857", "backgroundColor": "#a7f3d0",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500013, "version": 1, "versionNonce": 500014,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "nav_env_switch_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "nav_env_switch_text",
+ "x": 1440, "y": 1130,
+ "width": 100, "height": 20,
+ "text": "▾ env: prod",
+ "originalText": "▾ env: prod",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "middle",
+ "strokeColor": "#047857", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500015, "version": 1, "versionNonce": 500016,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "nav_env_switch", "lineHeight": 1.25
+ },
+ {
+ "type": "text",
+ "id": "nav_header_caption",
+ "x": 1180, "y": 1178,
+ "width": 380, "height": 18,
+ "text": "App header — workspace LEFT, brand center, env RIGHT",
+ "originalText": "App header — workspace LEFT, brand center, env RIGHT",
+ "fontSize": 11, "fontFamily": 3,
+ "textAlign": "center", "verticalAlign": "top",
+ "strokeColor": "#94a3b8", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500017, "version": 1, "versionNonce": 500018,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ },
+
+ {
+ "type": "rectangle",
+ "id": "nav_panel_api",
+ "x": 1180, "y": 1210, "width": 380, "height": 220,
+ "strokeColor": "#cbd5e1", "backgroundColor": "#f8fafc",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500015, "version": 1, "versionNonce": 500016,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "nav_panel_api_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "nav_panel_api_text",
+ "x": 1200, "y": 1225,
+ "width": 340, "height": 190,
+ "text": "API view (sidebar filter chip)\n\n▾ Billing API\n ▾ folder: Customers\n • GET /customers\n • POST /customers\n • GET /invoices\n▸ Search API\n▸ Internal API",
+ "originalText": "API view (sidebar filter chip)\n\n▾ Billing API\n ▾ folder: Customers\n • GET /customers\n • POST /customers\n • GET /invoices\n▸ Search API\n▸ Internal API",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e293b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500017, "version": 1, "versionNonce": 500018,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "nav_panel_api", "lineHeight": 1.45
+ },
+
+ {
+ "type": "rectangle",
+ "id": "nav_panel_auth",
+ "x": 1180, "y": 1450, "width": 380, "height": 140,
+ "strokeColor": "#cbd5e1", "backgroundColor": "#f8fafc",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500019, "version": 1, "versionNonce": 500020,
+ "isDeleted": false, "groupIds": [],
+ "boundElements": [{"id": "nav_panel_auth_text", "type": "text"}],
+ "link": null, "locked": false, "roundness": {"type": 3}
+ },
+ {
+ "type": "text",
+ "id": "nav_panel_auth_text",
+ "x": 1200, "y": 1465,
+ "width": 340, "height": 110,
+ "text": "Auth view (flat — no folders)\n\n• oauth-prod\n• oauth-staging\n• basic-internal\n• api-key-readonly",
+ "originalText": "Auth view (flat — no folders)\n\n• oauth-prod\n• oauth-staging\n• basic-internal\n• api-key-readonly",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#1e293b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500021, "version": 1, "versionNonce": 500022,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": "nav_panel_auth", "lineHeight": 1.45
+ },
+
+ {
+ "type": "text",
+ "id": "nav_hide_label",
+ "x": 1180, "y": 1600,
+ "width": 380, "height": 36,
+ "text": "Tabs / tree / titles show item NAME only.\nSource tab is the one place that shows the filename.",
+ "originalText": "Tabs / tree / titles show item NAME only.\nSource tab is the one place that shows the filename.",
+ "fontSize": 13, "fontFamily": 3,
+ "textAlign": "left", "verticalAlign": "top",
+ "strokeColor": "#64748b", "backgroundColor": "transparent",
+ "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid",
+ "roughness": 0, "opacity": 100, "angle": 0,
+ "seed": 500023, "version": 1, "versionNonce": 500024,
+ "isDeleted": false, "groupIds": [], "boundElements": null,
+ "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
+ }
+ ],
+ "appState": {
+ "viewBackgroundColor": "#ffffff",
+ "gridSize": 20
+ },
+ "files": {}
+}
diff --git a/docs/tap-studio-optimization-plan.md b/docs/tap-studio-optimization-plan.md
new file mode 100644
index 0000000..e558697
--- /dev/null
+++ b/docs/tap-studio-optimization-plan.md
@@ -0,0 +1,289 @@
+# Tap Studio Optimization Plan
+
+## Review Scope
+
+Reviewed the new Tap Studio feature across:
+
+- Backend: `src/backend/Tap.Studio`, `src/backend/Tap.Workspace`, new AppHost/sample wiring, and changed Tap hosting/server code.
+- UI: `src/ui-studio/src`, Studio API client/types, editor shell patterns, execution/streaming panels, variable tooling.
+- Security-sensitive areas: filesystem writes, workspace switching, token storage, OAuth callback, request execution, server binding, external CLI calls, SSRF surfaces.
+
+Verification attempted:
+
+- `dotnet build Tap.slnx -p:SkipTapUiBuild=true` currently fails because Aspire SDK/package versions are inconsistent.
+- `yarn --cwd src/ui-studio build` currently fails on a CodeMirror `StreamParser` typing mismatch in `src/ui-studio/src/editors/CodeBlock.tsx`.
+
+## Highest Priority Findings
+
+### P0: Restore Build Consistency
+
+The solution is not buildable as-is.
+
+- `samples/Sample.AppHost/Sample.AppHost.csproj` uses ` `.
+- `samples/Studio.AppHost/Studio.AppHost.csproj` uses ``.
+- `Directory.Packages.props` defines `AspireVersion` as `13.3.4`, pins `Aspire.Hosting.JavaScript` to `13.4.2`, and no longer defines `Aspire.Hosting.AppHost`.
+
+Plan:
+
+- Pick one Aspire version for the repo and apply it consistently to SDK declarations and central package versions.
+- Restore `Aspire.Hosting.AppHost` in central package management if AppHost projects reference it.
+- Prefer one AppHost csproj pattern across `Sample.AppHost` and `Studio.AppHost`.
+
+Acceptance:
+
+- `dotnet restore Tap.slnx` passes.
+- `dotnet build Tap.slnx -p:SkipTapUiBuild=true` passes with zero warnings.
+
+### P0: Fix Studio UI Typecheck
+
+`yarn --cwd src/ui-studio build` fails in `src/ui-studio/src/editors/CodeBlock.tsx` because the custom HTTP `StreamLanguage` parser narrows `StringStream.next()` to `string | undefined`, while CodeMirror's type returns `string | void`.
+
+Plan:
+
+- Type the parser against CodeMirror's actual `StringStream` contract or remove the handwritten stream type.
+- Add a tiny compile-only guard for the HTTP mode helper if practical.
+
+Acceptance:
+
+- `yarn --cwd src/ui-studio build` passes.
+
+### P0: Bind Studio Locally by Default
+
+`StudioHost.Build` calls `k.ListenAnyIP(options.Port)` when `ASPNETCORE_URLS` is unset, while `StudioOptions.Host` defaults to `localhost` but is not used for binding. This exposes a local developer tool that can execute arbitrary workspace requests, read workspace source, return tokens to the UI, and mutate files.
+
+Plan:
+
+- Bind to loopback by default, using `options.Host` explicitly.
+- Require an explicit opt-in for non-loopback binding.
+- Add a startup warning when listening on anything other than localhost/loopback.
+- Consider a local bearer token or origin guard for browser-facing API calls.
+
+Acceptance:
+
+- Default Studio startup only listens on loopback.
+- Non-loopback mode is explicit and documented.
+
+### P0: Centralize Workspace Path Validation
+
+Most folder/move endpoints use `TryResolveWorkspacePath`, but raw source saves call `WorkspaceService.Save(path, content)`, which combines `RootDirectory/.tap` with the caller-provided path without canonical containment checks. `/api/workspace/source` accepts this path from the client.
+
+Plan:
+
+- Move the workspace path resolver into `WorkspaceService` or a reusable `WorkspacePathResolver`.
+- Use it for every read/write/delete/move path, including `ReadSource`, `Save`, collection directory writes, and folder deletes.
+- Reject absolute paths, empty segments, `.`, `..`, and paths that canonicalize outside `.tap`.
+- Keep collection slug validation as a narrower guard, but still route writes through the shared resolver.
+
+Acceptance:
+
+- All filesystem mutations and raw source reads go through one resolver.
+- Unit tests cover traversal attempts such as `../x.req.md`, `a/../../x.req.md`, absolute paths, Windows separators, and encoded/normalized variants.
+
+### P1: Harden OAuth Callback Output
+
+`AuthFlowEndpoints` HTML-encodes the visible message, but interpolates `state` directly into JavaScript and uses `postMessage(..., "*")`.
+
+Plan:
+
+- JavaScript-encode serialized callback payloads with `JsonSerializer`, not string interpolation.
+- Restrict `postMessage` target origin to the resolved Studio origin where possible.
+- Add `Referrer-Policy: no-referrer` and a narrow `Content-Security-Policy` for the callback page.
+
+Acceptance:
+
+- Callback page has no raw query string values inside script.
+- Browser callback flow still completes.
+
+### P1: Treat Tokens and Secrets as First-Class Security Data
+
+`AuthTokenStore` persists OAuth/access/refresh tokens in JSON under the system directory. System and workspace variables have masking rules, but token persistence, file permissions, and redaction are spread across several places.
+
+Plan:
+
+- Introduce a `SecretStore` abstraction for auth tokens and system secrets.
+- Set restrictive file permissions on Unix and avoid inherited broad ACLs where possible.
+- Evaluate OS credential store integration as the preferred backend, with JSON as a fallback.
+- Create one redaction helper used by API DTO mapping, logs, errors, and variable traces.
+
+Acceptance:
+
+- Token files are user-private.
+- No API response exposes secret values unless the endpoint is explicitly an auth execution result.
+- Logs and error paths do not include bearer tokens, refresh tokens, private keys, or client secrets.
+
+## Architecture Improvements
+
+### 1. Separate Studio Host, Domain Services, and Endpoint Adapters
+
+Current endpoint files are doing orchestration, DTO mapping, validation, execution, and response shaping. Keep minimal APIs, but push repeated behavior down into services.
+
+Create:
+
+- `WorkspaceFileService`: load/read/write/move/delete source files safely.
+- `RequestExecutionService`: render and execute HTTP/WebSocket requests.
+- `GraphQLIntrospectionService`: use the shared execution plumbing with GraphQL-specific request shaping.
+- `AuthExecutionService`: keep auth profile execution separate from endpoint DTOs.
+- `StudioDtoMapper`: shared conversion for variable traces, workspace errors, summaries, and secret masking.
+
+Acceptance:
+
+- Endpoint classes mostly contain route registration and thin calls to services.
+- Shared execution behavior no longer differs between `/api/execute`, `/api/execute/stream`, and `/api/graphql/schema`.
+
+### 2. Consolidate HTTP Execution Plumbing
+
+`ExecuteEndpoint` and `ExecuteStreamEndpoint` duplicate body caps, content header classification, response decoding, request construction, variable trace mapping, and response header collection.
+
+Plan:
+
+- Create a shared `RenderedHttpRequestFactory`.
+- Create a shared `ResponseBodyCapture` utility with one body cap policy.
+- Create a shared `ContentTypeClassifier`.
+- Keep streaming-specific SSE pumping separate, but reuse request construction and final metadata mapping.
+
+Acceptance:
+
+- `IsContentHeader`, `TryDecodeBody`, `BodyCap`, and request header transfer rules exist in one place.
+- Unit tests cover binary, image, text, large/truncated, SSE, redirect, and content-header cases.
+
+### 3. Generate or Validate Client Contracts
+
+`src/backend/Tap.Studio/Contracts/Dtos.cs` and `src/ui-studio/src/api/types.ts` are manually kept in lockstep.
+
+Plan:
+
+- Add an OpenAPI or source-generated TypeScript contract step for Studio DTOs.
+- If full generation is too much initially, add a contract snapshot test that compares C# JSON shape against TypeScript-facing fixtures.
+- Keep source-generated `JsonSerializerContext`; generation should consume those DTOs rather than introduce reflection serialization.
+
+Acceptance:
+
+- Changing a DTO breaks CI unless the UI contract is updated.
+- Manual duplicate type drift is reduced or eliminated.
+
+## UI Reuse Improvements
+
+### 1. Extract the Spec Editor Lifecycle
+
+`RequestEditor`, `AuthEditor`, `EnvEditor`, `WorkspaceEditor`, and `CollectionEditor` repeat:
+
+- fetch detail on `generation`
+- derive `spec`
+- store `savedSpec`
+- compute dirty with `JSON.stringify`
+- save, discard, error, saving state
+
+Plan:
+
+- Introduce `useSpecEditor()`.
+- Include stable deep equality, reload integration, parse error mapping, and discard handling.
+- Keep editor-specific render code in each component.
+
+Acceptance:
+
+- Each editor owns only kind-specific fields and layout.
+- Save/discard/error behavior is consistent across all editors.
+
+### 2. Extract Variable Row Conversion
+
+Variable tables repeatedly convert between `Record` plus `secrets: string[]` and `KvRow[]`.
+
+Plan:
+
+- Add helpers like `varSpecsToRows`, `rowsToVarSpecPatch`, and `splitVarSpecs`.
+- Use them in request, collection, environment, workspace, and stage editors.
+
+Acceptance:
+
+- Secret flag behavior is identical across all variable tables.
+- One test suite covers empty rows, duplicate keys, secret flags, and stable ordering.
+
+### 3. Consolidate Execution State Handling
+
+`RequestEditor` assembles streaming execution snapshots locally from `meta`, `body`, `sse`, `ws`, `done`, and `error` events.
+
+Plan:
+
+- Extract a reducer such as `executionStreamReducer`.
+- Keep UI-specific state like active tabs in the component.
+- Reuse the reducer for future CLI/history/replay views.
+
+Acceptance:
+
+- Stream event behavior is testable without React.
+- SSE and WebSocket frame accumulation has one implementation.
+
+## Security Hardening Plan
+
+### 1. Add Local API Protection
+
+Studio is powerful enough to need browser and network hardening even as a local tool.
+
+Plan:
+
+- Loopback bind by default.
+- Add allowed-origin checks for mutating endpoints.
+- Consider a generated per-run local token for UI-to-API calls.
+- Ensure Vite dev proxy forwards the token during local development if token protection is enabled.
+
+### 2. Add Request Execution Guardrails
+
+Request execution is an intentional API-client feature, but the attack surface should be explicit.
+
+Plan:
+
+- Allow only `http`, `https`, `ws`, and `wss`.
+- Consider optional host allow/deny policy for metadata IPs, loopback, private ranges, and link-local addresses.
+- Cap redirects and consider stripping sensitive headers on cross-host redirects.
+- Make timeout/body/stream caps configurable but bounded by defaults.
+
+### 3. Add Filesystem Safety Tests
+
+Plan:
+
+- Add tests for workspace loading, path resolution, source save/read, move/delete, and collection slug writes.
+- Include platform-specific path cases for Windows separators and case sensitivity.
+
+## Consistency Plan
+
+### Package and Project Consistency
+
+- Keep package versions in `Directory.Packages.props`.
+- Use one Aspire SDK declaration pattern.
+- Keep AppHost project references consistent with Aspire source-generator requirements.
+- Decide whether `samples/aspire.config.json` should target `Sample.AppHost` or `Studio.AppHost`; if both are useful, document the root-level `aspire.config.json` versus `samples/aspire.config.json` split.
+
+### Backend Style Consistency
+
+- Keep all JSON DTOs in `Contracts/Dtos.cs` or split by bounded context once generation is in place.
+- Every new serialized DTO must be added to `StudioJson`.
+- Use one endpoint error shape for workspace parse errors, validation errors, and execution transport errors.
+- Prefer dependency-injected `HttpClient` instances over static clients so policies are centrally configured.
+
+### UI Style Consistency
+
+- Keep Mantine form patterns in shared editor primitives where possible.
+- Move common labels, method options, body modes, auth type metadata, and header suggestions into shared modules.
+- Avoid per-editor copies of dirty/save/source behavior.
+
+## Proposed Sequence
+
+1. Stabilize build and typecheck.
+2. Add workspace path resolver and tests.
+3. Change Studio default binding to loopback and add startup warnings.
+4. Consolidate HTTP execution helpers.
+5. Extract `useSpecEditor` and variable row helpers.
+6. Add DTO contract generation or snapshot validation.
+7. Harden token storage and callback page output.
+8. Add CI coverage for `dotnet build`, `src/ui-studio` build, and focused backend/UI tests.
+
+## Definition of Done
+
+- `dotnet build Tap.slnx -p:SkipTapUiBuild=true` passes with warnings as errors.
+- `yarn --cwd src/ui-studio build` passes.
+- Filesystem traversal tests pass.
+- Studio listens on loopback by default.
+- Execute/render/GraphQL share request construction and body decoding.
+- Editor save/dirty behavior is implemented once and reused.
+- DTO drift has automated detection.
+- Security-sensitive storage and callback behavior have tests or documented manual verification.
diff --git a/docs/workspace-format.md b/docs/workspace-format.md
new file mode 100644
index 0000000..cb54e89
--- /dev/null
+++ b/docs/workspace-format.md
@@ -0,0 +1,612 @@
+# Tap Workspace Format
+
+> Status: **draft v0**. Subject to change until v1.0. Every file Tap stores in your repo is plain Markdown with YAML frontmatter, so the format is reviewable as a normal git diff.
+
+A Tap **workspace** is a directory that contains a `.tap/` subfolder. Everything inside `.tap/` is meant to be checked into version control. Nothing in `.tap/` ever contains a secret value — only references to a secret provider.
+
+This document is the authoritative spec for the on-disk format. The Tap parser (`Tap.Workspace`) and renderer (`Tap.Workspace.Rendering`) implement exactly what's described here. If parser and spec disagree, the spec is the bug report.
+
+---
+
+## 1. Design goals
+
+1. **Git-native.** Every artifact is plain text. Renames work via `git mv`. Diffs are readable.
+2. **Composable.** A runnable request is the composition of *workspace + collection (+ stage) + auth + environment + request*. No single file is the whole story.
+3. **Readable on GitHub.** Frontmatter is the structured part; the body is human prose. A request file is a documentation page that happens to be executable.
+4. **Secret-safe by construction.** A literal secret cannot occur in a workspace file. Frontmatter values that look like `${{provider:path}}` are *references* and are never resolved at parse time.
+5. **One format, five shapes.** Every file is `Markdown + YAML frontmatter`. The `kind` field plus the filename suffix tell the parser what shape to expect.
+6. **No required tooling to read.** Any text editor or any Markdown renderer can display a workspace. Tap adds editing, validation, execution, and live preview on top.
+
+---
+
+## 2. File kinds
+
+| Kind | Filename suffix | Purpose |
+|---|---|---|
+| `request` | `*.req.md` | A single HTTP request template. |
+| `auth` | `*.auth.md` | A reusable authentication profile (bearer, basic, OAuth2, custom). |
+| `env` | `*.env.md` | A named environment (set of variables and secret bindings). |
+| `collection` | `_collection.md` *(at `collections//`)* | A top-level group of requests. Owns the base URL, optional named stages, default auth, default headers, plus collection-scoped variables and tags. |
+| `workspace` | `tap.md` *(at workspace root)* | Workspace-level config: name, default env, registered secret providers. |
+
+Sub-directories inside a collection are pure grouping for the explorer tree — they carry no metadata, no variables, and no inherited defaults. Every request below a collection inherits its baseUrl, stages, default auth, default headers, and variables; variable sharing across a group of requests lives on `_collection.md`.
+
+The filename suffix is canonical. The `kind:` frontmatter field is required and must match the suffix. A mismatch is a hard parse error.
+
+### 2.1 Suggested directory layout
+
+```
+my-service/
+├── src/ ← your code
+└── .tap/
+ ├── tap.md ← kind: workspace
+ ├── schemas/ ← JSON Schemas, auto-generated
+ │ ├── request.schema.json
+ │ ├── auth.schema.json
+ │ ├── env.schema.json
+ │ └── workspace.schema.json
+ ├── auth/
+ │ ├── stripe-bearer.auth.md
+ │ └── corp-oidc.auth.md
+ ├── environments/
+ │ ├── local.env.md
+ │ ├── staging.env.md
+ │ └── prod.env.md
+ └── collections/
+ └── stripe/
+ ├── _collection.md ← kind: collection (owns baseUrl, default auth/headers, stages)
+ ├── create-customer.req.md
+ ├── get-customer.req.md
+ └── refunds/ ← pure-grouping sub-folder
+ ├── issue.req.md
+ └── list.req.md
+```
+
+The three top-level directories (`auth/`, `environments/`, `collections/`) are
+structural: `auth/` and `environments/` hold flat lists of typed files; `collections/`
+hosts one sub-directory per collection. Inside each collection, nested directories
+are freeform grouping with no metadata — variable sharing across a group of requests
+lives on `_collection.md`.
+
+---
+
+## 3. Common file shape
+
+Every Tap file is:
+
+```
+---
+
+---
+
+
+```
+
+- **Frontmatter** is YAML 1.2, fenced by `---` lines. Required for all Tap files. Empty frontmatter (`---\n---`) is illegal — at minimum `kind:` must be present.
+- **Body** is CommonMark. Bodies are documentation for humans except in `request` files, where one fenced `http` block carries the executable template (§5).
+- Encoding is UTF-8, LF line endings.
+- Line length unconstrained; Tap's writer wraps prose at 100 columns by convention but never alters fenced blocks.
+
+### 3.1 Universal frontmatter fields
+
+These appear on every kind:
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `kind` | enum | yes | One of `request`, `auth`, `env`, `collection`, `workspace`. |
+| `id` | string | no | Stable identifier. Auto-generated as a [UUIDv7](https://uuid7.com) on first save if omitted. Used for cross-file refs that survive renames. |
+| `name` | string | no | Display name. Defaults to the filename stem if omitted. |
+| `tags` | string[] | no | Free-form labels for filtering. |
+
+### 3.2 Variable interpolation syntax
+
+Two distinct interpolations exist, and they mean different things:
+
+- `{{name}}` — a **variable reference**. Resolved from the merged variable scope (§7). Resolved during render. Always returns a string.
+- `${{scheme:path}}` — a **secret reference**. Resolved by the matching `ISecretProvider` (§8). Resolved at execute time only. Never visible to the UI in cleartext. May only appear in frontmatter fields that the spec marks as "secret-bearing".
+
+A literal `{` followed by `{` that you do not want interpolated is escaped as `\{{`.
+
+---
+
+## 4. `workspace` — `tap.md`
+
+The single file at the workspace root. Created on `tap init`.
+
+### 4.1 Frontmatter
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `kind` | `"workspace"` | yes | |
+| `name` | string | yes | |
+| `id` | uuid | yes | |
+| `defaultEnv` | path | no | Relative path to the `.env.md` file used when none is specified at execute time. |
+| `providers` | array of provider configs | no | Registers the secret providers available in this workspace. See §8.1. |
+| `vars` | map | no | Workspace-level variables (lowest precedence). |
+
+### 4.2 Example
+
+```markdown
+---
+kind: workspace
+id: 0192-3a4c-bb71-7c1d-9e8f0a1b2c3d
+name: acme-billing
+defaultEnv: environments/local.env.md
+providers:
+ # `env` is always registered — gated by host's TAP_VARS_ALLOWED / TAP_SECRETS_ALLOWED.
+ - scheme: keychain
+ service: tap.acme-billing
+ - scheme: azkv
+ vaultUrl: https://acme-prod.vault.azure.net
+ - scheme: age
+ keyFile: ${{env:TAP_AGE_KEY_FILE}}
+ file: secrets.age
+vars:
+ app.userAgent: tap/0.5
+---
+
+# Acme Billing
+
+API workspace for the billing service. Owned by @platform. Production access
+runs through `prod.env.md` and requires Azure Key Vault membership in the
+`billing-eng` group.
+```
+
+---
+
+## 5. `request` — `*.req.md`
+
+A single executable request. The most-edited file kind.
+
+### 5.1 Frontmatter
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `kind` | `"request"` | yes | |
+| `name` | string | no | |
+| `id` | uuid | no | |
+| `auth` | path \| id-ref \| `"none"` | no | Overrides the containing collection's `defaultAuth`. `"none"` opts out entirely. |
+| `protocol` | enum: `http` \| `websocket` | no | Wire protocol. Default `http`. `websocket` drives baseUrl scheme normalization (http→ws, https→wss) and switches the executor to a WebSocket transport. See §5.4. |
+| `vars` | map | no | Request-scoped variables (highest precedence except for explicit per-run overrides). |
+| `tags` | string[] | no | |
+| `assertions` | array of assertions | no | Reserved for v0.2. Ignored by v0 parser. |
+
+`var-spec` may be a literal string (becomes the default value) or an object:
+
+```yaml
+vars:
+ customer.email:
+ description: Email address used for signup
+ required: true
+ example: jane@example.com
+ customer.name: Jane Doe
+```
+
+### 5.2 Body
+
+The body is CommonMark. **Exactly one** fenced code block tagged `http` carries the request template. All other content is documentation and ignored at execute time.
+
+The `http` block follows the [VS Code REST Client / JetBrains HTTP Client](https://www.jetbrains.com/help/idea/exploring-http-syntax.html) syntax, with two extensions:
+
+1. `{{var}}` and `${{secret}}` interpolation per §3.2.
+2. The request line's URL may be a bare path (`/v1/customers`) — Tap prepends the containing collection's `baseUrl` (or, when a stage is active, the stage override). If the URL is not absolute and the collection has no baseUrl, the parser rejects the file.
+
+If multiple `http` blocks are present, the parser fails with `E_MULTIPLE_REQUEST_BLOCKS`. If zero are present and the request file isn't explicitly marked `skip: true`, the parser fails with `E_NO_REQUEST_BLOCK`.
+
+### 5.3 Example
+
+```markdown
+---
+kind: request
+id: 0192-3a4d-7000-7b91-a0c1d2e3f405
+name: Create customer
+auth: ../../auth/stripe-bearer.auth.md
+tags: [customer, write]
+vars:
+ customer.email:
+ description: Email address
+ required: true
+ customer.name:
+ description: Display name
+---
+
+# Create customer
+
+Creates a Stripe customer. Called during signup. Idempotent on `email` thanks
+to the upstream's idempotency-key middleware.
+
+## Request
+
+```http
+POST /v1/customers
+Content-Type: application/x-www-form-urlencoded
+
+email={{customer.email}}&name={{customer.name}}
+```
+
+## Notes
+
+When run against `prod`, coordinate with @billing — see runbook B-14.
+```
+
+### 5.4 WebSocket requests
+
+Setting `protocol: websocket` flips two behaviors:
+
+1. **URL scheme normalization.** The renderer rewrites the effective scheme so the executor opens a WebSocket. Specifically: scheme-less `host:port` (or `//host:port`) baseUrls pick up `ws://`; `http://X` is rewritten to `ws://X`; `https://X` becomes `wss://X`. An explicit `ws://`/`wss://` on the baseUrl or request line is left alone.
+2. **Transport.** The executor opens a `ClientWebSocket` against the resolved URL. The HTTP method (conventionally `GET`) and any custom headers ride the upgrade handshake — `Connection`, `Upgrade`, `Host`, and `Sec-WebSocket-*` are managed by the client and dropped if present.
+
+If the request carries a body, it is sent as the **first text frame** after the upgrade. Subsequent inbound frames stream back to the caller (`event: ws` on `/api/execute/stream`). To just listen, omit the body.
+
+Example:
+
+```markdown
+---
+kind: request
+name: Heartbeat
+protocol: websocket
+---
+
+```http
+GET /demo/stream/ws?interval=1000
+
+hello
+```
+```
+
+With `baseUrl: "{{DEMO_API_URL}}"` on the collection and `DEMO_API_URL=localhost:5298`, this resolves to `ws://localhost:5298/demo/stream/ws?interval=1000`. The same collection + var also serve plain HTTP requests off `http://localhost:5298`.
+
+---
+
+## 6. `collection` — `_collection.md`
+
+A top-level group of requests, owning the base URL, optional named stages, default auth, default headers, plus collection-scoped variables and tags. Lives at `collections//_collection.md`.
+
+### 6.1 Frontmatter
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `kind` | `"collection"` | yes | |
+| `name` | string | no | |
+| `id` | uuid | no | |
+| `baseUrl` | string | no | May contain `{{vars}}`. Scheme is optional — bare `host:port` is rendered with `http://` for normal requests and `ws://` for `protocol: websocket` requests. Required if any request inside writes a relative URL. |
+| `defaultAuth` | path \| id-ref | no | Auth profile inherited by every request in the collection that doesn't pin its own `auth:`. |
+| `defaultHeaders` | map | no | Merged under request-specific headers. |
+| `vars` | map | no | Collection-scoped variables. Cascade tier between workspace and stage. |
+| `stages` | sequence of stage | no | Named per-stage overrides (e.g. `dev`/`staging`/`prod`). Each stage may override `baseUrl`, `defaultAuth`, and `vars`. |
+| `defaultStage` | string | no | Stage to preselect in the editor. |
+| `tags` | string[] | no | |
+
+### 6.2 Example
+
+```markdown
+---
+kind: collection
+id: 0192-3a4d-9000-7a01-1234-5678-9abc-def0
+name: Stripe
+baseUrl: https://api.stripe.com
+defaultAuth: ../../auth/stripe-bearer.auth.md
+defaultHeaders:
+ Stripe-Version: "2025-04-30"
+ Accept: application/json
+stages:
+- name: live
+- name: test
+ baseUrl: https://api.stripe.com
+ defaultAuth: ../../auth/stripe-test-bearer.auth.md
+defaultStage: live
+---
+
+# Stripe
+
+[Public docs](https://stripe.com/docs/api). Every request below this collection
+inherits the baseUrl, default auth, and default headers automatically.
+```
+
+---
+
+## 7. `env` — `*.env.md`
+
+A named environment. Activated by Tap at execute time; provides values for `{{var}}` references and the bindings for `${{secret}}` references that this environment uses.
+
+### 7.1 Frontmatter
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `kind` | `"env"` | yes | |
+| `name` | string | no | |
+| `id` | uuid | no | |
+| `vars` | map | no | |
+
+The value of any `vars` entry may be a literal **or** a secret reference (`${{scheme:path}}`). Tap delays resolution until execute time and never logs the resolved value.
+
+### 7.2 Example
+
+```markdown
+---
+kind: env
+id: 0192-3a4d-c000-7e1f-...
+name: Production
+vars:
+ api.baseUrl: https://api.stripe.com
+ STRIPE_KEY: ${{azkv:billing-prod/stripe-live-key}}
+ customer.email: noreply+prod@acme.example
+---
+
+# Production
+
+Live Stripe. Destructive runs require approval from @billing. Audit log:
+every secret resolution from this env emits an entry tagged `env=prod`.
+```
+
+### 7.3 Variable scope and precedence
+
+When Tap renders a request, it merges variable scopes in this order (later overrides earlier):
+
+1. `tap.md` `vars`
+2. Owning `_collection.md` `vars` (the collection the request lives under)
+3. Active collection stage's `vars`
+4. Active `env.md` `vars`
+5. Request file `vars`
+6. Per-run overrides (CLI `--var foo=bar`, UI form input)
+
+Resolution is single-pass — a variable may not reference another variable that depends on it. Tap detects cycles at render time and fails with `E_VAR_CYCLE`.
+
+---
+
+## 8. `auth` — `*.auth.md`
+
+A reusable authentication profile. Used by requests via the `auth:` frontmatter field, or applied as an API default.
+
+### 8.1 Common frontmatter
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `kind` | `"auth"` | yes | |
+| `name` | string | no | |
+| `id` | uuid | no | |
+| `type` | enum | yes | `none` \| `basic` \| `bearer` \| `apiKey` \| `oauth2` \| `aws-sigv4` \| `custom`. |
+
+Type-specific fields below. Any field marked **(secret-bearing)** may contain a `${{...}}` reference.
+
+### 8.2 `bearer`
+
+| Field | Type | Required |
+|---|---|---|
+| `token` | string (secret-bearing) | yes |
+
+### 8.3 `basic`
+
+| Field | Type | Required |
+|---|---|---|
+| `username` | string | yes |
+| `password` | string (secret-bearing) | yes |
+
+### 8.4 `apiKey`
+
+| Field | Type | Required |
+|---|---|---|
+| `in` | enum: `header` \| `query` \| `cookie` | yes |
+| `name` | string | yes — the header/query/cookie name |
+| `value` | string (secret-bearing) | yes |
+
+### 8.5 `oauth2`
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `flow` | enum: `authorization_code` \| `client_credentials` \| `device_code` \| `password` | yes | |
+| `authorizeUrl` | string | conditional | Required for `authorization_code`. |
+| `tokenUrl` | string | yes | |
+| `clientId` | string (secret-bearing) | yes | |
+| `clientSecret` | string (secret-bearing) | conditional | Not required for public clients (PKCE-only). |
+| `scopes` | string[] | no | |
+| `audience` | string | no | |
+| `redirectUri` | string | no | Default: `http://localhost:7878/callback` (Tap's loopback). |
+| `tokenCache` | string | no | Default: `keychain`. The provider used to store the obtained tokens. |
+
+Acquired access tokens, refresh tokens, and expiry timestamps live in the **token cache provider** (default the OS keychain). They are never written to a workspace file.
+
+### 8.6 `aws-sigv4`
+
+| Field | Type | Required |
+|---|---|---|
+| `region` | string | yes |
+| `service` | string | yes |
+| `accessKeyId` | string (secret-bearing) | yes |
+| `secretAccessKey` | string (secret-bearing) | yes |
+| `sessionToken` | string (secret-bearing) | no |
+
+### 8.7 `custom`
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `headers` | map | no | Headers injected as-is. |
+| `query` | map | no | |
+
+Use sparingly. If you find yourself reaching for `custom`, file an issue — likely there's a first-class type missing.
+
+---
+
+## 9. `collection` — `_collection.md`
+
+A **collection** is the top-level grouping for requests. Each collection lives at
+`collections//_collection.md`; the slug is the directory name and serves as the
+collection's id-on-disk. Nested directories below the collection are pure grouping
+(no metadata, no inheritance) — every request, no matter how deeply nested, belongs
+to exactly one collection.
+
+### 9.1 Frontmatter
+
+See §6 — collections own the base URL, default headers, default auth, stages, vars, and tags. This section is retained for navigation only; the canonical schema lives in §6.
+
+---
+
+## 10. Secret providers
+
+A **secret provider** resolves `${{scheme:path}}` references at execute time. Providers are registered in the workspace's `tap.md` `providers:` array (§4.2). Each provider is identified by its `scheme`; references and providers are bound by exact scheme match.
+
+### 10.1 Built-in providers (v0)
+
+| Scheme | Source | Reference example |
+|---|---|---|
+| `env` | Process environment variable (gated by the host allowlist — see below) | `${{env:STRIPE_KEY}}` |
+| `keychain` | OS keychain (macOS Keychain / Windows Credential Manager / Linux libsecret) | `${{keychain:acme/stripe-key}}` |
+| `age` | A workspace file encrypted with [age](https://age-encryption.org); decrypted in-memory using a key from another provider | `${{age:stripe/secret-key}}` |
+| `azkv` | Azure Key Vault | `${{azkv:billing-prod/stripe-live-key}}` |
+| `1p` | 1Password CLI (`op read`) | `${{1p:Personal/Stripe/api-key}}` |
+
+#### `env` allowlist
+
+The `env` provider is the only one whose reachable surface is bounded by the **host process**
+rather than the workspace file — anything the Tap process can see in its own environment is
+fair game otherwise, and the workspace is shared / checked in. Two process env vars gate
+access; both take a comma-separated list of glob patterns where `*` is the only wildcard:
+
+| Host env var | Effect |
+|---|---|
+| `TAP_VARS_ALLOWED` | Names whose values surface as plain System variables in the cascade (visible in the UI). Usable as `{{NAME}}` directly. |
+| `TAP_SECRETS_ALLOWED` | Names whose values stay masked everywhere in the UI but can be resolved via `${{env:NAME}}` at execute time. |
+
+Example:
+
+```shell
+export TAP_VARS_ALLOWED="VITE_*,ASPNETCORE_ENVIRONMENT,DEMO_API_URL"
+export TAP_SECRETS_ALLOWED="DEMO_*_TOKEN,AZURE_*"
+```
+
+If neither variable is set, the `env` provider denies every reference and the System scope is
+empty — deny-by-default is the safe default. References to names that don't match either
+pattern list fail with `E_SECRET_RESOLUTION_FAILED`.
+
+### 10.2 Resolution rules
+
+- A reference resolves to a string. Non-string secret values are an error.
+- Tap caches resolutions in memory for the duration of a render. Two references to the same secret within one execute call hit the provider once.
+- A reference whose scheme is not registered in `providers:` fails with `E_UNKNOWN_SECRET_SCHEME`.
+- Failed resolution (provider down, ref missing) produces `E_SECRET_RESOLUTION_FAILED`; Tap surfaces which ref failed but never the partial value.
+- Resolved values are redacted from execution history. Only the ref text is recorded.
+
+### 10.3 Adding a custom provider
+
+Tap will support out-of-tree providers via a plugin model (post-v0). For v0 the built-in set is the supported surface.
+
+---
+
+## 11. Rendering: from files to a ResolvedRequest
+
+`Tap.Workspace.Rendering.WorkspaceRenderer.RenderAsync(requestRef, envRef, overrides)` produces a `ResolvedRequest`:
+
+```
+ResolvedRequest {
+ method: "POST"
+ url: "https://api.stripe.com/v1/customers"
+ headers: { "Content-Type": "application/x-www-form-urlencoded",
+ "Stripe-Version": "2025-04-30",
+ "Accept": "application/json",
+ "Authorization": "Bearer sk_live_***" }
+ body: "email=jane%40example.com&name=Jane%20Doe"
+ metadata: { sourceFile, sourceLine, envId, secretsUsed: ["azkv:billing-prod/stripe-live-key"] }
+}
+```
+
+The render pipeline:
+
+1. Load the request file; parse frontmatter + body.
+2. Find the owning collection by walking the request path (`collections//...`); resolve the active stage, the request-or-stage-or-collection `auth:` ref, and inherited default headers.
+3. Load the active `env.md`. Build the merged variable scope (§7.3).
+4. Expand `{{var}}` in the URL line, header lines, and body of the fenced `http` block. Reject on unknown var.
+5. Apply auth: bearer/basic/apikey inject headers/cookies/query; oauth2 obtains a token via the token cache (refreshing if needed); aws-sigv4 signs the canonical request.
+6. Resolve `${{secret}}` references inline (last step — keeps secrets out of stages 1–4).
+7. Return the resolved request. The caller (executor, CLI `render`, diff viewer) consumes it.
+
+Step 6 is auditable: the renderer emits an `ISecretResolutionTrace` listing which refs were resolved, in which provider, at what timestamp.
+
+---
+
+## 12. References between files
+
+Two ways to point from one file to another:
+
+1. **Relative path** (recommended for v0): `auth: ../../auth/stripe-bearer.auth.md`. Survives `git mv` provided both files move together. Clearer in diffs.
+2. **Id reference**: `auth: id:0192-3a4d-9000-...`. Tap maintains an index built from `id:` fields. Survives rename without coordinated moves but requires the index to be up-to-date.
+
+The parser accepts both, normalizes internally to a canonical `WorkspaceRef`. Tap's writer always emits relative paths.
+
+---
+
+## 13. Versioning, IDs, and stability
+
+- A new file with no `id:` gets a UUIDv7 assigned by the writer on first save.
+- The id is the durable identity. Renaming the file preserves the id.
+- Cross-file `id:` references resolve through the workspace index; if an id has no owner, references to it produce `E_DANGLING_REF`.
+- The format version is implicit in this document. Files do not carry a version field in v0; a future breaking change will introduce a `tapFormat: "1.x"` field in `tap.md`.
+
+---
+
+## 14. Parse errors (canonical)
+
+| Code | Meaning |
+|---|---|
+| `E_FRONTMATTER_MISSING` | File has no `---` fenced frontmatter block. |
+| `E_FRONTMATTER_MALFORMED_YAML` | Frontmatter is not valid YAML 1.2. |
+| `E_KIND_MISMATCH` | `kind:` does not match the filename suffix. |
+| `E_KIND_MISSING` | `kind:` field absent. |
+| `E_UNKNOWN_FIELD` | Frontmatter contains an unrecognized field for that kind (warning, not error, in v0). |
+| `E_NO_REQUEST_BLOCK` | A `request` file has no fenced `http` block. |
+| `E_MULTIPLE_REQUEST_BLOCKS` | A `request` file has more than one fenced `http` block. |
+| `E_DANGLING_REF` | A `path` or `id:` reference does not resolve. |
+| `E_VAR_UNKNOWN` | A `{{var}}` interpolation references a var not in scope. |
+| `E_VAR_CYCLE` | Variable scope contains a reference cycle. |
+| `E_UNKNOWN_SECRET_SCHEME` | A `${{scheme:...}}` reference uses a scheme not registered. |
+| `E_SECRET_RESOLUTION_FAILED` | A registered provider rejected the reference. |
+| `E_AUTH_TYPE_INVALID` | Auth `type:` is not a recognized value. |
+| `E_HTTP_BLOCK_SYNTAX` | The fenced `http` block fails to parse as VS Code REST Client syntax. |
+
+---
+
+## 15. Out of scope for v0
+
+The following are deliberate omissions, slated for later versions:
+
+- **Assertions / tests** on responses (`assertions:` is reserved but ignored).
+- **Pre-request and post-response scripts** (planned: a `scripts/` directory with TypeScript modules referenced from request frontmatter).
+- **Request chaining** (composite "flow" files that orchestrate multiple requests).
+- **GraphQL request type** (handled today via the standard `application/json` body; a first-class GraphQL kind is a v0.2 candidate).
+- **gRPC** as a first-class kind — captured read-only by the Tap tunnel for now.
+- **SSE** as a first-class kind — `text/event-stream` responses on regular HTTP requests are already parsed and surfaced; no separate request type is needed.
+- WebSocket: now a first-class request via `protocol: websocket` (§5.4). The executor opens the connection, sends the body (if any) as the first frame, and streams inbound frames back.
+- **Multi-cursor environments** (overlays, env stacks). Single active env per execution in v0.
+
+---
+
+## 16. Worked example: end-to-end
+
+Given the workspace from §2.1 and the files in §4.2, §5.3, §6.2, §7.2, §8.2:
+
+```
+$ tap render collections/customer/create.req.md --env environments/prod.env.md \
+ --var customer.email=jane@example.com --var customer.name="Jane Doe"
+```
+
+The renderer produces:
+
+```http
+POST https://api.stripe.com/v1/customers
+Stripe-Version: 2025-04-30
+Accept: application/json
+Authorization: Bearer sk_live_***
+Content-Type: application/x-www-form-urlencoded
+
+email=jane%40example.com&name=Jane%20Doe
+```
+
+With the audit trace:
+
+```
+secret azkv:billing-prod/stripe-live-key → resolved in 142ms
+var baseUrl ← collections/customer/_collection.md
+var customer.email ← CLI --var
+var customer.name ← CLI --var
+```
+
+This is the contract the rest of Tap (executor, diff viewer, capture-promotion flow, satellite) is built against.
diff --git a/global.json b/global.json
index ce67766..8287d38 100644
--- a/global.json
+++ b/global.json
@@ -1,5 +1,6 @@
{
"sdk": {
- "version": "10.0.201"
+ "version": "10.0.201",
+ "rollForward": "latestFeature"
}
}
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 0000000..993d13d
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/Demo.Api/Auth/DemoAuth.cs b/samples/Demo.Api/Auth/DemoAuth.cs
new file mode 100644
index 0000000..97968c7
--- /dev/null
+++ b/samples/Demo.Api/Auth/DemoAuth.cs
@@ -0,0 +1,393 @@
+using System.Collections.Immutable;
+using System.Security.Claims;
+using Microsoft.AspNetCore;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.EntityFrameworkCore;
+using OpenIddict.Abstractions;
+using OpenIddict.Server.AspNetCore;
+using OpenIddict.Validation.AspNetCore;
+using static OpenIddict.Abstractions.OpenIddictConstants;
+
+namespace Demo.Api.Auth;
+
+///
+/// OpenIddict-backed OAuth2 / OIDC server intended for local Tap demos. Supports the grants
+/// Tap's AuthRunner drives end-to-end without an external IdP:
+///
+/// * client_credentials — m2m, no UI.
+/// * password — ROPC; seeded test user below.
+/// * authorization_code + PKCE — interactive popup. A bare-bones HTML consent
+/// page is hosted at /connect/authorize so the demo doesn't need an external
+/// IdP UI.
+/// * refresh_token — silent renew off any of the above.
+///
+/// Device-code (RFC 8628) and On-Behalf-Of (JWT-bearer) are exercised by the runner against
+/// real identity providers — wiring them into OpenIddict in a sample isn't worth the API
+/// drag. The Azure-CLI auth types target real AAD; the device-code grant works against any
+/// OIDC IdP that advertises device_authorization_endpoint in discovery.
+///
+/// Tokens are signed with development certs OpenIddict mints on first run — fine for samples,
+/// never for production.
+///
+public static class DemoAuth
+{
+ public const string ClientId = "tap-demo";
+ public const string ClientSecret = "tap-demo-secret";
+ public const string PublicClientId = "tap-demo-public";
+ public const string TestUser = "alice";
+ public const string TestPassword = "wonderland";
+ /// Fallback when the AppHost didn't plumb STUDIO_CALLBACK_URL through.
+ /// Real runs always override this via env var so the seeded redirect URI matches the
+ /// Aspire-assigned Studio port.
+ public const string DefaultStudioRedirectUri = "http://localhost:5298/api/auth/callback";
+
+ /// Live redirect URI Demo.Api should accept on the OAuth code flow. Resolved
+ /// at boot from STUDIO_CALLBACK_URL (set by Studio.AppHost) and falls back to
+ /// the localhost constant so standalone runs still work.
+ public static string StudioRedirectUri =>
+ Environment.GetEnvironmentVariable("STUDIO_CALLBACK_URL") ?? DefaultStudioRedirectUri;
+
+ public static void AddServices(WebApplicationBuilder builder)
+ {
+ builder.Services.AddDbContext(o =>
+ {
+ o.UseInMemoryDatabase("demo-api-openiddict");
+ o.UseOpenIddict();
+ });
+
+ builder.Services.AddOpenIddict()
+ .AddCore(o => o.UseEntityFrameworkCore().UseDbContext())
+ .AddServer(o =>
+ {
+ o.SetTokenEndpointUris("/connect/token");
+ o.SetAuthorizationEndpointUris("/connect/authorize");
+ o.SetUserInfoEndpointUris("/connect/userinfo");
+ o.SetIntrospectionEndpointUris("/connect/introspect");
+ o.SetConfigurationEndpointUris("/.well-known/openid-configuration");
+ o.SetJsonWebKeySetEndpointUris("/.well-known/jwks");
+
+ o.AllowClientCredentialsFlow();
+ o.AllowPasswordFlow();
+ o.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange();
+ o.AllowRefreshTokenFlow();
+
+ o.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.OfflineAccess, "api");
+
+ o.AddDevelopmentEncryptionCertificate();
+ o.AddDevelopmentSigningCertificate();
+
+ // Plain HTTP is fine on localhost; spare the demo from needing dev-https.
+ o.UseAspNetCore()
+ .EnableTokenEndpointPassthrough()
+ .EnableAuthorizationEndpointPassthrough()
+ .EnableUserInfoEndpointPassthrough()
+ .DisableTransportSecurityRequirement();
+ })
+ .AddValidation(o =>
+ {
+ o.UseLocalServer();
+ o.UseAspNetCore();
+ });
+
+ builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
+ builder.Services.AddAuthorization();
+ }
+
+ /// Idempotently registers the demo clients on first boot.
+ public static async Task SeedAsync(IServiceProvider services)
+ {
+ var apps = services.GetRequiredService();
+
+ // Confidential client — drives client_credentials + password + auth-code (with secret).
+ // Auth-code is allowed too so users experimenting with the existing CC profile by
+ // flipping the grant type get a working flow without remembering to also swap
+ // clientId to tap-demo-public.
+ if (await apps.FindByClientIdAsync(ClientId) is null)
+ {
+ await apps.CreateAsync(new OpenIddictApplicationDescriptor
+ {
+ ClientId = ClientId,
+ ClientSecret = ClientSecret,
+ DisplayName = "Tap demo client (confidential)",
+ ClientType = ClientTypes.Confidential,
+ RedirectUris = { new Uri(StudioRedirectUri) },
+ Permissions =
+ {
+ Permissions.Endpoints.Token,
+ Permissions.Endpoints.Authorization,
+ Permissions.Endpoints.Introspection,
+ Permissions.GrantTypes.ClientCredentials,
+ Permissions.GrantTypes.Password,
+ Permissions.GrantTypes.AuthorizationCode,
+ Permissions.GrantTypes.RefreshToken,
+ Permissions.ResponseTypes.Code,
+ Permissions.Scopes.Profile,
+ Permissions.Scopes.Email,
+ Permissions.Prefixes.Scope + "api",
+ Permissions.Prefixes.Scope + Scopes.OfflineAccess,
+ },
+ });
+ }
+
+ // Public (PKCE) client — for the auth-code popup driven by Tap.Studio. No secret;
+ // PKCE provides the proof. Redirect URI must match AuthRunner.DefaultRedirectUri.
+ if (await apps.FindByClientIdAsync(PublicClientId) is null)
+ {
+ await apps.CreateAsync(new OpenIddictApplicationDescriptor
+ {
+ ClientId = PublicClientId,
+ DisplayName = "Tap demo public client (PKCE)",
+ ClientType = ClientTypes.Public,
+ RedirectUris = { new Uri(StudioRedirectUri) },
+ Permissions =
+ {
+ Permissions.Endpoints.Token,
+ Permissions.Endpoints.Authorization,
+ Permissions.GrantTypes.AuthorizationCode,
+ Permissions.GrantTypes.RefreshToken,
+ Permissions.ResponseTypes.Code,
+ Permissions.Scopes.Profile,
+ Permissions.Scopes.Email,
+ Permissions.Prefixes.Scope + "api",
+ Permissions.Prefixes.Scope + Scopes.OfflineAccess,
+ },
+ Requirements =
+ {
+ Requirements.Features.ProofKeyForCodeExchange,
+ },
+ });
+ }
+ }
+
+ public static void MapEndpoints(WebApplication app)
+ {
+ // ----- /connect/token : branches by grant_type. ------------------------------
+ app.MapMethods("/connect/token", new[] { "POST" }, async (HttpContext ctx) =>
+ {
+ var request = ctx.GetOpenIddictServerRequest()
+ ?? throw new InvalidOperationException("OpenID Connect request not found.");
+
+ if (request.IsClientCredentialsGrantType())
+ return IssueClientCredentials(request);
+
+ if (request.IsPasswordGrantType())
+ return IssuePassword(request);
+
+ if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
+ {
+ // Both replay the principal OpenIddict stashed on the original sign-in.
+ var info = await ctx.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ if (!info.Succeeded)
+ return BadGrant("The token request was rejected by the authentication server.");
+ return Results.SignIn(info.Principal!,
+ authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
+ return Results.BadRequest(new
+ {
+ error = Errors.UnsupportedGrantType,
+ error_description = "Grant not supported by this demo.",
+ });
+ });
+
+ // ----- /connect/authorize : tiny inline consent page for code+PKCE. ---------
+ app.MapMethods("/connect/authorize", new[] { "GET", "POST" }, async (HttpContext ctx) =>
+ {
+ var request = ctx.GetOpenIddictServerRequest()
+ ?? throw new InvalidOperationException("OpenID Connect request not found.");
+
+ // First hit (GET) — render a tiny login form that POSTs back to /connect/authorize
+ // with the same query string. Tap.Studio's popup completes the round-trip when
+ // the IdP redirects to /api/auth/callback with code + state.
+ if (HttpMethods.IsGet(ctx.Request.Method))
+ {
+ ctx.Response.ContentType = "text/html; charset=utf-8";
+ await ctx.Response.WriteAsync(RenderLoginPage(ctx.Request.Query));
+ return Results.Empty;
+ }
+
+ var form = await ctx.Request.ReadFormAsync();
+ var username = form["username"].ToString();
+ var password = form["password"].ToString();
+ if (!CredentialsOk(username, password))
+ {
+ ctx.Response.ContentType = "text/html; charset=utf-8";
+ ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ // Re-emit hidden inputs from the form body so the user can retry without
+ // the browser losing the original OAuth parameters.
+ await ctx.Response.WriteAsync(RenderLoginPage(FormToQuery(form), "Invalid username or password."));
+ return Results.Empty;
+ }
+
+ var principal = BuildUserPrincipal(username, request.GetScopes());
+ return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ });
+
+ // ----- /connect/userinfo : echoes the user's standard claims. ---------------
+ app.MapGet("/connect/userinfo", (HttpContext ctx) =>
+ {
+ var user = ctx.User;
+ if (user.Identity is null || !user.Identity.IsAuthenticated) return Results.Unauthorized();
+ return Results.Json(new
+ {
+ sub = user.FindFirstValue(Claims.Subject),
+ name = user.FindFirstValue(Claims.Name),
+ email = user.FindFirstValue(Claims.Email),
+ role = user.FindFirstValue(Claims.Role),
+ });
+ }).RequireAuthorization();
+
+ // ----- /demo/auth/whoami : sample protected resource. ----------------------
+ app.MapGet("/demo/auth/whoami", (HttpContext ctx) =>
+ {
+ var claims = ctx.User.Claims
+ .GroupBy(c => c.Type)
+ .ToDictionary(g => g.Key, g => g.Select(c => c.Value).ToArray());
+ return Results.Json(new
+ {
+ authenticated = ctx.User.Identity?.IsAuthenticated == true,
+ claims,
+ });
+ }).RequireAuthorization();
+ }
+
+ // ---- Grant-handling helpers ------------------------------------------------------
+
+ private static IResult IssueClientCredentials(OpenIddictRequest request)
+ {
+ var identity = new ClaimsIdentity(
+ OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
+ Claims.Name, Claims.Role);
+ identity.AddClaim(new Claim(Claims.Subject, request.ClientId!));
+ identity.AddClaim(new Claim(Claims.Name, request.ClientId!));
+ var principal = new ClaimsPrincipal(identity);
+ principal.SetScopes(request.GetScopes());
+ principal.SetResources("api");
+ foreach (var claim in principal.Claims) claim.SetDestinations(GetDestinations(claim));
+ return Results.SignIn(principal,
+ authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
+ private static IResult IssuePassword(OpenIddictRequest request)
+ {
+ if (!CredentialsOk(request.Username, request.Password))
+ return BadGrant("Bad username or password.");
+
+ var principal = BuildUserPrincipal(request.Username!, request.GetScopes());
+ return Results.SignIn(principal,
+ authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
+ private static ClaimsPrincipal BuildUserPrincipal(string username, ImmutableArray scopes)
+ {
+ var identity = new ClaimsIdentity(
+ OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
+ Claims.Name, Claims.Role);
+ identity.AddClaim(new Claim(Claims.Subject, username));
+ identity.AddClaim(new Claim(Claims.Name, username));
+ identity.AddClaim(new Claim(Claims.Email, $"{username}@example.com"));
+ identity.AddClaim(new Claim(Claims.Role, "demo-user"));
+ var principal = new ClaimsPrincipal(identity);
+ principal.SetScopes(scopes);
+ principal.SetResources("api");
+ // offline_access is what unlocks refresh-token issuance; OpenIddict only mints
+ // refresh tokens when the principal carries that scope.
+ foreach (var claim in principal.Claims) claim.SetDestinations(GetDestinations(claim));
+ return principal;
+ }
+
+ private static bool CredentialsOk(string? username, string? password)
+ => string.Equals(username, TestUser, StringComparison.Ordinal)
+ && string.Equals(password, TestPassword, StringComparison.Ordinal);
+
+ private static IResult BadGrant(string description)
+ => Results.Forbid(
+ authenticationSchemes: new[] { OpenIddictServerAspNetCoreDefaults.AuthenticationScheme },
+ properties: new AuthenticationProperties(new Dictionary
+ {
+ [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
+ [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description,
+ }));
+
+ /// Decide which token a claim belongs in. Subject + name always make it into
+ /// the access token; the rest gate on the relevant scope.
+ private static IEnumerable GetDestinations(Claim claim)
+ {
+ yield return Destinations.AccessToken;
+
+ switch (claim.Type)
+ {
+ case Claims.Name when claim.Subject?.HasScope(Scopes.Profile) == true:
+ case Claims.Email when claim.Subject?.HasScope(Scopes.Email) == true:
+ case Claims.Role when claim.Subject?.HasScope(Scopes.Profile) == true:
+ yield return Destinations.IdentityToken;
+ break;
+ }
+ }
+
+ /// Adapt the submitted form back into an IQueryCollection so the retry-render
+ /// path can keep using the same builder.
+ private static IQueryCollection FormToQuery(IFormCollection form)
+ {
+ var dict = new Dictionary();
+ foreach (var (k, v) in form) dict[k] = v;
+ return new QueryCollection(dict);
+ }
+
+ // ---- Tiny inline UI ------------------------------------------------------------
+
+ ///
+ /// Bare-bones consent page for the authorization-code flow. POSTs back to
+ /// /connect/authorize with every original OAuth parameter re-emitted as a hidden
+ /// input — OpenIddict reads parameters from the form body on POST and a bare
+ /// action="/connect/authorize?…" drops the query when the browser submits.
+ /// Without these inputs OpenIddict's validators report ID2029 (mandatory client_id missing).
+ /// No CSRF token because the demo runs on localhost over plain HTTP.
+ ///
+ private static string RenderLoginPage(IQueryCollection query, string? error = null)
+ {
+ var errorHtml = error is null ? string.Empty : $"{System.Net.WebUtility.HtmlEncode(error)}
";
+ var hiddenInputs = new System.Text.StringBuilder();
+ foreach (var kv in query)
+ {
+ // username/password are user-submitted; everything else (client_id, response_type,
+ // scope, code_challenge, …) is forwarded verbatim so OpenIddict sees a complete
+ // authorize request on POST.
+ if (string.Equals(kv.Key, "username", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(kv.Key, "password", StringComparison.OrdinalIgnoreCase))
+ continue;
+ foreach (var v in kv.Value)
+ {
+ hiddenInputs.Append(" ");
+ }
+ }
+
+ return $$"""
+
+ Demo.Api · Sign in
+
+
+ """;
+ }
+}
diff --git a/samples/Demo.Api/Demo.Api.csproj b/samples/Demo.Api/Demo.Api.csproj
new file mode 100644
index 0000000..f02139c
--- /dev/null
+++ b/samples/Demo.Api/Demo.Api.csproj
@@ -0,0 +1,16 @@
+
+
+ net10.0
+ Demo.Api
+ Demo.Api
+ false
+ false
+
+
+
+
+
+
+
+
+
diff --git a/samples/Demo.Api/DemoDbContext.cs b/samples/Demo.Api/DemoDbContext.cs
new file mode 100644
index 0000000..feec2aa
--- /dev/null
+++ b/samples/Demo.Api/DemoDbContext.cs
@@ -0,0 +1,10 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Demo.Api;
+
+///
+/// EF Core context used solely by OpenIddict to persist clients and tokens. The store is
+/// in-memory — the test client and user are reseeded on every boot via
+/// .
+///
+public sealed class DemoDbContext(DbContextOptions options) : DbContext(options);
diff --git a/samples/Demo.Api/Endpoints/ContentEndpoints.cs b/samples/Demo.Api/Endpoints/ContentEndpoints.cs
new file mode 100644
index 0000000..d9a0522
--- /dev/null
+++ b/samples/Demo.Api/Endpoints/ContentEndpoints.cs
@@ -0,0 +1,127 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Demo.Api.Endpoints;
+
+///
+/// Response-side content type playground. Each route returns the same logical payload in
+/// a different content type so Tap's body-rendering can be exercised end-to-end.
+///
+public static class ContentEndpoints
+{
+ public static void Map(WebApplication app)
+ {
+ var g = app.MapGroup("/demo/content");
+
+ g.MapGet("/json", () =>
+ Results.Json(new ContentPayload("json", "Hello from Demo.Api", DateTimeOffset.UtcNow),
+ ContentJson.Default.ContentPayload));
+
+ g.MapGet("/xml", () =>
+ {
+ var xml = ""
+ + $"Hello from Demo.Api {DateTimeOffset.UtcNow:o} ";
+ return Results.Content(xml, "application/xml", Encoding.UTF8);
+ });
+
+ g.MapGet("/yaml", () =>
+ {
+ var yaml = $"kind: yaml\nmessage: Hello from Demo.Api\ntime: {DateTimeOffset.UtcNow:o}\n";
+ return Results.Content(yaml, "application/yaml", Encoding.UTF8);
+ });
+
+ g.MapGet("/text", () => Results.Text(
+ $"kind: text\nmessage: Hello from Demo.Api\ntime: {DateTimeOffset.UtcNow:o}\n",
+ "text/plain"));
+
+ g.MapGet("/html", () => Results.Content(
+ "Demo "
+ + "Hello from Demo.Api HTML content type.
",
+ "text/html"));
+
+ g.MapGet("/css", () => Results.Content(
+ "body { font-family: system-ui; color: #333; }\nh1 { color: tomato; }\n",
+ "text/css"));
+
+ g.MapGet("/javascript", () => Results.Content(
+ "export const greet = (name) => `Hello, ${name}!`;\n",
+ "application/javascript"));
+
+ g.MapGet("/csv", () =>
+ {
+ var csv = "id,name,score\n1,alice,9.7\n2,bob,8.3\n3,carol,9.1\n";
+ return Results.Content(csv, "text/csv");
+ });
+
+ g.MapGet("/markdown", () => Results.Content(
+ "# Demo\n\nMarkdown response.\n\n- one\n- two\n- three\n",
+ "text/markdown"));
+
+ // Tiny 3x3 PNG (red square) — base64-decoded once and cached.
+ g.MapGet("/png", () => Results.Bytes(Png3x3, "image/png"));
+
+ // Tiny JPEG (solid red) — base64-decoded once and cached.
+ g.MapGet("/jpeg", () => Results.Bytes(JpegRed, "image/jpeg"));
+
+ // 12-byte SVG (red circle).
+ g.MapGet("/svg", () => Results.Content(
+ ""
+ + " ",
+ "image/svg+xml"));
+
+ g.MapGet("/binary", () =>
+ {
+ // 256 bytes of incrementing data — useful for exercising the inspector's binary preview.
+ var data = new byte[256];
+ for (var i = 0; i < data.Length; i++) data[i] = (byte)i;
+ return Results.Bytes(data, "application/octet-stream", "demo.bin");
+ });
+
+ g.MapGet("/problem", () => Results.Problem(
+ title: "Demo problem",
+ detail: "This is RFC 7807 application/problem+json.",
+ statusCode: StatusCodes.Status418ImATeapot,
+ type: "https://example.com/probs/demo"));
+
+ g.MapGet("/empty", () => Results.NoContent());
+
+ g.MapGet("/large/{kib:int}", (int kib) =>
+ {
+ kib = Math.Clamp(kib, 1, 4096);
+ var bytes = new byte[kib * 1024];
+ new Random(42).NextBytes(bytes);
+ return Results.Bytes(bytes, "application/octet-stream", $"demo-{kib}KiB.bin");
+ });
+
+ g.MapGet("/slow/{ms:int}", async (int ms, CancellationToken ct) =>
+ {
+ ms = Math.Clamp(ms, 0, 30_000);
+ await Task.Delay(ms, ct);
+ return Results.Json(new { waitedMs = ms, time = DateTimeOffset.UtcNow },
+ ContentJson.Default.SlowResponse);
+ });
+ }
+
+ private static readonly byte[] Png3x3 = Convert.FromBase64String(
+ // 3x3 solid red PNG.
+ "iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAFklEQVQI12P8z8DwHwAFBQIAX8jx6gAAAABJRU5ErkJggg==");
+
+ private static readonly byte[] JpegRed = Convert.FromBase64String(
+ // 8x8 solid red JPEG.
+ "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAYEBAUEBAYFBQUGBgYHCQ4JCQgICRINDQoOFRIWFhUSFBQXGiEcFxgfGRQUHScdHyIjJSUlFhwpLCgkKyEkJST/2wBDAQYGBgkICREJCREkGBQYJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCT/wAARCAAIAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwBVAH//2Q==");
+}
+
+internal sealed record ContentPayload(
+ [property: JsonPropertyName("kind")] string Kind,
+ [property: JsonPropertyName("message")] string Message,
+ [property: JsonPropertyName("time")] DateTimeOffset Time);
+
+internal sealed record SlowResponse(
+ [property: JsonPropertyName("waitedMs")] int WaitedMs,
+ [property: JsonPropertyName("time")] DateTimeOffset Time);
+
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+[JsonSerializable(typeof(ContentPayload))]
+[JsonSerializable(typeof(SlowResponse))]
+internal sealed partial class ContentJson : JsonSerializerContext;
diff --git a/samples/Demo.Api/Endpoints/DemoEndpoints.cs b/samples/Demo.Api/Endpoints/DemoEndpoints.cs
new file mode 100644
index 0000000..f88154f
--- /dev/null
+++ b/samples/Demo.Api/Endpoints/DemoEndpoints.cs
@@ -0,0 +1,76 @@
+namespace Demo.Api.Endpoints;
+
+/// Catalog endpoint — emits a flat list of every demo route so an unfamiliar
+/// user can poke around without consulting the source.
+public static class DemoEndpoints
+{
+ public static void MapIndex(WebApplication app)
+ {
+ app.MapGet("/demo/index", () => Results.Json(new
+ {
+ methods = new[]
+ {
+ "GET /demo/methods/",
+ "POST /demo/methods/",
+ "PUT /demo/methods/",
+ "PATCH /demo/methods/",
+ "DELETE /demo/methods/",
+ "HEAD /demo/methods/",
+ "OPTIONS /demo/methods/",
+ "GET /demo/methods/status/{code}",
+ "GET /demo/methods/headers",
+ "GET /demo/methods/query",
+ },
+ content = new[]
+ {
+ "GET /demo/content/json",
+ "GET /demo/content/xml",
+ "GET /demo/content/yaml",
+ "GET /demo/content/text",
+ "GET /demo/content/html",
+ "GET /demo/content/css",
+ "GET /demo/content/javascript",
+ "GET /demo/content/csv",
+ "GET /demo/content/markdown",
+ "GET /demo/content/png",
+ "GET /demo/content/jpeg",
+ "GET /demo/content/svg",
+ "GET /demo/content/binary",
+ "GET /demo/content/problem",
+ "GET /demo/content/empty",
+ "GET /demo/content/large/{kib}",
+ "GET /demo/content/slow/{ms}",
+ },
+ uploads = new[]
+ {
+ "POST /demo/upload/json",
+ "POST /demo/upload/form",
+ "POST /demo/upload/multipart",
+ "POST /demo/upload/text",
+ "POST /demo/upload/raw",
+ },
+ streaming = new[]
+ {
+ "GET /demo/stream/sse?count=5&interval=500",
+ "GET /demo/stream/ws (WebSocket upgrade)",
+ },
+ graphql = new[]
+ {
+ "POST /graphql",
+ "GET /graphql (Nitro UI)",
+ },
+ auth = new
+ {
+ discovery = "/.well-known/openid-configuration",
+ token = "/connect/token",
+ userinfo = "/connect/userinfo",
+ introspection = "/connect/introspect",
+ client_id = Demo.Api.Auth.DemoAuth.ClientId,
+ client_secret = Demo.Api.Auth.DemoAuth.ClientSecret,
+ test_user = Demo.Api.Auth.DemoAuth.TestUser,
+ test_password = Demo.Api.Auth.DemoAuth.TestPassword,
+ protected_sample = "/demo/auth/whoami",
+ },
+ }));
+ }
+}
diff --git a/samples/Demo.Api/Endpoints/MethodsEndpoints.cs b/samples/Demo.Api/Endpoints/MethodsEndpoints.cs
new file mode 100644
index 0000000..799fc29
--- /dev/null
+++ b/samples/Demo.Api/Endpoints/MethodsEndpoints.cs
@@ -0,0 +1,113 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Demo.Api.Endpoints;
+
+///
+/// Exercises every HTTP method Tap may need to capture. Each handler echoes the request
+/// envelope (method, path, query, headers, body) so the inspector can verify the round
+/// trip without needing a separate response-validation flow.
+///
+public static class MethodsEndpoints
+{
+ public static void Map(WebApplication app)
+ {
+ var g = app.MapGroup("/demo/methods");
+
+ // Cast through Delegate so the route handler's Task return value is
+ // written to the response (rather than being silently discarded by RequestDelegate).
+ Delegate echoDelegate = Echo;
+ g.MapGet("/", echoDelegate).WithName("methods-get");
+ g.MapPost("/", echoDelegate).WithName("methods-post");
+ g.MapPut("/", echoDelegate).WithName("methods-put");
+ g.MapPatch("/", echoDelegate).WithName("methods-patch");
+ g.MapDelete("/", echoDelegate).WithName("methods-delete");
+
+ // HEAD returns the same headers as GET but the framework will strip the body.
+ g.MapMethods("/", new[] { "HEAD" }, echoDelegate).WithName("methods-head");
+
+ // OPTIONS — advertise the supported verbs in the Allow header.
+ g.MapMethods("/", new[] { "OPTIONS" }, (HttpResponse res) =>
+ {
+ res.Headers.Allow = "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS";
+ return Results.NoContent();
+ }).WithName("methods-options");
+
+ // Status-code playground: /demo/methods/status/{code} returns that status with a JSON body.
+ g.MapMethods("/status/{code:int}", new[] { "GET", "POST", "PUT", "PATCH", "DELETE" }, (int code) =>
+ Results.Json(new StatusReply(code, ReasonPhrases.Phrase(code)),
+ MethodsJson.Default.StatusReply, statusCode: code))
+ .WithName("methods-status");
+
+ // Header passthrough — echoes request headers verbatim so tests can verify header
+ // forwarding through the proxy.
+ g.MapGet("/headers", (HttpRequest req) =>
+ Results.Json(req.Headers.ToDictionary(h => h.Key, h => h.Value.ToString())));
+
+ // Query-string echo with arbitrary keys.
+ g.MapGet("/query", (HttpRequest req) =>
+ Results.Json(req.Query.ToDictionary(q => q.Key, q => q.Value.ToString())));
+ }
+
+ private static async Task Echo(HttpContext ctx)
+ {
+ string body;
+ using (var reader = new StreamReader(ctx.Request.Body))
+ {
+ body = await reader.ReadToEndAsync();
+ }
+
+ return Results.Json(new MethodEcho(
+ Method: ctx.Request.Method,
+ Path: ctx.Request.Path,
+ Query: ctx.Request.QueryString.Value ?? string.Empty,
+ ContentType: ctx.Request.ContentType,
+ Headers: ctx.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()),
+ Body: body),
+ MethodsJson.Default.MethodEcho);
+ }
+}
+
+internal sealed record MethodEcho(
+ [property: JsonPropertyName("method")] string Method,
+ [property: JsonPropertyName("path")] string Path,
+ [property: JsonPropertyName("query")] string Query,
+ [property: JsonPropertyName("contentType")] string? ContentType,
+ [property: JsonPropertyName("headers")] IDictionary Headers,
+ [property: JsonPropertyName("body")] string Body);
+
+internal sealed record StatusReply(
+ [property: JsonPropertyName("code")] int Code,
+ [property: JsonPropertyName("description")] string Description);
+
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+[JsonSerializable(typeof(MethodEcho))]
+[JsonSerializable(typeof(StatusReply))]
+internal sealed partial class MethodsJson : JsonSerializerContext;
+
+internal static class ReasonPhrases
+{
+ public static string Phrase(int code) => code switch
+ {
+ 200 => "OK",
+ 201 => "Created",
+ 202 => "Accepted",
+ 204 => "No Content",
+ 301 => "Moved Permanently",
+ 302 => "Found",
+ 304 => "Not Modified",
+ 400 => "Bad Request",
+ 401 => "Unauthorized",
+ 403 => "Forbidden",
+ 404 => "Not Found",
+ 409 => "Conflict",
+ 418 => "I'm a teapot",
+ 422 => "Unprocessable Entity",
+ 429 => "Too Many Requests",
+ 500 => "Internal Server Error",
+ 502 => "Bad Gateway",
+ 503 => "Service Unavailable",
+ 504 => "Gateway Timeout",
+ _ => "Status",
+ };
+}
diff --git a/samples/Demo.Api/Endpoints/StreamingEndpoints.cs b/samples/Demo.Api/Endpoints/StreamingEndpoints.cs
new file mode 100644
index 0000000..493b542
--- /dev/null
+++ b/samples/Demo.Api/Endpoints/StreamingEndpoints.cs
@@ -0,0 +1,141 @@
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Demo.Api.Endpoints;
+
+///
+/// Server-Sent Events + WebSocket samples. Both endpoints emit predictable, timed traffic
+/// so the Tap capture middleware has something deterministic to record.
+///
+public static class StreamingEndpoints
+{
+ public static void Map(WebApplication app)
+ {
+ // SSE — emits `count` tick events at `interval` ms intervals, then a terminating "done".
+ app.MapGet("/demo/stream/sse", async (HttpContext ctx, int? count, int? interval, CancellationToken ct) =>
+ {
+ var n = Math.Clamp(count ?? 5, 1, 500);
+ var delayMs = Math.Clamp(interval ?? 500, 50, 60_000);
+
+ ctx.Response.Headers.ContentType = "text/event-stream";
+ ctx.Response.Headers.CacheControl = "no-cache, no-transform";
+ ctx.Response.Headers["X-Accel-Buffering"] = "no";
+
+ await ctx.Response.WriteAsync(": stream start\n\n", ct);
+ await ctx.Response.Body.FlushAsync(ct);
+
+ for (var i = 1; i <= n && !ct.IsCancellationRequested; i++)
+ {
+ var payload = JsonSerializer.Serialize(
+ new SseTick(i, n, DateTimeOffset.UtcNow),
+ StreamingJson.Default.SseTick);
+ await ctx.Response.WriteAsync($"id: {i}\nevent: tick\ndata: {payload}\n\n", ct);
+ await ctx.Response.Body.FlushAsync(ct);
+
+ if (i < n)
+ {
+ try { await Task.Delay(delayMs, ct); }
+ catch (TaskCanceledException) { break; }
+ }
+ }
+
+ if (!ct.IsCancellationRequested)
+ {
+ await ctx.Response.WriteAsync("event: done\ndata: {}\n\n", ct);
+ await ctx.Response.Body.FlushAsync(ct);
+ }
+ });
+
+ // WebSocket echo + heartbeat. Text frames are echoed back with an `echo:` prefix,
+ // binary frames echo verbatim, and the server emits a tick frame every `interval` ms.
+ app.Map("/demo/stream/ws", async (HttpContext ctx, int? interval, CancellationToken ct) =>
+ {
+ if (!ctx.WebSockets.IsWebSocketRequest)
+ {
+ ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await ctx.Response.WriteAsync("Expected a WebSocket upgrade.", ct);
+ return;
+ }
+
+ var delayMs = Math.Clamp(interval ?? 1500, 100, 60_000);
+ using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
+
+ var greeting = JsonSerializer.Serialize(
+ new WsHello("Demo.Api WebSocket connected", DateTimeOffset.UtcNow),
+ StreamingJson.Default.WsHello);
+ await ws.SendAsync(Encoding.UTF8.GetBytes(greeting), WebSocketMessageType.Text, true, ct);
+
+ var heartbeat = Task.Run(async () =>
+ {
+ var i = 0;
+ while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
+ {
+ try { await Task.Delay(delayMs, ct); }
+ catch (TaskCanceledException) { break; }
+ if (ws.State != WebSocketState.Open) break;
+ var tick = JsonSerializer.Serialize(
+ new WsTick(++i, DateTimeOffset.UtcNow),
+ StreamingJson.Default.WsTick);
+ try
+ {
+ await ws.SendAsync(Encoding.UTF8.GetBytes(tick),
+ WebSocketMessageType.Text, true, ct);
+ }
+ catch (WebSocketException) { break; }
+ }
+ }, ct);
+
+ var buffer = new byte[8192];
+ try
+ {
+ while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
+ {
+ var result = await ws.ReceiveAsync(buffer, ct);
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ await ws.CloseAsync(result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
+ result.CloseStatusDescription, ct);
+ break;
+ }
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ var text = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ var reply = $"echo: {text}";
+ await ws.SendAsync(Encoding.UTF8.GetBytes(reply),
+ WebSocketMessageType.Text, true, ct);
+ }
+ else
+ {
+ await ws.SendAsync(buffer.AsMemory(0, result.Count),
+ WebSocketMessageType.Binary, true, ct);
+ }
+ }
+ }
+ catch (OperationCanceledException) { /* client gone */ }
+ catch (WebSocketException) { /* client gone */ }
+
+ try { await heartbeat; } catch { /* heartbeat already exited */ }
+ });
+ }
+}
+
+internal sealed record SseTick(
+ [property: JsonPropertyName("index")] int Index,
+ [property: JsonPropertyName("total")] int Total,
+ [property: JsonPropertyName("time")] DateTimeOffset Time);
+
+internal sealed record WsHello(
+ [property: JsonPropertyName("hello")] string Hello,
+ [property: JsonPropertyName("time")] DateTimeOffset Time);
+
+internal sealed record WsTick(
+ [property: JsonPropertyName("tick")] int Tick,
+ [property: JsonPropertyName("time")] DateTimeOffset Time);
+
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+[JsonSerializable(typeof(SseTick))]
+[JsonSerializable(typeof(WsHello))]
+[JsonSerializable(typeof(WsTick))]
+internal sealed partial class StreamingJson : JsonSerializerContext;
diff --git a/samples/Demo.Api/Endpoints/UploadEndpoints.cs b/samples/Demo.Api/Endpoints/UploadEndpoints.cs
new file mode 100644
index 0000000..8c2ed6b
--- /dev/null
+++ b/samples/Demo.Api/Endpoints/UploadEndpoints.cs
@@ -0,0 +1,136 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace Demo.Api.Endpoints;
+
+///
+/// Request-side content types — JSON, form-urlencoded, multipart, raw, and text. Each
+/// handler echoes back what it parsed so request-body capture in the Tap inspector can
+/// be verified end-to-end.
+///
+public static class UploadEndpoints
+{
+ public static void Map(WebApplication app)
+ {
+ var g = app.MapGroup("/demo/upload");
+
+ // application/json — bind any object via System.Text.Json.
+ g.MapPost("/json", async (HttpRequest req) =>
+ {
+ using var doc = await JsonDocument.ParseAsync(req.Body);
+ return Results.Json(new
+ {
+ received = "json",
+ contentType = req.ContentType,
+ bytes = req.ContentLength,
+ payload = doc.RootElement.Clone(),
+ });
+ }).Accepts("application/json");
+
+ // application/x-www-form-urlencoded — echo the field map.
+ g.MapPost("/form", async (HttpRequest req) =>
+ {
+ var form = await req.ReadFormAsync();
+ return Results.Json(new
+ {
+ received = "form",
+ contentType = req.ContentType,
+ fields = form.ToDictionary(f => f.Key, f => f.Value.ToString()),
+ });
+ }).Accepts("application/x-www-form-urlencoded");
+
+ // multipart/form-data — list fields and file metadata.
+ g.MapPost("/multipart", async (HttpRequest req) =>
+ {
+ var form = await req.ReadFormAsync();
+ var files = form.Files.Select(f => new
+ {
+ f.Name,
+ fileName = f.FileName,
+ contentType = f.ContentType,
+ length = f.Length,
+ }).ToArray();
+ return Results.Json(new
+ {
+ received = "multipart",
+ contentType = req.ContentType,
+ fields = form.ToDictionary(f => f.Key, f => f.Value.ToString()),
+ files,
+ });
+ }).Accepts("multipart/form-data").DisableAntiforgery();
+
+ // application/xml — parse, echo the root element + leaf elements.
+ g.MapPost("/xml", async (HttpRequest req) =>
+ {
+ using var reader = new StreamReader(req.Body);
+ var xml = await reader.ReadToEndAsync();
+ try
+ {
+ var doc = XDocument.Parse(xml);
+ var root = doc.Root;
+ var leaves = root is null
+ ? []
+ : root.Descendants()
+ .Where(e => !e.HasElements)
+ .ToDictionary(e => e.Name.LocalName, e => e.Value);
+ return Results.Json(new
+ {
+ received = "xml",
+ contentType = req.ContentType,
+ bytes = xml.Length,
+ root = root?.Name.LocalName,
+ fields = leaves,
+ });
+ }
+ catch (XmlException ex)
+ {
+ return Results.Json(new
+ {
+ received = "xml",
+ contentType = req.ContentType,
+ error = ex.Message,
+ }, statusCode: 400);
+ }
+ });
+
+ // text/plain — echo verbatim.
+ g.MapPost("/text", async (HttpRequest req) =>
+ {
+ using var reader = new StreamReader(req.Body);
+ var text = await reader.ReadToEndAsync();
+ return Results.Json(new
+ {
+ received = "text",
+ contentType = req.ContentType,
+ length = text.Length,
+ text,
+ });
+ });
+
+ // Raw byte upload — count bytes, hash, and report content type.
+ g.MapPost("/raw", async (HttpRequest req) =>
+ {
+ using var ms = new MemoryStream();
+ await req.Body.CopyToAsync(ms);
+ var bytes = ms.ToArray();
+ return Results.Json(new RawUploadReply(
+ Received: "raw",
+ ContentType: req.ContentType,
+ Length: bytes.Length,
+ Sha256Hex: Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)).ToLowerInvariant()),
+ UploadJson.Default.RawUploadReply);
+ });
+ }
+}
+
+internal sealed record RawUploadReply(
+ [property: JsonPropertyName("received")] string Received,
+ [property: JsonPropertyName("contentType")] string? ContentType,
+ [property: JsonPropertyName("length")] int Length,
+ [property: JsonPropertyName("sha256Hex")] string Sha256Hex);
+
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+[JsonSerializable(typeof(RawUploadReply))]
+internal sealed partial class UploadJson : JsonSerializerContext;
diff --git a/samples/Demo.Api/GraphQL/DemoGraphQL.cs b/samples/Demo.Api/GraphQL/DemoGraphQL.cs
new file mode 100644
index 0000000..463b32c
--- /dev/null
+++ b/samples/Demo.Api/GraphQL/DemoGraphQL.cs
@@ -0,0 +1,91 @@
+using HotChocolate.Subscriptions;
+
+namespace Demo.Api.GraphQL;
+
+///
+/// HotChocolate GraphQL surface for the demo: a simple in-memory book store with queries,
+/// mutations, and a subscription topic for newly-created books. Subscriptions ride
+/// graphql-ws so the Tap WebSocket capture can pick them up.
+///
+public static class DemoGraphQL
+{
+ public static void AddServices(WebApplicationBuilder builder)
+ {
+ builder.Services.AddSingleton();
+
+ builder.Services
+ .AddGraphQLServer()
+ .AddInMemorySubscriptions()
+ .AddQueryType()
+ .AddMutationType()
+ .AddSubscriptionType();
+ }
+
+ public static void MapEndpoints(WebApplication app)
+ {
+ app.MapGraphQL("/graphql");
+ }
+}
+
+public sealed record Book(int Id, string Title, string Author, int Year);
+
+/// Thread-safe in-memory store. A singleton service so the GraphQL resolvers can
+/// hold long-lived state without dragging in EF Core.
+public sealed class BookStore
+{
+ private readonly List _books = new()
+ {
+ new(1, "The Pragmatic Programmer", "Andy Hunt", 1999),
+ new(2, "Clean Code", "Robert C. Martin", 2008),
+ new(3, "Refactoring", "Martin Fowler", 1999),
+ };
+ private int _nextId = 4;
+ private readonly Lock _gate = new();
+
+ public IReadOnlyList All()
+ {
+ lock (_gate) return _books.ToArray();
+ }
+
+ public Book? Find(int id)
+ {
+ lock (_gate) return _books.FirstOrDefault(b => b.Id == id);
+ }
+
+ public Book Add(string title, string author, int year)
+ {
+ lock (_gate)
+ {
+ var book = new Book(_nextId++, title, author, year);
+ _books.Add(book);
+ return book;
+ }
+ }
+}
+
+public sealed class Query
+{
+ public IReadOnlyList GetBooks([Service] BookStore store) => store.All();
+ public Book? GetBook(int id, [Service] BookStore store) => store.Find(id);
+ public string Hello(string? name = null) => $"Hello, {name ?? "world"}!";
+}
+
+public sealed class Mutation
+{
+ public async Task AddBook(string title, string author, int year,
+ [Service] BookStore store,
+ [Service] ITopicEventSender sender,
+ CancellationToken ct)
+ {
+ var book = store.Add(title, author, year);
+ await sender.SendAsync(nameof(Subscription.BookAdded), book, ct);
+ return book;
+ }
+}
+
+public sealed class Subscription
+{
+ [Subscribe]
+ [Topic]
+ public Book BookAdded([EventMessage] Book book) => book;
+}
diff --git a/samples/Demo.Api/Program.cs b/samples/Demo.Api/Program.cs
new file mode 100644
index 0000000..ec3dd7d
--- /dev/null
+++ b/samples/Demo.Api/Program.cs
@@ -0,0 +1,56 @@
+// Demo.Api — exercises the surface area Tap captures and replays:
+// * /demo/methods/* — every HTTP verb (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
+// * /demo/content/* — common response content types (json, xml, text, html, png, binary, …)
+// * /demo/upload/* — request content types (json, form-urlencoded, multipart, raw)
+// * /demo/stream/sse — Server-Sent Events
+// * /demo/stream/ws — WebSocket echo + heartbeat
+// * /graphql — HotChocolate GraphQL (query + mutation + subscription over WS)
+// * /connect/token, — OpenIddict OAuth2/OIDC (client_credentials + ROPC) with discovery,
+// /connect/userinfo, userinfo, and introspection so Tap auth flows have a real OIDC IDP
+// /connect/introspect, to talk to.
+// /.well-known/openid-configuration
+
+using Demo.Api;
+using Demo.Api.Auth;
+using Demo.Api.Endpoints;
+using Demo.Api.GraphQL;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
+ p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
+
+DemoAuth.AddServices(builder);
+DemoGraphQL.AddServices(builder);
+
+var app = builder.Build();
+
+app.UseCors();
+app.UseWebSockets();
+app.UseAuthentication();
+app.UseAuthorization();
+
+// Seed OpenIddict client + a test user on first start.
+using (var scope = app.Services.CreateScope())
+{
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ await DemoAuth.SeedAsync(scope.ServiceProvider);
+}
+
+app.MapGet("/", () => Results.Json(new
+{
+ name = "Demo.Api",
+ docs = "/demo/index",
+ graphql = "/graphql",
+ oidc = "/.well-known/openid-configuration",
+}));
+
+DemoEndpoints.MapIndex(app);
+MethodsEndpoints.Map(app);
+ContentEndpoints.Map(app);
+UploadEndpoints.Map(app);
+StreamingEndpoints.Map(app);
+DemoAuth.MapEndpoints(app);
+DemoGraphQL.MapEndpoints(app);
+
+app.Run();
diff --git a/samples/Demo.Api/Properties/launchSettings.json b/samples/Demo.Api/Properties/launchSettings.json
new file mode 100644
index 0000000..a1d7943
--- /dev/null
+++ b/samples/Demo.Api/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/samples/Sample.AppHost/Program.cs b/samples/Sample.AppHost/Program.cs
index 0771f4d..d1c975e 100644
--- a/samples/Sample.AppHost/Program.cs
+++ b/samples/Sample.AppHost/Program.cs
@@ -78,6 +78,14 @@
.WithEnvironment("Jwt__Issuer", jwtIssuer)
.WithEnvironment("Jwt__Audience", jwtAudience);
+// Demo.Api — broad surface area for testing Tap features end-to-end:
+// HTTP verbs + content types, file uploads, SSE, WebSockets, GraphQL (HotChocolate),
+// and a real OAuth2/OIDC token endpoint (OpenIddict, in-memory store).
+// Runs alongside Sample.Api so any scenario tap can be pointed at either.
+builder.AddProject("demo-api")
+ .WithHttpEndpoint(port: 5180, name: "http")
+ .WithExternalHttpEndpoints();
+
// Collect tap entries to pass to the Sample.Client. Local-only proxies use the
// localhost URL directly (no host filter on the YARP route); tunnel-routed taps
// need the public URL since YARP filters by Host header.
diff --git a/samples/Sample.AppHost/Sample.AppHost.csproj b/samples/Sample.AppHost/Sample.AppHost.csproj
index 7b27085..b989084 100644
--- a/samples/Sample.AppHost/Sample.AppHost.csproj
+++ b/samples/Sample.AppHost/Sample.AppHost.csproj
@@ -1,5 +1,4 @@
-
-
+
net10.0
@@ -9,13 +8,13 @@
-
-
-
+
+
+
diff --git a/samples/Studio.AppHost/Program.cs b/samples/Studio.AppHost/Program.cs
new file mode 100644
index 0000000..69b1a10
--- /dev/null
+++ b/samples/Studio.AppHost/Program.cs
@@ -0,0 +1,102 @@
+// Studio.AppHost — local dev runner for Tap Studio.
+//
+// studio-api REST + SSE backend (Tap.Studio, ASP.NET Core)
+// studio-ui Vite dev server hosting the React UI (src/ui-studio/)
+//
+// Aspire allocates the ports for both resources; the Vite proxy URL is wired up via
+// a reference to studio-api so the UI always points at the right place.
+//
+// Override the workspace path with:
+// STUDIO_WORKSPACE=/path/to/your/repo aspire run
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+// Resolve the workspace root. Default: the bundled sample-workspace under samples/.
+var workspaceRoot = Environment.GetEnvironmentVariable("STUDIO_WORKSPACE")
+ ?? Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "sample-workspace"));
+
+// Demo.Api — the canonical upstream for trying out Tap Studio. Exercises every
+// content type / HTTP verb plus SSE, WebSockets, GraphQL, and OAuth2/OIDC. Runs
+// independently of Studio so workspace requests can target it (no .WithReference —
+// the studio doesn't talk to the demo API directly; the user's request files do).
+// Port is dynamic; the resolved host:port (no scheme) is forwarded to studio-api as
+// DEMO_API_URL so sample-workspace/apis/demo.api.md can pick it up via
+// `{{DEMO_API_URL}}`. The renderer prepends `http://` for HTTP requests and `ws://`
+// for WebSocket requests, so the same variable serves both transports.
+var demoApi = builder.AddProject("demo-api")
+ .WithHttpEndpoint(name: "http")
+ .WithExternalHttpEndpoints();
+
+// Dev-only fallbacks for the sample auth profiles. Each one is referenced by a
+// `{{env:NAME}}` token in sample-workspace/auth/*; without a value the env
+// provider raises E_PROVIDER_RESOLUTION_FAILED on first execute. Real usage should
+// set these in the user's shell; we honor that and only inject a deterministic dev
+// fallback when missing so the sample is runnable out of the box.
+static string DemoSecret(string name, string fallback)
+ => Environment.GetEnvironmentVariable(name) ?? fallback;
+
+var jwtSecret = DemoSecret("DEMO_JWT_SECRET", "studio-demo-jwt-secret-not-for-production-use");
+var demoBearer = DemoSecret("DEMO_BEARER_TOKEN", "studio-demo-bearer-token-not-for-production-use");
+var demoBasicPassword = DemoSecret("DEMO_BASIC_PASSWORD", "studio-demo-basic-password-not-for-production-use");
+var demoApiKey = DemoSecret("DEMO_API_KEY", "studio-demo-api-key-not-for-production-use");
+
+// Tap reads from the host environment via the workspace's `env` provider (declared in
+// tap.md). Two allowlists gate which names that provider exposes: TAP_VARS_ALLOWED
+// carries names whose values may be displayed; TAP_SECRETS_ALLOWED carries names whose
+// values stay masked but resolve normally when referenced as `{{env:NAME}}`. The sample
+// workspace pulls DEMO_* + USER + AZURE_*; only DEMO_API_URL + USER are "showable" — the
+// rest are credentials. Override these on your shell to broaden the surface.
+var studio = builder.AddProject("studio-api")
+ .WithHttpEndpoint(name: "http")
+ .WithEnvironment("Studio__WorkspaceRoot", workspaceRoot)
+ .WithEnvironment("DEMO_API_URL", demoApi.GetEndpoint("http").Property(EndpointProperty.HostAndPort))
+ .WithEnvironment("DEMO_JWT_SECRET", jwtSecret)
+ .WithEnvironment("DEMO_BEARER_TOKEN", demoBearer)
+ .WithEnvironment("DEMO_BASIC_PASSWORD", demoBasicPassword)
+ .WithEnvironment("DEMO_API_KEY", demoApiKey)
+ .WithEnvironment("TAP_VARS_ALLOWED", "DEMO_API_URL,USER")
+ .WithEnvironment("TAP_SECRETS_ALLOWED", "DEMO_BEARER_TOKEN,DEMO_BASIC_PASSWORD,DEMO_API_KEY,DEMO_JWT_SECRET,AZURE_*")
+ .WaitFor(demoApi)
+ .WithExternalHttpEndpoints();
+
+// Demo.Api must register Studio's OAuth callback URL on its seeded client(s). The
+// callback lives at /api/auth/callback, and the Studio base URL is
+// Aspire-allocated per run — so we forward it via env var and Demo.Api reads it at
+// seed time. Without this, the client's registered redirect_uri wouldn't match the
+// one Studio sends on the authorize request, and OpenIddict would reject the flow.
+demoApi.WithEnvironment("STUDIO_CALLBACK_URL",
+ ReferenceExpression.Create($"{studio.GetEndpoint("http")}/api/auth/callback"));
+
+// Vite UI — VITE_STUDIO_API_URL is resolved at start time from the studio-api endpoint.
+var uiDir = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "src", "ui-studio"));
+var studioUi = builder.AddViteApp("studio-ui", uiDir, "dev")
+ .WithYarn()
+ .WithEnvironment("VITE_STUDIO_API_URL", studio.GetEndpoint("http"))
+ .WaitFor(studio)
+ .WithExternalHttpEndpoints();
+
+// Optionally launch the Tauri desktop shell as a native window over the running
+// studio-ui, so `aspire run` can bring up the whole desktop dev loop. Off by
+// default — enable with RunDesktop=true (env var, user-secrets, or appsettings):
+// RunDesktop=true aspire run
+// The shell reads STUDIO_DESKTOP_URL, skips its bundled sidecar, and points the
+// webview straight at studio-ui — so you get Vite hot reload and the same
+// Aspire-managed studio-api/demo-api the browser dev loop uses.
+if (bool.TryParse(builder.Configuration["RunDesktop"], out var runDesktop) && runDesktop)
+{
+ var desktopDir = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "src", "desktop"));
+ builder.AddExecutable("studio-desktop", "yarn", desktopDir, "dev")
+ .WithEnvironment("STUDIO_DESKTOP_URL", studioUi.GetEndpoint("http"))
+ // DCP spawns executables with a sanitized environment; forward the real
+ // HOME so corepack (~/.cache), cargo (~/.cargo) and rustup (~/.rustup)
+ // resolve for `tauri dev`. (src/desktop also uses the node-modules linker
+ // so Yarn doesn't depend on a HOME-derived global PnP cache.)
+ .WithEnvironment(ctx =>
+ {
+ if (Environment.GetEnvironmentVariable("HOME") is { Length: > 0 } home)
+ ctx.EnvironmentVariables["HOME"] = home;
+ })
+ .WaitFor(studioUi);
+}
+
+builder.Build().Run();
diff --git a/samples/Studio.AppHost/Properties/launchSettings.json b/samples/Studio.AppHost/Properties/launchSettings.json
new file mode 100644
index 0000000..ebe8973
--- /dev/null
+++ b/samples/Studio.AppHost/Properties/launchSettings.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17101;http://localhost:15101",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21101",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22101"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15102",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19101",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20101"
+ }
+ }
+ }
+}
diff --git a/samples/Studio.AppHost/Studio.AppHost.csproj b/samples/Studio.AppHost/Studio.AppHost.csproj
new file mode 100644
index 0000000..e9c4ad0
--- /dev/null
+++ b/samples/Studio.AppHost/Studio.AppHost.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ Exe
+ true
+ tap-studio-apphost
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Studio.AppHost/appsettings.json b/samples/Studio.AppHost/appsettings.json
new file mode 100644
index 0000000..955dc8e
--- /dev/null
+++ b/samples/Studio.AppHost/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ },
+ "// RunDesktop": "Set true to also launch the Tauri desktop shell over studio-ui. Prefer flipping it locally (user-secrets / env) rather than committing true, so aspire run doesn't open a GUI for everyone.",
+ "RunDesktop": false
+}
diff --git a/samples/aspire.config.json b/samples/aspire.config.json
index bf04029..3983e9e 100644
--- a/samples/aspire.config.json
+++ b/samples/aspire.config.json
@@ -1,5 +1,5 @@
{
"appHost": {
- "path": "Sample.AppHost/Sample.AppHost.csproj"
+ "path": "Studio.AppHost/Studio.AppHost.csproj"
}
}
\ No newline at end of file
diff --git a/samples/sample-workspace/auth/azure-cli-obo.auth.md b/samples/sample-workspace/auth/azure-cli-obo.auth.md
new file mode 100644
index 0000000..6b2225a
--- /dev/null
+++ b/samples/sample-workspace/auth/azure-cli-obo.auth.md
@@ -0,0 +1,39 @@
+---
+kind: auth
+name: Azure CLI · On-Behalf-Of
+type: azure-cli
+flow: on_behalf_of
+tenant: '{{env:AZURE_TENANT_ID}}'
+userScope: 'api://{{env:AZURE_MIDTIER_APP_ID}}/access_as_user'
+clientId: '{{env:AZURE_MIDTIER_APP_ID}}'
+clientSecret: '{{env:AZURE_MIDTIER_APP_SECRET}}'
+scopes:
+ - 'api://{{env:AZURE_DOWNSTREAM_APP_ID}}/.default'
+tags:
+ - azure
+ - obo
+---
+
+# Azure CLI · On-Behalf-Of
+
+Same `azure-cli` type as the basic profile, with `flow: on_behalf_of` to chain an OBO
+exchange on top of the az step:
+
+1. **az step.** `az account get-access-token --scope ` mints a user token
+ targeting the middle-tier API (`AZURE_MIDTIER_APP_ID`). Tenant is pinned so the
+ token is issued by the right AAD instance.
+2. **OBO exchange.** Tap POSTs the user token to
+ `https://login.microsoftonline.com//oauth2/v2.0/token` with
+ `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` and the middle-tier app's
+ client credentials. The token endpoint returns a downstream token whose audience is
+ `AZURE_DOWNSTREAM_APP_ID`.
+
+Required env vars (allowlist them in `tap.md`):
+
+- `AZURE_TENANT_ID` — tenant id or domain.
+- `AZURE_MIDTIER_APP_ID` — application (client) id of the middle-tier API.
+- `AZURE_MIDTIER_APP_SECRET` — client secret used for the OBO exchange.
+- `AZURE_DOWNSTREAM_APP_ID` — application id of the downstream API.
+
+Run `az login` first. If the middle-tier hasn't granted itself permission to call the
+downstream API, AAD returns `AADSTS65001` (consent missing).
diff --git a/samples/sample-workspace/auth/azure-cli.auth.md b/samples/sample-workspace/auth/azure-cli.auth.md
new file mode 100644
index 0000000..971c3e5
--- /dev/null
+++ b/samples/sample-workspace/auth/azure-cli.auth.md
@@ -0,0 +1,17 @@
+---
+kind: auth
+name: Azure CLI (Microsoft Graph)
+type: azure-cli
+scope: https://graph.microsoft.com/.default
+tags:
+ - azure
+ - azure-cli
+---
+
+# Azure CLI — Microsoft Graph
+
+Shells out to `az account get-access-token --scope https://graph.microsoft.com/.default`
+and surfaces the resulting access token. Requires the user to have run `az login`
+locally; Tap doesn't manage Azure credentials itself.
+
+Swap `scope:` to `resource: https://management.azure.com/` for ARM-flavoured (v1) tokens.
diff --git a/samples/sample-workspace/auth/demo-apikey.auth.md b/samples/sample-workspace/auth/demo-apikey.auth.md
new file mode 100644
index 0000000..2640508
--- /dev/null
+++ b/samples/sample-workspace/auth/demo-apikey.auth.md
@@ -0,0 +1,17 @@
+---
+kind: auth
+name: Demo API Key
+type: apiKey
+in: header
+apiKeyName: X-API-Key
+apiKeyValue: '{{env:DEMO_API_KEY}}'
+tags:
+ - demo
+ - apiKey
+---
+
+# Demo API Key
+
+Injects `X-API-Key: ` on every request. The value resolves through the
+workspace's `env` provider — set `DEMO_API_KEY=…` before launching the AppHost. Switch
+`in:` to `query` (or `cookie`) to put the key elsewhere.
diff --git a/samples/sample-workspace/auth/demo-basic.auth.md b/samples/sample-workspace/auth/demo-basic.auth.md
new file mode 100644
index 0000000..e7e30ba
--- /dev/null
+++ b/samples/sample-workspace/auth/demo-basic.auth.md
@@ -0,0 +1,8 @@
+---
+kind: auth
+name: Demo Basic
+type: basic
+username: alice
+password: '{{DEMO_BASIC_PASSWORD}}'
+tags: [demo, basic]
+---
diff --git a/samples/sample-workspace/auth/demo-bearer.auth.md b/samples/sample-workspace/auth/demo-bearer.auth.md
new file mode 100644
index 0000000..4fa11c5
--- /dev/null
+++ b/samples/sample-workspace/auth/demo-bearer.auth.md
@@ -0,0 +1,6 @@
+---
+kind: auth
+name: Demo bearer
+type: bearer
+token: ey123
+---
diff --git a/samples/sample-workspace/auth/demo-custom.auth.md b/samples/sample-workspace/auth/demo-custom.auth.md
new file mode 100644
index 0000000..afed728
--- /dev/null
+++ b/samples/sample-workspace/auth/demo-custom.auth.md
@@ -0,0 +1,18 @@
+---
+kind: auth
+name: Demo Custom headers
+type: custom
+headers:
+ X-Tap-Sample: studio-demo
+ X-Trace-Id: 'req-{{env:USER}}'
+ Authorization: 'Bearer {{env:DEMO_BEARER_TOKEN}}'
+tags:
+ - demo
+ - custom
+---
+
+# Demo Custom headers
+
+Arbitrary headers, all run through the variable + secret interpolator. Useful when an
+upstream needs multiple cooperating headers (e.g. a bearer + a tenant id + a trace id)
+that don't map cleanly onto Tap's per-type schemas.
diff --git a/samples/sample-workspace/auth/demo-oauth-cc.auth.md b/samples/sample-workspace/auth/demo-oauth-cc.auth.md
new file mode 100644
index 0000000..b03300d
--- /dev/null
+++ b/samples/sample-workspace/auth/demo-oauth-cc.auth.md
@@ -0,0 +1,24 @@
+---
+kind: auth
+name: Demo OAuth2 (client credentials)
+type: oauth2
+flow: client_credentials
+tokenUrl: http://{{DEMO_API_URL}}/connect/token
+clientId: tap-demo
+clientSecret: tap-demo-secret
+scopes:
+ - api
+tags:
+ - demo
+ - oauth2
+---
+
+# Demo OAuth2 — client credentials
+
+Server-to-server grant against the Demo.Api OpenIddict instance. The token URL embeds
+`{{DEMO_API_URL}}` which the workspace's `env` provider resolves from the host's
+`DEMO_API_URL` (allowlisted via `TAP_VARS_ALLOWED`). Provider tokens like
+`{{env:NAME}}` and `{{dev:NAME}}` resolve the same way at this point.
+
+Useful smoke test: hit **Execute** in the right pane. The runner POSTs to
+`/connect/token` and returns the access token decoded inline.
diff --git a/samples/sample-workspace/auth/demo-oauth-pkce.auth.md b/samples/sample-workspace/auth/demo-oauth-pkce.auth.md
new file mode 100644
index 0000000..028f8cd
--- /dev/null
+++ b/samples/sample-workspace/auth/demo-oauth-pkce.auth.md
@@ -0,0 +1,13 @@
+---
+kind: auth
+name: Demo OAuth2 (authorization code + PKCE)
+type: oauth2
+flow: authorization_code_pkce
+useDiscovery: true
+authority: http://{{DEMO_API_URL}}
+authorizeUrl: http://{{DEMO_API_URL}}/connect/authorize
+tokenUrl: http://{{DEMO_API_URL}}/connect/token
+clientId: tap-demo-public
+scopes: [openid, profile, email, api, offline_access]
+tags: [demo, oauth2, pkce]
+---
diff --git a/samples/sample-workspace/auth/demo-oauth-ropc.auth.md b/samples/sample-workspace/auth/demo-oauth-ropc.auth.md
new file mode 100644
index 0000000..f04d94f
--- /dev/null
+++ b/samples/sample-workspace/auth/demo-oauth-ropc.auth.md
@@ -0,0 +1,16 @@
+---
+kind: auth
+name: Demo OAuth2 (password / ROPC)
+type: oauth2
+flow: password
+useDiscovery: true
+authority: http://{{DEMO_API_URL}}
+authorizeUrl: http://{{DEMO_API_URL}}/connect/authorize
+tokenUrl: http://{{DEMO_API_URL}}/connect/token
+clientId: tap-demo
+clientSecret: tap-demo-secret
+scopes: [openid, profile, email, api, offline_access]
+username: alice
+password: wonderland
+tags: [demo, oauth2]
+---
diff --git a/samples/sample-workspace/auth/foo.auth.md b/samples/sample-workspace/auth/foo.auth.md
new file mode 100644
index 0000000..fe83800
--- /dev/null
+++ b/samples/sample-workspace/auth/foo.auth.md
@@ -0,0 +1,6 @@
+---
+kind: auth
+name: foo
+type: bearer
+token: '{{env:MY_TOKEN}}'
+---
diff --git a/samples/sample-workspace/auth/github-cli.auth.md b/samples/sample-workspace/auth/github-cli.auth.md
new file mode 100644
index 0000000..a58daab
--- /dev/null
+++ b/samples/sample-workspace/auth/github-cli.auth.md
@@ -0,0 +1,26 @@
+---
+kind: auth
+name: GitHub CLI
+type: github
+mode: gh-cli
+tags:
+ - github
+ - cli
+---
+
+# GitHub CLI
+
+Shells out to `gh auth token` on this machine and uses the returned token as a
+`Authorization: Bearer …` header. Tap also adds `X-GitHub-Api-Version: 2022-11-28`
+and `Accept: application/vnd.github+json` automatically.
+
+## Prerequisites
+
+```shell
+brew install gh # or: winget install GitHub.cli
+gh auth login # follow the prompt; pick scopes that fit the requests
+```
+
+No fields to fill in — the runner picks up whichever account is currently active
+in the GitHub CLI. Switch accounts with `gh auth switch` and re-execute the
+profile to refresh.
diff --git a/samples/sample-workspace/auth/jwt-hs256.auth.md b/samples/sample-workspace/auth/jwt-hs256.auth.md
new file mode 100644
index 0000000..ac8069f
--- /dev/null
+++ b/samples/sample-workspace/auth/jwt-hs256.auth.md
@@ -0,0 +1,13 @@
+---
+kind: auth
+name: JWT (HS256)
+type: jwt
+algorithm: HS256
+issuer: tap-studio
+audience: demo-api
+subject: alice
+key: '{{JWT_SECRET}}'
+expiresIn: 600
+payload: "{\n \"scope\": \"read:items write:items\",\n \"roles\": [\"demo-user\"],\n \"tenant\": \"studio-demo\"\n}\n"
+tags: [jwt, demo]
+---
diff --git a/samples/sample-workspace/auth/test.auth.md b/samples/sample-workspace/auth/test.auth.md
new file mode 100644
index 0000000..2bc9351
--- /dev/null
+++ b/samples/sample-workspace/auth/test.auth.md
@@ -0,0 +1,6 @@
+---
+kind: auth
+name: Test
+type: bearer
+token: '{{env:MY_TOKEN}}'
+---
diff --git a/samples/sample-workspace/collections/demo/_collection.md b/samples/sample-workspace/collections/demo/_collection.md
new file mode 100644
index 0000000..6eaa1fe
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/_collection.md
@@ -0,0 +1,30 @@
+---
+kind: collection
+name: Demo
+baseUrl: '{{DEMO_API_URL}}'
+defaultHeaders:
+ Accept: application/json
+ User-Agent: tap-studio-demo/0.1
+tags: [demo, local]
+stages:
+- name: Dev
+- name: uat
+ baseUrl: http://q.demo.test
+- name: prod
+ baseUrl: http://p.demo.test
+---
+# Demo
+
+Sample requests against `Demo.Api` (the local upstream registered by
+`samples/Studio.AppHost`). The collection owns the baseUrl and shared headers —
+every request inside inherits them automatically.
+
+Sub-folders mirror the demo surface:
+
+- **methods/** — every HTTP verb + a status-code playground
+- **content/** — response content-type round trips (JSON, XML, YAML, HTML, CSS,
+ JS, CSV, Markdown, PNG, JPEG, SVG, binary, problem, empty, large, slow, 4xx/5xx)
+- **uploads/** — request content-type round trips (JSON, form, multipart, raw)
+- **streaming/** — SSE + WebSocket
+- **graphql/** — HotChocolate queries, mutations, and the Nitro UI link
+- **auth/** — OAuth2 client-credentials and ROPC against `/demo/auth/whoami`
diff --git a/samples/sample-workspace/collections/demo/auth/whoami-anonymous.req.md b/samples/sample-workspace/collections/demo/auth/whoami-anonymous.req.md
new file mode 100644
index 0000000..577bda3
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/auth/whoami-anonymous.req.md
@@ -0,0 +1,16 @@
+---
+kind: request
+name: Whoami (no auth, expect 401)
+auth: none
+tags: [demo, auth, anonymous, 401]
+---
+
+```http
+GET /demo/auth/whoami
+```
+
+# Unauthenticated — should return 401
+
+`/demo/auth/whoami` requires a valid bearer token. Hitting it with `auth: none`
+verifies that the OpenIddict validation handler is wired up and rejects the
+request — useful as a negative test.
diff --git a/samples/sample-workspace/collections/demo/auth/whoami-pkce.req.md b/samples/sample-workspace/collections/demo/auth/whoami-pkce.req.md
new file mode 100644
index 0000000..3743b28
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/auth/whoami-pkce.req.md
@@ -0,0 +1,23 @@
+---
+kind: request
+name: Whoami (auth code + PKCE)
+auth: ../../../auth/demo-oauth-pkce.auth.md
+tags: [demo, auth, oauth2, pkce]
+---
+
+```http
+GET /demo/auth/whoami
+```
+
+# Authenticated — authorization code + PKCE
+
+Interactive flow. The first time you hit **Execute**:
+
+1. Tap opens a popup pointed at Demo.Api's `/connect/authorize`.
+2. Sign in as `alice` / `wonderland`.
+3. The popup redirects to Studio's `/api/auth/callback`, the code is exchanged,
+ and the access token + refresh token land in Studio's token cache.
+4. This request goes out with `Authorization: Bearer `.
+
+Subsequent executions reuse the cached token (silently refreshing when it's near
+expiry). Hit the refresh icon in the auth's Try-It panel to force re-auth.
diff --git a/samples/sample-workspace/collections/demo/auth/whoami-ropc.req.md b/samples/sample-workspace/collections/demo/auth/whoami-ropc.req.md
new file mode 100644
index 0000000..4b10842
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/auth/whoami-ropc.req.md
@@ -0,0 +1,14 @@
+---
+kind: request
+name: Whoami (password / ROPC)
+auth: ../../../auth/demo-oauth-ropc.auth.md
+tags: [demo, auth, oauth2, password]
+---
+# Authenticated request — password (ROPC)
+
+The ROPC profile mints a user token (`alice` / `wonderland`) and stamps the
+access token onto this request.
+
+```http
+GET /demo/auth/whoami
+```
diff --git a/samples/sample-workspace/collections/demo/content/binary.req.md b/samples/sample-workspace/collections/demo/content/binary.req.md
new file mode 100644
index 0000000..791b370
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/binary.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: Content — Binary
+tags: [demo, content, binary]
+---
+# Binary response
+
+Returns 256 bytes of `application/octet-stream` (incrementing values). Useful for
+exercising the inspector's binary preview / hex view.
+
+```http
+GET /demo/content/binary
+```
diff --git a/samples/sample-workspace/collections/demo/content/css.req.md b/samples/sample-workspace/collections/demo/content/css.req.md
new file mode 100644
index 0000000..c37f9d1
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/css.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — CSS
+tags: [demo, content, css]
+---
+# CSS response
+
+Returns `text/css` — exercises CSS selector and property highlighting.
+
+```http
+GET /demo/content/css
+```
diff --git a/samples/sample-workspace/collections/demo/content/csv.req.md b/samples/sample-workspace/collections/demo/content/csv.req.md
new file mode 100644
index 0000000..719b637
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/csv.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — CSV
+tags: [demo, content, csv]
+---
+# CSV response
+
+Returns `text/csv` — a tiny 3-row table.
+
+```http
+GET /demo/content/csv
+```
diff --git a/samples/sample-workspace/collections/demo/content/empty.req.md b/samples/sample-workspace/collections/demo/content/empty.req.md
new file mode 100644
index 0000000..21722ae
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/empty.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: Content — Empty (204)
+tags: [demo, content, empty, status]
+---
+# 204 No Content
+
+Returns `204 No Content` with no body — verifies the "Empty body." placeholder
+renders in the Body tab.
+
+```http
+GET /demo/content/empty
+```
diff --git a/samples/sample-workspace/collections/demo/content/html.req.md b/samples/sample-workspace/collections/demo/content/html.req.md
new file mode 100644
index 0000000..a4850bd
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/html.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — HTML
+tags: [demo, content, html]
+---
+# HTML response
+
+Returns `text/html` — exercises HTML highlighting (tags, attributes, embedded text).
+
+```http
+GET /demo/content/html
+```
diff --git a/samples/sample-workspace/collections/demo/content/javascript.req.md b/samples/sample-workspace/collections/demo/content/javascript.req.md
new file mode 100644
index 0000000..285a34d
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/javascript.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — JavaScript
+tags: [demo, content, javascript]
+---
+# JavaScript response
+
+Returns `application/javascript` — exercises JS syntax highlighting.
+
+```http
+GET /demo/content/javascript
+```
diff --git a/samples/sample-workspace/collections/demo/content/jpeg.req.md b/samples/sample-workspace/collections/demo/content/jpeg.req.md
new file mode 100644
index 0000000..b5e357f
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/jpeg.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — JPEG
+tags: [demo, content, image]
+---
+# JPEG response
+
+Returns `image/jpeg` — same inline image preview path as PNG, different decoder.
+
+```http
+GET /demo/content/jpeg
+```
diff --git a/samples/sample-workspace/collections/demo/content/json.req.md b/samples/sample-workspace/collections/demo/content/json.req.md
new file mode 100644
index 0000000..8895f6c
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/json.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: Content — JSON
+tags: [demo, content, json]
+---
+# JSON response
+
+Source-of-truth shape served by `Results.Json`. Exercises pretty-printing and
+JSON syntax highlighting in the Body tab.
+
+```http
+GET /demo/content/json
+```
diff --git a/samples/sample-workspace/collections/demo/content/large.req.md b/samples/sample-workspace/collections/demo/content/large.req.md
new file mode 100644
index 0000000..91362b9
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/large.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — Large (128 KiB)
+tags: [demo, content, large]
+---
+# 128 KiB binary
+
+Stress-tests the 1 MB body cap + binary preview in the inspector.
+
+```http
+GET /demo/content/large/128
+```
diff --git a/samples/sample-workspace/collections/demo/content/markdown.req.md b/samples/sample-workspace/collections/demo/content/markdown.req.md
new file mode 100644
index 0000000..188cc28
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/markdown.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — Markdown
+tags: [demo, content, markdown]
+---
+# Markdown response
+
+Returns `text/markdown` — exercises markdown highlighting (headings, lists).
+
+```http
+GET /demo/content/markdown
+```
diff --git a/samples/sample-workspace/collections/demo/content/png.req.md b/samples/sample-workspace/collections/demo/content/png.req.md
new file mode 100644
index 0000000..fe11037
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/png.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: Content — PNG
+tags: [demo, content, image]
+---
+# PNG response
+
+Tiny 3×3 red PNG. Verifies binary body capture and the inline ` ` preview
+(via a base64 `data:` URL) in the Body tab.
+
+```http
+GET /demo/content/png
+```
diff --git a/samples/sample-workspace/collections/demo/content/problem.req.md b/samples/sample-workspace/collections/demo/content/problem.req.md
new file mode 100644
index 0000000..dc05d67
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/problem.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — Problem+JSON
+tags: [demo, content, problem]
+---
+# RFC 7807 problem details
+
+Verifies non-2xx + `application/problem+json` handling.
+
+```http
+GET /demo/content/problem
+```
diff --git a/samples/sample-workspace/collections/demo/content/slow.req.md b/samples/sample-workspace/collections/demo/content/slow.req.md
new file mode 100644
index 0000000..2102c1e
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/slow.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — Slow (2s)
+tags: [demo, content, latency]
+---
+# Artificial 2 s delay
+
+Useful for verifying spinners, cancellation, and the timing column.
+
+```http
+GET /demo/content/slow/2000
+```
diff --git a/samples/sample-workspace/collections/demo/content/status-404.req.md b/samples/sample-workspace/collections/demo/content/status-404.req.md
new file mode 100644
index 0000000..9f3edb3
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/status-404.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: Status — 404 Not Found
+tags: [demo, content, status]
+---
+# 404 Not Found
+
+Returns a `404` so you can verify the yellow status pill and that the JSON
+status body renders correctly.
+
+```http
+GET /demo/methods/status/404
+```
diff --git a/samples/sample-workspace/collections/demo/content/status-500.req.md b/samples/sample-workspace/collections/demo/content/status-500.req.md
new file mode 100644
index 0000000..bb9c32b
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/status-500.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Status — 500 Server Error
+tags: [demo, content, status]
+---
+# 500 Server Error
+
+Returns a `500` so you can verify the red status pill for 5xx responses.
+
+```http
+GET /demo/methods/status/500
+```
diff --git a/samples/sample-workspace/collections/demo/content/svg.req.md b/samples/sample-workspace/collections/demo/content/svg.req.md
new file mode 100644
index 0000000..f3169ee
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/svg.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: Content — SVG
+tags: [demo, content, image, xml]
+---
+# SVG response
+
+Returns `image/svg+xml`. SVG is structured XML, so the Body tab highlights it as
+XML (folding by element, attributes shown distinct).
+
+```http
+GET /demo/content/svg
+```
diff --git a/samples/sample-workspace/collections/demo/content/text.req.md b/samples/sample-workspace/collections/demo/content/text.req.md
new file mode 100644
index 0000000..4006534
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/text.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — Plain text
+tags: [demo, content, text]
+---
+# Plain text response
+
+Returns `text/plain` — no highlighting, raw content rendered as-is.
+
+```http
+GET /demo/content/text
+```
diff --git a/samples/sample-workspace/collections/demo/content/xml.req.md b/samples/sample-workspace/collections/demo/content/xml.req.md
new file mode 100644
index 0000000..d992e3f
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/xml.req.md
@@ -0,0 +1,11 @@
+---
+kind: request
+name: Content — XML
+tags: [demo, content, xml]
+---
+# XML response
+
+```http
+GET /demo/content/xml
+Accept: application/xml
+```
diff --git a/samples/sample-workspace/collections/demo/content/yaml.req.md b/samples/sample-workspace/collections/demo/content/yaml.req.md
new file mode 100644
index 0000000..68d14e3
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/content/yaml.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Content — YAML
+tags: [demo, content, yaml]
+---
+# YAML response
+
+Returns `application/yaml` — exercises YAML highlighting (keys, scalars, indentation).
+
+```http
+GET /demo/content/yaml
+```
diff --git a/samples/sample-workspace/collections/demo/ggoog.req.md b/samples/sample-workspace/collections/demo/ggoog.req.md
new file mode 100644
index 0000000..62d774d
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/ggoog.req.md
@@ -0,0 +1,8 @@
+---
+kind: request
+name: ggoog
+---
+
+```http
+GET https://www.google.com/ddd
+```
diff --git a/samples/sample-workspace/collections/demo/google.req.md b/samples/sample-workspace/collections/demo/google.req.md
new file mode 100644
index 0000000..cbe07f2
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/google.req.md
@@ -0,0 +1,8 @@
+---
+kind: request
+name: Google
+---
+
+```http
+GET https://www.google.com
+```
diff --git a/samples/sample-workspace/collections/demo/graphql/add-book.req.md b/samples/sample-workspace/collections/demo/graphql/add-book.req.md
new file mode 100644
index 0000000..8d9ba62
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/graphql/add-book.req.md
@@ -0,0 +1,24 @@
+---
+kind: request
+name: GraphQL — add book (mutation)
+tags: [demo, graphql, mutation]
+---
+
+```http
+POST /graphql
+Content-Type: application/json
+
+{
+ "query": "mutation Add($t:String!,$a:String!,$y:Int!){ \n addBook(title:$t, author:$a, year:$y){ id title } }",
+ "variables": {
+ "t": "Designing Data-Intensive Applications",
+ "a": "Martin Kleppmann",
+ "y": 2017
+ }
+}
+```
+
+# Mutation — addBook
+
+Mutation also pushes onto the `BookAdded` subscription topic, so any live
+subscriber over graphql-ws gets the new book.
diff --git a/samples/sample-workspace/collections/demo/graphql/get-book.req.md b/samples/sample-workspace/collections/demo/graphql/get-book.req.md
new file mode 100644
index 0000000..95692ed
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/graphql/get-book.req.md
@@ -0,0 +1,19 @@
+---
+kind: request
+name: GraphQL — single book (variables)
+tags: [demo, graphql, query, variables]
+---
+# Single book by id
+
+Exercises GraphQL variables — the query is parameterised and the JSON body
+embeds `variables`.
+
+```http
+POST /graphql
+Content-Type: application/json
+
+{
+ "query": "query GetBook($id: Int!) { book(id: $id) { id title author year } }",
+ "variables": { "id": 2 }
+}
+```
diff --git a/samples/sample-workspace/collections/demo/graphql/list-books.req.md b/samples/sample-workspace/collections/demo/graphql/list-books.req.md
new file mode 100644
index 0000000..b618ef8
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/graphql/list-books.req.md
@@ -0,0 +1,15 @@
+---
+kind: request
+name: GraphQL — list books
+tags: [demo, graphql, query]
+---
+# Books query
+
+```http
+POST /graphql
+Content-Type: application/json
+
+{
+ "query": "{ books { id title author year } }"
+}
+```
diff --git a/samples/sample-workspace/collections/demo/methods/01-get.req.md b/samples/sample-workspace/collections/demo/methods/01-get.req.md
new file mode 100644
index 0000000..408e56a
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/methods/01-get.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: GET /demo/methods
+tags: [demo, methods, get]
+---
+# GET — echo
+
+Verbs round-trip: the handler echoes method, path, query, headers, and body. Use
+this to confirm verb dispatch + header forwarding through any Tap tunnel.
+
+```http
+GET /demo/methods/?env={{user.name}}
+```
diff --git a/samples/sample-workspace/collections/demo/methods/02-post.req.md b/samples/sample-workspace/collections/demo/methods/02-post.req.md
new file mode 100644
index 0000000..b13afb2
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/methods/02-post.req.md
@@ -0,0 +1,17 @@
+---
+kind: request
+name: POST /demo/methods
+tags: [demo, methods, post]
+---
+# POST — echo
+
+```http
+POST /demo/methods/
+Content-Type: application/json
+
+{
+ "user": "{{user.name}}",
+ "email": "{{user.email}}",
+ "verb": "POST"
+}
+```
diff --git a/samples/sample-workspace/collections/demo/methods/03-put.req.md b/samples/sample-workspace/collections/demo/methods/03-put.req.md
new file mode 100644
index 0000000..8387adb
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/methods/03-put.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: PUT /demo/methods
+tags: [demo, methods, put]
+---
+# PUT — echo
+
+```http
+PUT /demo/methods/
+Content-Type: application/json
+
+{ "replace": "the whole resource" }
+```
diff --git a/samples/sample-workspace/collections/demo/methods/04-patch.req.md b/samples/sample-workspace/collections/demo/methods/04-patch.req.md
new file mode 100644
index 0000000..93b4484
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/methods/04-patch.req.md
@@ -0,0 +1,15 @@
+---
+kind: request
+name: PATCH /demo/methods
+tags: [demo, methods, patch]
+---
+# PATCH — echo
+
+```http
+PATCH /demo/methods/
+Content-Type: application/json-patch+json
+
+[
+ { "op": "replace", "path": "/user/name", "value": "{{user.name}}" }
+]
+```
diff --git a/samples/sample-workspace/collections/demo/methods/05-delete.req.md b/samples/sample-workspace/collections/demo/methods/05-delete.req.md
new file mode 100644
index 0000000..9a71601
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/methods/05-delete.req.md
@@ -0,0 +1,10 @@
+---
+kind: request
+name: DELETE /demo/methods
+tags: [demo, methods, delete]
+---
+# DELETE — echo
+
+```http
+DELETE /demo/methods/
+```
diff --git a/samples/sample-workspace/collections/demo/methods/06-teapot.req.md b/samples/sample-workspace/collections/demo/methods/06-teapot.req.md
new file mode 100644
index 0000000..480711b
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/methods/06-teapot.req.md
@@ -0,0 +1,12 @@
+---
+kind: request
+name: Status 418 (teapot)
+tags: [demo, methods, status]
+---
+# Status playground
+
+Any status from the `{code}` path; 418 is the classic.
+
+```http
+GET /demo/methods/status/418
+```
diff --git a/samples/sample-workspace/collections/demo/streaming/sse.req.md b/samples/sample-workspace/collections/demo/streaming/sse.req.md
new file mode 100644
index 0000000..f7e8848
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/streaming/sse.req.md
@@ -0,0 +1,21 @@
+---
+kind: request
+name: Streaming — SSE
+tags: [demo, stream, sse]
+vars:
+ count:
+ default: "10"
+ description: How many events to emit
+ interval:
+ default: "500"
+ description: Milliseconds between events
+---
+# SSE — 10 events at 500 ms
+
+The response panel auto-switches to the **Events** tab. Click × on the response
+header to abort mid-stream.
+
+```http
+GET /demo/stream/sse?count={{count}}&interval={{interval}}
+Accept: text/event-stream
+```
diff --git a/samples/sample-workspace/collections/demo/streaming/ws.req.md b/samples/sample-workspace/collections/demo/streaming/ws.req.md
new file mode 100644
index 0000000..9e3a86e
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/streaming/ws.req.md
@@ -0,0 +1,20 @@
+---
+kind: request
+name: Streaming — WebSocket
+protocol: websocket
+tags: [demo, stream, websocket]
+---
+# WebSocket
+
+The linked API's `baseUrl` (`{{DEMO_API_URL}}` → `host:port`) is rendered with a
+`ws://` scheme because of `protocol: websocket` above. Click **Send** to open the
+connection — Studio captures each frame and shows them in the response panel.
+
+The optional body is sent as the first text frame after the upgrade completes;
+remove it to just listen for the heartbeat.
+
+```http
+GET /demo/stream/ws?interval=1000
+
+hello
+```
diff --git a/samples/sample-workspace/collections/demo/uploads/.files/hello.bin b/samples/sample-workspace/collections/demo/uploads/.files/hello.bin
new file mode 100644
index 0000000..6dadbc5
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/.files/hello.bin
@@ -0,0 +1 @@
+Hello from a binary upload. This file lives in .files/ next to binary.req.md and is loaded byte-for-byte at execution time.
\ No newline at end of file
diff --git a/samples/sample-workspace/collections/demo/uploads/.files/rega_logo.jpeg b/samples/sample-workspace/collections/demo/uploads/.files/rega_logo.jpeg
new file mode 100644
index 0000000..95186f1
Binary files /dev/null and b/samples/sample-workspace/collections/demo/uploads/.files/rega_logo.jpeg differ
diff --git a/samples/sample-workspace/collections/demo/uploads/binary.req.md b/samples/sample-workspace/collections/demo/uploads/binary.req.md
new file mode 100644
index 0000000..39d2f06
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/binary.req.md
@@ -0,0 +1,20 @@
+---
+kind: request
+name: Upload — Binary
+tags: [demo, upload, binary]
+---
+# Binary upload
+
+The **Binary** body mode posts file bytes with a chosen `Content-Type`. The file
+lives in a sideband `.files/` directory next to this request — the body just
+holds a ref string. At send time, Tap Studio resolves the ref, reads the file
+off disk, and ships the bytes verbatim as `ByteArrayContent`. Demo.Api echoes
+the byte length and a SHA-256 so byte-perfect proxying through a tap can be
+verified end-to-end.
+
+```http
+POST /demo/upload/raw
+Content-Type: application/octet-stream
+
+< ./.files/hello.bin
+```
diff --git a/samples/sample-workspace/collections/demo/uploads/form.req.md b/samples/sample-workspace/collections/demo/uploads/form.req.md
new file mode 100644
index 0000000..41f357a
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/form.req.md
@@ -0,0 +1,13 @@
+---
+kind: request
+name: Upload — form-urlencoded
+tags: [demo, upload, form]
+---
+# application/x-www-form-urlencoded
+
+```http
+POST /demo/upload/form
+Content-Type: application/x-www-form-urlencoded
+
+name={{user.name}}&email={{user.email}}&checked=true
+```
diff --git a/samples/sample-workspace/collections/demo/uploads/json.req.md b/samples/sample-workspace/collections/demo/uploads/json.req.md
new file mode 100644
index 0000000..d1ab57a
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/json.req.md
@@ -0,0 +1,19 @@
+---
+kind: request
+name: Upload — JSON
+tags: [demo, upload, json]
+---
+# JSON upload
+
+Echoes the parsed JSON back so you can verify the request-body capture.
+
+```http
+POST /demo/upload/json
+Content-Type: application/json
+
+{
+ "user": "{{user.name}}",
+ "email": "{{user.email}}",
+ "items": [1, 2, 3]
+}
+```
diff --git a/samples/sample-workspace/collections/demo/uploads/multipart-multi.req.md b/samples/sample-workspace/collections/demo/uploads/multipart-multi.req.md
new file mode 100644
index 0000000..6d79346
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/multipart-multi.req.md
@@ -0,0 +1,36 @@
+---
+kind: request
+name: Upload — Multipart (multi-file)
+tags: [demo, upload, multipart, multi]
+---
+# multipart/form-data — multiple fields and files
+
+Exercises the **Multipart** body editor end-to-end: two text fields plus two
+file parts with distinct Content-Types. Boundary is the canonical
+`tap-multipart-boundary` so the on-disk diff stays clean across edits.
+
+```http
+POST /demo/upload/multipart
+Content-Type: multipart/form-data; boundary=tap-multipart-boundary
+
+--tap-multipart-boundary
+Content-Disposition: form-data; name="user"
+
+{{user.name}}
+--tap-multipart-boundary
+Content-Disposition: form-data; name="email"
+
+{{user.email}}
+--tap-multipart-boundary
+Content-Disposition: form-data; name="readme"; filename="README.md"
+Content-Type: text/markdown
+
+# Hello
+Multipart upload from Tap Studio.
+--tap-multipart-boundary
+Content-Disposition: form-data; name="config"; filename="config.json"
+Content-Type: application/json
+
+{"feature":"multipart","enabled":true}
+--tap-multipart-boundary--
+```
diff --git a/samples/sample-workspace/collections/demo/uploads/multipart.req.md b/samples/sample-workspace/collections/demo/uploads/multipart.req.md
new file mode 100644
index 0000000..4b0138e
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/multipart.req.md
@@ -0,0 +1,25 @@
+---
+kind: request
+name: Upload — multipart
+tags: [demo, upload, multipart]
+---
+# multipart/form-data
+
+The server lists fields + file metadata back. Boundary is fixed so the request
+body in this file is reproducible — Tap won't rewrite it.
+
+```http
+POST /demo/upload/multipart
+Content-Type: multipart/form-data; boundary=tap-multipart-boundary
+
+--tap-multipart-boundary
+Content-Disposition: form-data; name="user"
+
+{{user.name}}
+--tap-multipart-boundary
+Content-Disposition: form-data; name="file"; filename="hello.txt"
+Content-Type: text/plain
+
+Hello from a multipart upload.
+--tap-multipart-boundary--
+```
diff --git a/samples/sample-workspace/collections/demo/uploads/raw.req.md b/samples/sample-workspace/collections/demo/uploads/raw.req.md
new file mode 100644
index 0000000..dd078fb
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/raw.req.md
@@ -0,0 +1,16 @@
+---
+kind: request
+name: Upload — raw bytes
+tags: [demo, upload, raw, binary]
+---
+# Raw byte upload
+
+The server reports length + SHA-256. Useful for verifying byte-perfect proxying
+through a Tap tunnel.
+
+```http
+POST /demo/upload/raw
+Content-Type: application/octet-stream
+
+SGVsbG8gZnJvbSBhIHJhdyB1cGxvYWQu
+```
diff --git a/samples/sample-workspace/collections/demo/uploads/text.req.md b/samples/sample-workspace/collections/demo/uploads/text.req.md
new file mode 100644
index 0000000..89961e4
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/text.req.md
@@ -0,0 +1,17 @@
+---
+kind: request
+name: Upload — Raw text
+tags: [demo, upload, raw, text]
+---
+
+```http
+POST /demo/upload/text
+Content-Type: image/jpeg
+
+< ./.files/rega_logo.jpeg
+```
+
+# Raw text body
+
+The Studio's **Raw / Text** body mode sends the textarea contents verbatim with
+`Content-Type: text/plain`. Useful for log lines, prose, hand-rolled formats.
diff --git a/samples/sample-workspace/collections/demo/uploads/xml.req.md b/samples/sample-workspace/collections/demo/uploads/xml.req.md
new file mode 100644
index 0000000..97da013
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/uploads/xml.req.md
@@ -0,0 +1,22 @@
+---
+kind: request
+name: Upload — Raw XML
+tags: [demo, upload, raw, xml]
+---
+# Raw XML body
+
+The **Raw / XML** body mode ships an XML document with
+`Content-Type: application/xml`. Demo.Api parses the document and echoes back
+the root element name and leaf-element values.
+
+```http
+POST /demo/upload/xml
+Content-Type: application/xml
+
+
+
+ {{user.name}}
+ - Tap Studio licence
+ 3
+
+```
diff --git a/samples/sample-workspace/collections/demo/whoami-cc.req.md b/samples/sample-workspace/collections/demo/whoami-cc.req.md
new file mode 100644
index 0000000..3e6d396
--- /dev/null
+++ b/samples/sample-workspace/collections/demo/whoami-cc.req.md
@@ -0,0 +1,15 @@
+---
+kind: request
+name: Whoami (client credentials)
+auth: ../../auth/demo-oauth-cc.auth.md
+tags: [demo, auth, oauth2, client-credentials]
+---
+
+```http
+GET /demo/auth/whoami
+```
+
+# Authenticated request — client_credentials
+
+Run **Authenticate** on the auth profile first; the resulting bearer token is
+attached automatically by Tap.
diff --git a/samples/sample-workspace/collections/github/_collection.md b/samples/sample-workspace/collections/github/_collection.md
new file mode 100644
index 0000000..defa65f
--- /dev/null
+++ b/samples/sample-workspace/collections/github/_collection.md
@@ -0,0 +1,24 @@
+---
+kind: collection
+name: GitHub
+baseUrl: https://api.github.com
+defaultAuth: ../../auth/github-cli.auth.md
+defaultHeaders:
+ Accept: application/vnd.github+json
+ User-Agent: tap-studio-demo/0.1
+tags: [github, demo]
+---
+# GitHub
+
+Sample requests against `api.github.com`. The collection defaults to the
+`github-cli` auth profile — every call below picks up whichever account
+`gh auth login` is signed in as on this machine.
+
+- **profile.req.md** — `GET /user`, the authenticated user's profile
+- **repos.req.md** — `GET /user/repos`, repos the authenticated user can access
+
+Switch accounts with `gh auth switch`, then execute the auth profile again from
+the editor to refresh the cached token.
+
+`X-GitHub-Api-Version: 2022-11-28` is added automatically by the GitHub auth runner;
+no need to set it here.
diff --git a/samples/sample-workspace/collections/github/profile.req.md b/samples/sample-workspace/collections/github/profile.req.md
new file mode 100644
index 0000000..dbaceae
--- /dev/null
+++ b/samples/sample-workspace/collections/github/profile.req.md
@@ -0,0 +1,29 @@
+---
+kind: request
+name: My profile (GET /user)
+tags: [github, profile]
+---
+
+```http
+GET /user
+```
+
+# Authenticated user profile
+
+`GET /user` returns the GitHub user the request is authenticated as. Uses the
+`github-cli` auth inherited from `apis/github.api.md`, so this fires off whichever
+account `gh auth login` last signed into.
+
+Expect a JSON body shaped like:
+
+```json
+{
+ "login": "octocat",
+ "id": 1,
+ "name": "monalisa octocat",
+ "company": "GitHub",
+ "blog": "https://github.com/blog",
+ "public_repos": 2,
+ "followers": 20
+}
+```
diff --git a/samples/sample-workspace/collections/github/repos.req.md b/samples/sample-workspace/collections/github/repos.req.md
new file mode 100644
index 0000000..8e0412e
--- /dev/null
+++ b/samples/sample-workspace/collections/github/repos.req.md
@@ -0,0 +1,26 @@
+---
+kind: request
+name: My repos (GET /user/repos)
+tags: [github, repos]
+---
+
+```http
+GET /user/repos?per_page=20&sort=updated
+```
+
+# Repos the authenticated user can access
+
+`GET /user/repos` lists repositories the authenticated user has explicit access
+to (owned, collaborator, or via org membership). Inherits the `github-cli` auth
+from `apis/github.api.md`.
+
+Useful query params:
+
+- `per_page` — page size (max 100; default 30)
+- `sort` — `created` | `updated` | `pushed` | `full_name`
+- `affiliation` — comma-separated: `owner,collaborator,organization_member`
+- `visibility` — `all` | `public` | `private`
+
+The token scope matters: classic PATs need `repo` to see private repos; the gh
+CLI default scopes include `repo`, so this Just Works out of the box. With a
+fine-grained token check the token's repository access list.
diff --git a/samples/sample-workspace/collections/streams/_collection.md b/samples/sample-workspace/collections/streams/_collection.md
new file mode 100644
index 0000000..0e91e95
--- /dev/null
+++ b/samples/sample-workspace/collections/streams/_collection.md
@@ -0,0 +1,9 @@
+---
+kind: collection
+name: Live Streams
+tags: [streaming]
+---
+# Live Streams
+
+A standalone collection — its requests aren't tied to any single API. Public feeds
+like Wikimedia's recent-changes stream live here.
diff --git a/samples/sample-workspace/collections/streams/wikimedia-recent.req.md b/samples/sample-workspace/collections/streams/wikimedia-recent.req.md
new file mode 100644
index 0000000..02ddf66
--- /dev/null
+++ b/samples/sample-workspace/collections/streams/wikimedia-recent.req.md
@@ -0,0 +1,28 @@
+---
+kind: request
+name: SSE (Wikimedia recent changes)
+auth: none
+tags: [content-type, sse, stream]
+---
+# SSE — live stream
+
+Wikimedia's public `recentchange` stream emits one SSE frame for every edit landing on
+any Wikimedia wiki. Use it to verify the live Events tab:
+
+1. Click **Send**.
+2. The Response panel auto-switches to the **Events** tab.
+3. Frames stream in as they happen — click any row to expand the JSON payload.
+4. Click × on the response panel header to abort the stream.
+
+Notes:
+- Wikimedia content-negotiates on `Accept` — we explicitly request `text/event-stream`,
+ otherwise it falls back to a JSON document. It also requires a User-Agent.
+- This is high-frequency (10–50 events / sec) — a good stress test for the live list.
+- We don't link an `api:` file because the default `Accept: application/json` from
+ `httpbin.api.md` would override the SSE content type negotiation.
+
+```http
+GET https://stream.wikimedia.org/v2/stream/recentchange
+Accept: text/event-stream
+User-Agent: tap-studio-demo/0.1 (https://github.com/philbir/tap; demo)
+```
diff --git a/samples/sample-workspace/environments/local.env.md b/samples/sample-workspace/environments/local.env.md
new file mode 100644
index 0000000..4ba606c
--- /dev/null
+++ b/samples/sample-workspace/environments/local.env.md
@@ -0,0 +1,11 @@
+---
+kind: env
+name: Local
+vars:
+ user.name: Jane Doe
+ user.email: jane@example.com
+---
+
+# Local
+
+Default env for the demo. Plain values only — no secret refs.
diff --git a/samples/sample-workspace/tap.md b/samples/sample-workspace/tap.md
new file mode 100644
index 0000000..b2b8f96
--- /dev/null
+++ b/samples/sample-workspace/tap.md
@@ -0,0 +1,15 @@
+---
+kind: workspace
+id: 0192-3a4c-bb71-7c1d-9e8f0a1b2c3d
+name: studio-demo
+defaultEnv: environments/local.env.md
+defaultVariableProvider: file
+variableProviders:
+- name: env
+ type: env
+- name: file
+ type: file
+vars:
+ JWT_SECRET: '{{env:DEMO_JWT_SECRET}}'
+tags: [demo, local, public, sample, streaming, websocket, graphql, oauth2]
+---
diff --git a/scripts/build-desktop.sh b/scripts/build-desktop.sh
new file mode 100755
index 0000000..078d3a4
--- /dev/null
+++ b/scripts/build-desktop.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# Local-dev convenience wrapper: publish the sidecar for the host triple and
+# launch (or bundle) the Tauri shell. CI doesn't use this — it calls
+# src/desktop/scripts/compile-server.mjs directly per matrix entry
+# and then runs tauri-action.
+#
+# scripts/build-desktop.sh # publish sidecar + yarn tauri build
+# scripts/build-desktop.sh --dev # publish sidecar + yarn tauri dev
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DESKTOP_DIR="$REPO_ROOT/src/desktop"
+
+DEV=false
+if [[ "${1:-}" == "--dev" ]]; then
+ DEV=true
+fi
+
+# Publish the .NET sidecar + stage wwwroot for the host triple.
+# compile-server.mjs detects the triple from `rustc -vV` (preferred) or Node
+# platform info and writes into src/desktop/src-tauri/binaries/.
+node "$DESKTOP_DIR/scripts/compile-server.mjs"
+
+cd "$DESKTOP_DIR"
+if [[ ! -d node_modules ]]; then
+ yarn install
+fi
+if $DEV; then
+ yarn tauri dev
+else
+ yarn tauri build
+fi
diff --git a/scripts/pack-local.sh b/scripts/pack-local.sh
new file mode 100755
index 0000000..7b122cc
--- /dev/null
+++ b/scripts/pack-local.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Build Tap NuGets and drop them into a local file-system feed so a sibling repo
+# (e.g. Antoniq's AppHost) can consume the fix without going through nuget.org.
+#
+# Usage:
+# scripts/pack-local.sh # default version + feed
+# scripts/pack-local.sh 0.5.0-local # custom version
+# scripts/pack-local.sh 0.5.0-local /path/to/nuget-feed # custom version + feed
+#
+# Consumer setup (in the sibling repo):
+# 1. Create or edit NuGet.config:
+#
+#
+#
+# 2. dotnet add package Tap.Aspire.Hosting --version 0.5.0-local --source tap-local
+# 3. dotnet nuget locals http-cache --clear # if you republish the same version
+#
+# Notes:
+# - Local feed is a flat directory of .nupkg files; `dotnet pack -o ` is
+# enough — no `nuget push` needed.
+# - Both Tap.Aspire.Hosting AND Tap.Internals (the transitive Tap.Core package)
+# land in the feed, since the former has a ProjectReference to the latter.
+# - If you republish the same version, run the `nuget locals http-cache --clear`
+# line above on the consumer side; NuGet caches by version string.
+
+set -euo pipefail
+
+VERSION="${1:-0.5.0-local}"
+FEED="${2:-/Users/p7e/code/nuget-source}"
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+
+mkdir -p "$FEED"
+
+echo "→ Packing Tap @ $VERSION into $FEED"
+dotnet pack "$REPO_ROOT/src/backend/Tap.Core/Tap.Core.csproj" \
+ -c Release -p:Version="$VERSION" -o "$FEED" --nologo
+dotnet pack "$REPO_ROOT/src/backend/Tap.Hosting/Tap.Hosting.csproj" \
+ -c Release -p:Version="$VERSION" -o "$FEED" --nologo
+
+echo
+echo "Done. Packages in $FEED:"
+ls -1 "$FEED" | grep -E "^(Tap\.Aspire\.Hosting|Tap\.Internals)\.${VERSION//./\\.}\.(nu|sn)pkg$" || true
+echo
+echo "Consume from a sibling repo with:"
+echo " dotnet add package Tap.Aspire.Hosting --version $VERSION --source $FEED"
diff --git a/scripts/publish-studio.sh b/scripts/publish-studio.sh
new file mode 100755
index 0000000..37872d5
--- /dev/null
+++ b/scripts/publish-studio.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# Publish Tap.Studio as a self-contained single-file binary.
+#
+# Usage:
+# scripts/publish-studio.sh # host RID, ./artifacts/studio/
+# scripts/publish-studio.sh osx-arm64 # one RID
+# scripts/publish-studio.sh osx-arm64 osx-x64 # several RIDs
+# scripts/publish-studio.sh --all # all supported RIDs
+#
+# The build target BuildStudioUi runs first (yarn install + yarn build) and
+# copies src/ui-studio/dist into wwwroot, so the resulting binary serves the SPA
+# from its own filesystem at runtime.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+CSPROJ="$REPO_ROOT/src/backend/Tap.Studio/Tap.Studio.csproj"
+OUT_BASE="$REPO_ROOT/artifacts/studio"
+
+ALL_RIDS=(osx-arm64 osx-x64 linux-x64 linux-arm64 win-x64)
+
+host_rid() {
+ local os arch
+ case "$(uname -s)" in
+ Darwin) os="osx" ;;
+ Linux) os="linux" ;;
+ MINGW*|MSYS*|CYGWIN*) os="win" ;;
+ *) echo "Unsupported host OS: $(uname -s)" >&2; exit 1 ;;
+ esac
+ case "$(uname -m)" in
+ arm64|aarch64) arch="arm64" ;;
+ x86_64|amd64) arch="x64" ;;
+ *) echo "Unsupported host arch: $(uname -m)" >&2; exit 1 ;;
+ esac
+ echo "${os}-${arch}"
+}
+
+if [[ "${1:-}" == "--all" ]]; then
+ RIDS=("${ALL_RIDS[@]}")
+elif [[ $# -gt 0 ]]; then
+ RIDS=("$@")
+else
+ RIDS=("$(host_rid)")
+fi
+
+for rid in "${RIDS[@]}"; do
+ out="$OUT_BASE/$rid"
+ echo "==> Publishing Tap.Studio for $rid → $out"
+ rm -rf "$out"
+ dotnet publish "$CSPROJ" \
+ --configuration Release \
+ --runtime "$rid" \
+ --output "$out" \
+ -p:PublishSingleFile=true \
+ -p:SelfContained=true
+done
+
+echo
+echo "Done. Binaries:"
+for rid in "${RIDS[@]}"; do
+ out="$OUT_BASE/$rid"
+ exe=$(ls "$out"/Tap.Studio* 2>/dev/null | head -1 || true)
+ printf ' %-14s %s\n' "$rid" "${exe:-}"
+done
diff --git a/scripts/studio-ui-dev.sh b/scripts/studio-ui-dev.sh
new file mode 100755
index 0000000..9d4cbe7
--- /dev/null
+++ b/scripts/studio-ui-dev.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Convenience wrapper: launch the Studio UI dev server pointed at a locally-running
+# Tap.Studio backend. Set STUDIO_API_URL to override the default discovery, otherwise
+# we ask the Aspire CLI for the active studio-api URL.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+api_url="${STUDIO_API_URL:-}"
+
+if [[ -z "$api_url" ]]; then
+ # Best-effort discovery from a running Studio.AppHost via aspire describe.
+ if command -v aspire >/dev/null && command -v python3 >/dev/null; then
+ discovered=$(aspire describe \
+ --apphost samples/Studio.AppHost/Studio.AppHost.csproj \
+ --non-interactive --format Json 2>/dev/null \
+ | python3 -c 'import json,sys
+try:
+ d = json.load(sys.stdin)
+ s = next(r for r in d["resources"] if r["name"].startswith("studio-api") and r.get("state") == "Running")
+ print(s["urls"][0]["url"])
+except Exception:
+ pass' 2>/dev/null || true)
+ api_url="$discovered"
+ fi
+fi
+
+if [[ -z "$api_url" ]]; then
+ api_url="http://localhost:5298"
+fi
+
+export VITE_STUDIO_API_URL="$api_url"
+echo "[studio-ui-dev] Proxying /api -> $VITE_STUDIO_API_URL"
+exec yarn --cwd src/ui-studio dev
diff --git a/src/Tap.Cli/BrowserLauncher.cs b/src/backend/Tap.Cli/BrowserLauncher.cs
similarity index 100%
rename from src/Tap.Cli/BrowserLauncher.cs
rename to src/backend/Tap.Cli/BrowserLauncher.cs
diff --git a/src/Tap.Cli/Commands/DocsCommand.cs b/src/backend/Tap.Cli/Commands/DocsCommand.cs
similarity index 88%
rename from src/Tap.Cli/Commands/DocsCommand.cs
rename to src/backend/Tap.Cli/Commands/DocsCommand.cs
index b1fc6a8..a2dad9c 100644
--- a/src/Tap.Cli/Commands/DocsCommand.cs
+++ b/src/backend/Tap.Cli/Commands/DocsCommand.cs
@@ -15,7 +15,7 @@ public sealed class Settings : CommandSettings
public bool Print { get; init; }
}
- public override int Execute(CommandContext context, Settings settings)
+ protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
if (settings.Print)
{
diff --git a/src/Tap.Cli/Commands/InstallCloudflaredCommand.cs b/src/backend/Tap.Cli/Commands/InstallCloudflaredCommand.cs
similarity index 90%
rename from src/Tap.Cli/Commands/InstallCloudflaredCommand.cs
rename to src/backend/Tap.Cli/Commands/InstallCloudflaredCommand.cs
index 3b303a0..df2040a 100644
--- a/src/Tap.Cli/Commands/InstallCloudflaredCommand.cs
+++ b/src/backend/Tap.Cli/Commands/InstallCloudflaredCommand.cs
@@ -7,7 +7,7 @@ namespace Tap.Cli.Commands;
public sealed class InstallCloudflaredCommand : AsyncCommand
{
- public override async Task ExecuteAsync(CommandContext context)
+ protected override async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
using var loggerFactory = LoggerFactory.Create(b => b.AddSpectreConsoleLogger());
var installer = new CloudflaredInstaller(loggerFactory.CreateLogger());
diff --git a/src/Tap.Cli/Commands/RmCommand.cs b/src/backend/Tap.Cli/Commands/RmCommand.cs
similarity index 87%
rename from src/Tap.Cli/Commands/RmCommand.cs
rename to src/backend/Tap.Cli/Commands/RmCommand.cs
index de6588b..f84c8ff 100644
--- a/src/Tap.Cli/Commands/RmCommand.cs
+++ b/src/backend/Tap.Cli/Commands/RmCommand.cs
@@ -14,7 +14,7 @@ public sealed class Settings : CommandSettings
public string ProfileName { get; init; } = "";
}
- public override int Execute(CommandContext context, Settings settings)
+ protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
var store = new TunnelProfileStore();
if (store.Delete(settings.ProfileName))
diff --git a/src/Tap.Cli/Commands/RunCommand.cs b/src/backend/Tap.Cli/Commands/RunCommand.cs
similarity index 99%
rename from src/Tap.Cli/Commands/RunCommand.cs
rename to src/backend/Tap.Cli/Commands/RunCommand.cs
index 992a89e..1b09485 100644
--- a/src/Tap.Cli/Commands/RunCommand.cs
+++ b/src/backend/Tap.Cli/Commands/RunCommand.cs
@@ -29,7 +29,7 @@ public sealed class Settings : TapBaseSettings
public string? Name { get; init; }
}
- public override async Task ExecuteAsync(CommandContext context, Settings settings)
+ protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
// Wire Ctrl+C and 'q' to a single cancellation source so background tasks, the
// request-stream subscription, and the inspector all stop cooperatively.
diff --git a/src/Tap.Cli/Commands/SaveCommand.cs b/src/backend/Tap.Cli/Commands/SaveCommand.cs
similarity index 96%
rename from src/Tap.Cli/Commands/SaveCommand.cs
rename to src/backend/Tap.Cli/Commands/SaveCommand.cs
index ef7a0ad..83a94ff 100644
--- a/src/Tap.Cli/Commands/SaveCommand.cs
+++ b/src/backend/Tap.Cli/Commands/SaveCommand.cs
@@ -20,7 +20,7 @@ public sealed class Settings : TapBaseSettings
public string? Upstream { get; init; }
}
- public override int Execute(CommandContext context, Settings settings)
+ protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
var store = new TunnelProfileStore();
diff --git a/src/Tap.Cli/Commands/SharedSettings.cs b/src/backend/Tap.Cli/Commands/SharedSettings.cs
similarity index 100%
rename from src/Tap.Cli/Commands/SharedSettings.cs
rename to src/backend/Tap.Cli/Commands/SharedSettings.cs
diff --git a/src/Tap.Cli/Commands/TailscaleCliRunner.cs b/src/backend/Tap.Cli/Commands/TailscaleCliRunner.cs
similarity index 100%
rename from src/Tap.Cli/Commands/TailscaleCliRunner.cs
rename to src/backend/Tap.Cli/Commands/TailscaleCliRunner.cs
diff --git a/src/Tap.Cli/Commands/UiCommand.cs b/src/backend/Tap.Cli/Commands/UiCommand.cs
similarity index 95%
rename from src/Tap.Cli/Commands/UiCommand.cs
rename to src/backend/Tap.Cli/Commands/UiCommand.cs
index 7e317ea..c3c32c4 100644
--- a/src/Tap.Cli/Commands/UiCommand.cs
+++ b/src/backend/Tap.Cli/Commands/UiCommand.cs
@@ -27,7 +27,7 @@ public sealed class Settings : CommandSettings
public bool NoOpen { get; init; }
}
- public override async Task ExecuteAsync(CommandContext context, Settings settings)
+ protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var cts = new CancellationTokenSource();
var ct = cts.Token;
diff --git a/src/Tap.Cli/Program.cs b/src/backend/Tap.Cli/Program.cs
similarity index 100%
rename from src/Tap.Cli/Program.cs
rename to src/backend/Tap.Cli/Program.cs
diff --git a/src/Tap.Cli/SpectreConsoleLogger.cs b/src/backend/Tap.Cli/SpectreConsoleLogger.cs
similarity index 100%
rename from src/Tap.Cli/SpectreConsoleLogger.cs
rename to src/backend/Tap.Cli/SpectreConsoleLogger.cs
diff --git a/src/Tap.Cli/Tap.Cli.csproj b/src/backend/Tap.Cli/Tap.Cli.csproj
similarity index 100%
rename from src/Tap.Cli/Tap.Cli.csproj
rename to src/backend/Tap.Cli/Tap.Cli.csproj
diff --git a/src/Tap.Core/Auth/TapAuthMiddleware.cs b/src/backend/Tap.Core/Auth/TapAuthMiddleware.cs
similarity index 100%
rename from src/Tap.Core/Auth/TapAuthMiddleware.cs
rename to src/backend/Tap.Core/Auth/TapAuthMiddleware.cs
diff --git a/src/Tap.Core/Auth/TapAuthOptions.cs b/src/backend/Tap.Core/Auth/TapAuthOptions.cs
similarity index 100%
rename from src/Tap.Core/Auth/TapAuthOptions.cs
rename to src/backend/Tap.Core/Auth/TapAuthOptions.cs
diff --git a/src/Tap.Core/Cloudflare/CloudflareApi.cs b/src/backend/Tap.Core/Cloudflare/CloudflareApi.cs
similarity index 86%
rename from src/Tap.Core/Cloudflare/CloudflareApi.cs
rename to src/backend/Tap.Core/Cloudflare/CloudflareApi.cs
index 3c8a31b..1e67b3f 100644
--- a/src/Tap.Core/Cloudflare/CloudflareApi.cs
+++ b/src/backend/Tap.Core/Cloudflare/CloudflareApi.cs
@@ -89,16 +89,48 @@ public async Task GetZoneIdByNameAsync(string zoneName, CancellationToke
return zone.Id;
}
+ /// Names of every zone visible to the current API token (best-effort, for diagnostics).
+ public async Task> ListZoneNamesAsync(CancellationToken ct)
+ {
+ using var req = NewRequest(HttpMethod.Get, $"{ApiBase}/zones?per_page=50");
+ var payload = await SendAsync>(req, ct);
+ return payload.Result?.Select(z => z.Name).ToArray() ?? [];
+ }
+
/// Resolve a zone id by walking labels of upward (a.b.c.d -> b.c.d -> c.d).
public async Task ResolveZoneIdForFqdnAsync(string fqdn, CancellationToken ct)
{
var labels = fqdn.Split('.');
+ var tried = new List();
for (var i = 1; i < labels.Length - 1; i++)
{
var candidate = string.Join('.', labels.Skip(i));
+ tried.Add(candidate);
try { return await GetZoneIdByNameAsync(candidate, ct); } catch { }
}
- throw new InvalidOperationException($"Could not resolve a Cloudflare zone for '{fqdn}'.");
+
+ // Nothing matched. The usual cause is a domain/account mismatch (the hostname's zone
+ // simply isn't on the account this token is scoped to), so enrich the failure with the
+ // zones the token CAN see — that turns a dead-end error into a self-diagnosing one.
+ var triedClause = tried.Count > 0 ? $" (tried {string.Join(", ", tried)})" : "";
+ throw new InvalidOperationException(
+ $"Could not resolve a Cloudflare zone for '{fqdn}'{triedClause}. {await DescribeVisibleZonesAsync(ct)}");
+ }
+
+ private async Task DescribeVisibleZonesAsync(CancellationToken ct)
+ {
+ try
+ {
+ var zones = await ListZoneNamesAsync(ct);
+ return zones.Count == 0
+ ? "This API token can see no zones — it likely lacks the Zone:Read permission or is scoped to a different account."
+ : $"Zones visible to this API token: {string.Join(", ", zones)}. "
+ + "Use a hostname under one of those, or add the missing domain to this Cloudflare account.";
+ }
+ catch (Exception ex)
+ {
+ return $"(Could not list visible zones to help diagnose: {ex.Message})";
+ }
}
public async Task EnsureDnsCnameAsync(string zoneId, string fqdn, string targetCname, CancellationToken ct)
diff --git a/src/Tap.Core/Cloudflare/CloudflareTokenDecoder.cs b/src/backend/Tap.Core/Cloudflare/CloudflareTokenDecoder.cs
similarity index 100%
rename from src/Tap.Core/Cloudflare/CloudflareTokenDecoder.cs
rename to src/backend/Tap.Core/Cloudflare/CloudflareTokenDecoder.cs
diff --git a/src/Tap.Core/Cloudflare/TunnelProvisioner.cs b/src/backend/Tap.Core/Cloudflare/TunnelProvisioner.cs
similarity index 100%
rename from src/Tap.Core/Cloudflare/TunnelProvisioner.cs
rename to src/backend/Tap.Core/Cloudflare/TunnelProvisioner.cs
diff --git a/src/Tap.Core/Cloudflared/CloudflaredInstaller.cs b/src/backend/Tap.Core/Cloudflared/CloudflaredInstaller.cs
similarity index 100%
rename from src/Tap.Core/Cloudflared/CloudflaredInstaller.cs
rename to src/backend/Tap.Core/Cloudflared/CloudflaredInstaller.cs
diff --git a/src/Tap.Core/Cloudflared/CloudflaredRunner.cs b/src/backend/Tap.Core/Cloudflared/CloudflaredRunner.cs
similarity index 100%
rename from src/Tap.Core/Cloudflared/CloudflaredRunner.cs
rename to src/backend/Tap.Core/Cloudflared/CloudflaredRunner.cs
diff --git a/src/Tap.Core/Profiles/TunnelProfile.cs b/src/backend/Tap.Core/Profiles/TunnelProfile.cs
similarity index 100%
rename from src/Tap.Core/Profiles/TunnelProfile.cs
rename to src/backend/Tap.Core/Profiles/TunnelProfile.cs
diff --git a/src/Tap.Core/Profiles/TunnelProfileStore.cs b/src/backend/Tap.Core/Profiles/TunnelProfileStore.cs
similarity index 100%
rename from src/Tap.Core/Profiles/TunnelProfileStore.cs
rename to src/backend/Tap.Core/Profiles/TunnelProfileStore.cs
diff --git a/src/Tap.Core/Tap.Core.csproj b/src/backend/Tap.Core/Tap.Core.csproj
similarity index 100%
rename from src/Tap.Core/Tap.Core.csproj
rename to src/backend/Tap.Core/Tap.Core.csproj
diff --git a/src/Tap.Hosting/Cloudflared/CloudflareTunnelAnnotation.cs b/src/backend/Tap.Hosting/Cloudflared/CloudflareTunnelAnnotation.cs
similarity index 100%
rename from src/Tap.Hosting/Cloudflared/CloudflareTunnelAnnotation.cs
rename to src/backend/Tap.Hosting/Cloudflared/CloudflareTunnelAnnotation.cs
diff --git a/src/Tap.Hosting/Cloudflared/CloudflaredExtensions.cs b/src/backend/Tap.Hosting/Cloudflared/CloudflaredExtensions.cs
similarity index 100%
rename from src/Tap.Hosting/Cloudflared/CloudflaredExtensions.cs
rename to src/backend/Tap.Hosting/Cloudflared/CloudflaredExtensions.cs
diff --git a/src/Tap.Hosting/Cloudflared/CloudflaredLifecycleHook.cs b/src/backend/Tap.Hosting/Cloudflared/CloudflaredLifecycleHook.cs
similarity index 100%
rename from src/Tap.Hosting/Cloudflared/CloudflaredLifecycleHook.cs
rename to src/backend/Tap.Hosting/Cloudflared/CloudflaredLifecycleHook.cs
diff --git a/src/Tap.Hosting/Cloudflared/CloudflaredTunnelResource.cs b/src/backend/Tap.Hosting/Cloudflared/CloudflaredTunnelResource.cs
similarity index 100%
rename from src/Tap.Hosting/Cloudflared/CloudflaredTunnelResource.cs
rename to src/backend/Tap.Hosting/Cloudflared/CloudflaredTunnelResource.cs
diff --git a/src/Tap.Hosting/Tailscale/TailscaleExtensions.cs b/src/backend/Tap.Hosting/Tailscale/TailscaleExtensions.cs
similarity index 100%
rename from src/Tap.Hosting/Tailscale/TailscaleExtensions.cs
rename to src/backend/Tap.Hosting/Tailscale/TailscaleExtensions.cs
diff --git a/src/Tap.Hosting/Tailscale/TailscaleFunnelResource.cs b/src/backend/Tap.Hosting/Tailscale/TailscaleFunnelResource.cs
similarity index 100%
rename from src/Tap.Hosting/Tailscale/TailscaleFunnelResource.cs
rename to src/backend/Tap.Hosting/Tailscale/TailscaleFunnelResource.cs
diff --git a/src/Tap.Hosting/Tailscale/TailscaleLifecycleHook.cs b/src/backend/Tap.Hosting/Tailscale/TailscaleLifecycleHook.cs
similarity index 100%
rename from src/Tap.Hosting/Tailscale/TailscaleLifecycleHook.cs
rename to src/backend/Tap.Hosting/Tailscale/TailscaleLifecycleHook.cs
diff --git a/src/Tap.Hosting/Tailscale/TailscaledDaemonResource.cs b/src/backend/Tap.Hosting/Tailscale/TailscaledDaemonResource.cs
similarity index 100%
rename from src/Tap.Hosting/Tailscale/TailscaledDaemonResource.cs
rename to src/backend/Tap.Hosting/Tailscale/TailscaledDaemonResource.cs
diff --git a/src/Tap.Hosting/Tap.Hosting.csproj b/src/backend/Tap.Hosting/Tap.Hosting.csproj
similarity index 100%
rename from src/Tap.Hosting/Tap.Hosting.csproj
rename to src/backend/Tap.Hosting/Tap.Hosting.csproj
diff --git a/src/Tap.Hosting/Tap/TapAuthExtensions.cs b/src/backend/Tap.Hosting/Tap/TapAuthExtensions.cs
similarity index 100%
rename from src/Tap.Hosting/Tap/TapAuthExtensions.cs
rename to src/backend/Tap.Hosting/Tap/TapAuthExtensions.cs
diff --git a/src/Tap.Hosting/Tap/TapExtensions.cs b/src/backend/Tap.Hosting/Tap/TapExtensions.cs
similarity index 88%
rename from src/Tap.Hosting/Tap/TapExtensions.cs
rename to src/backend/Tap.Hosting/Tap/TapExtensions.cs
index b6f3f59..745a6b9 100644
--- a/src/Tap.Hosting/Tap/TapExtensions.cs
+++ b/src/backend/Tap.Hosting/Tap/TapExtensions.cs
@@ -150,7 +150,7 @@ public static TapHandle AddTap(
///
/// Same Tap, hosted as a Docker container instead of a project. The container image
/// must contain the published Tap.Server with its wwwroot/ bundled
- /// (use src/Tap.Server/Dockerfile from this repo).
+ /// (use src/backend/Tap.Server/Dockerfile from this repo).
///
public static TapHandle AddTapContainer(
this IDistributedApplicationBuilder builder,
@@ -228,20 +228,46 @@ private static void ConfigureCommonTapEnv(
}
}
+ ///
+ /// Sentinel for 's maxRequestBodyBytes default: 5 GiB.
+ /// Large enough that the upstream's Kestrel won't reject practical uploads (videos,
+ /// archives, large form posts), while still bounded so a runaway request can't grow
+ /// without limit. Pass null to skip the injection entirely, or any explicit
+ /// value to use that instead.
+ ///
+ public const long DefaultMaxRequestBodyBytes = 5L * 1024 * 1024 * 1024;
+
///
/// Route traffic to 's endpoint through the given tap.
/// If the tap has a tunnel attached (via
/// or ), the upstream is also published
/// through that tunnel. is required for existing/api-managed
/// tunnels with no dynamic-host zone.
+ ///
+ ///
+ /// By default this also injects Kestrel__Limits__MaxRequestBodySize into the
+ /// upstream's environment so its Kestrel doesn't reject large uploads with a 413 mid-
+ /// stream. Tap itself is a transparent proxy and removes its own cap; without lifting
+ /// the upstream's cap too, a tunneled video upload would 413 (or hang while the client
+ /// uploads the rest) at the upstream Kestrel's default 30 MB. Pass
+ /// maxRequestBodyBytes: null to keep the upstream's own configured limit.
+ ///
///
public static IResourceBuilder WithTap(
this IResourceBuilder builder,
TapHandle tap,
string? hostname = null,
- string? endpointName = null)
- where T : IResourceWithEndpoints
+ string? endpointName = null,
+ long? maxRequestBodyBytes = DefaultMaxRequestBodyBytes)
+ where T : IResourceWithEndpoints, IResourceWithEnvironment
{
+ if (maxRequestBodyBytes is { } bytes)
+ {
+ builder.WithEnvironment(
+ "Kestrel__Limits__MaxRequestBodySize",
+ bytes.ToString(CultureInfo.InvariantCulture));
+ }
+
switch (tap.AttachedTunnel)
{
case CloudflaredTunnelResource cf:
diff --git a/src/Tap.Hosting/Tunnels/TapTunnelAnnotation.cs b/src/backend/Tap.Hosting/Tunnels/TapTunnelAnnotation.cs
similarity index 100%
rename from src/Tap.Hosting/Tunnels/TapTunnelAnnotation.cs
rename to src/backend/Tap.Hosting/Tunnels/TapTunnelAnnotation.cs
diff --git a/src/Tap.Hosting/Tunnels/TapTunnelIngress.cs b/src/backend/Tap.Hosting/Tunnels/TapTunnelIngress.cs
similarity index 100%
rename from src/Tap.Hosting/Tunnels/TapTunnelIngress.cs
rename to src/backend/Tap.Hosting/Tunnels/TapTunnelIngress.cs
diff --git a/src/Tap.Hosting/Tunnels/TapTunnelResource.cs b/src/backend/Tap.Hosting/Tunnels/TapTunnelResource.cs
similarity index 100%
rename from src/Tap.Hosting/Tunnels/TapTunnelResource.cs
rename to src/backend/Tap.Hosting/Tunnels/TapTunnelResource.cs
diff --git a/src/Tap.Server/CaptureMiddleware.cs b/src/backend/Tap.Server/CaptureMiddleware.cs
similarity index 98%
rename from src/Tap.Server/CaptureMiddleware.cs
rename to src/backend/Tap.Server/CaptureMiddleware.cs
index 281e64f..1963e54 100644
--- a/src/Tap.Server/CaptureMiddleware.cs
+++ b/src/backend/Tap.Server/CaptureMiddleware.cs
@@ -160,14 +160,15 @@ private static async Task CaptureRequestBodyAsync(HttpContext ctx, RequestRecord
return;
}
- ctx.Request.EnableBuffering();
-
+ // Decide BEFORE EnableBuffering — buffering an unread binary upload would spill the
+ // whole payload to a temp file (and copy it again on forward) for no benefit.
if (!IsTextContentType(ctx.Request.ContentType) || length > MaxCaptureBytes)
{
record.RequestBodyTruncated = true;
return;
}
+ ctx.Request.EnableBuffering();
using var ms = new MemoryStream();
await ctx.Request.Body.CopyToAsync(ms);
record.RequestBody = Encoding.UTF8.GetString(ms.ToArray());
diff --git a/src/Tap.Server/CapturingResponseStream.cs b/src/backend/Tap.Server/CapturingResponseStream.cs
similarity index 100%
rename from src/Tap.Server/CapturingResponseStream.cs
rename to src/backend/Tap.Server/CapturingResponseStream.cs
diff --git a/src/Tap.Server/CloudflareClient.cs b/src/backend/Tap.Server/CloudflareClient.cs
similarity index 100%
rename from src/Tap.Server/CloudflareClient.cs
rename to src/backend/Tap.Server/CloudflareClient.cs
diff --git a/src/Tap.Server/Dockerfile b/src/backend/Tap.Server/Dockerfile
similarity index 86%
rename from src/Tap.Server/Dockerfile
rename to src/backend/Tap.Server/Dockerfile
index e6f0ff0..66187c0 100644
--- a/src/Tap.Server/Dockerfile
+++ b/src/backend/Tap.Server/Dockerfile
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.6
#
# Tap inspector image. Build context = repo root, so paths are relative to repo root.
-# docker build -t ghcr.io/philbir/tap:latest -f src/Tap.Server/Dockerfile .
+# docker build -t ghcr.io/philbir/tap:latest -f src/backend/Tap.Server/Dockerfile .
# 1) Build the React inspector UI (yarn / vite).
# The repo uses Yarn 4 with default PnP, but PnP runs into resolver bugs inside Alpine
@@ -9,11 +9,11 @@
# it's slower but reliable, and only affects this image.
FROM node:22-alpine AS ui
WORKDIR /ui
-COPY ui/package.json ui/yarn.lock* ./
+COPY src/ui-inspector/package.json src/ui-inspector/yarn.lock* ./
RUN echo "nodeLinker: node-modules" > .yarnrc.yml \
&& corepack enable \
&& yarn install
-COPY ui/ ./
+COPY src/ui-inspector/ ./
# Re-write .yarnrc.yml in case the COPY brought one over (we want node-modules in this stage).
RUN echo "nodeLinker: node-modules" > .yarnrc.yml \
&& yarn build
@@ -23,8 +23,8 @@ RUN echo "nodeLinker: node-modules" > .yarnrc.yml \
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY global.json Directory.Build.props Directory.Packages.props ./
-COPY src/Tap.Core/ ./src/Tap.Core/
-COPY src/Tap.Server/ ./src/Tap.Server/
+COPY src/backend/Tap.Core/ ./src/Tap.Core/
+COPY src/backend/Tap.Server/ ./src/Tap.Server/
COPY --from=ui /ui/dist ./src/Tap.Server/wwwroot/
RUN dotnet publish src/Tap.Server/Tap.Server.csproj \
-c Release -o /app \
diff --git a/src/Tap.Server/IRequestStore.cs b/src/backend/Tap.Server/IRequestStore.cs
similarity index 100%
rename from src/Tap.Server/IRequestStore.cs
rename to src/backend/Tap.Server/IRequestStore.cs
diff --git a/src/Tap.Server/InMemoryRequestStore.cs b/src/backend/Tap.Server/InMemoryRequestStore.cs
similarity index 100%
rename from src/Tap.Server/InMemoryRequestStore.cs
rename to src/backend/Tap.Server/InMemoryRequestStore.cs
diff --git a/src/Tap.Server/InspectorIngressEntry.cs b/src/backend/Tap.Server/InspectorIngressEntry.cs
similarity index 100%
rename from src/Tap.Server/InspectorIngressEntry.cs
rename to src/backend/Tap.Server/InspectorIngressEntry.cs
diff --git a/src/Tap.Server/ProfileEndpoints.cs b/src/backend/Tap.Server/ProfileEndpoints.cs
similarity index 100%
rename from src/Tap.Server/ProfileEndpoints.cs
rename to src/backend/Tap.Server/ProfileEndpoints.cs
diff --git a/src/Tap.Server/Program.cs b/src/backend/Tap.Server/Program.cs
similarity index 100%
rename from src/Tap.Server/Program.cs
rename to src/backend/Tap.Server/Program.cs
diff --git a/src/Tap.Server/RequestRecord.cs b/src/backend/Tap.Server/RequestRecord.cs
similarity index 100%
rename from src/Tap.Server/RequestRecord.cs
rename to src/backend/Tap.Server/RequestRecord.cs
diff --git a/src/Tap.Server/TailscaleClient.cs b/src/backend/Tap.Server/TailscaleClient.cs
similarity index 100%
rename from src/Tap.Server/TailscaleClient.cs
rename to src/backend/Tap.Server/TailscaleClient.cs
diff --git a/src/Tap.Server/Tap.Server.csproj b/src/backend/Tap.Server/Tap.Server.csproj
similarity index 95%
rename from src/Tap.Server/Tap.Server.csproj
rename to src/backend/Tap.Server/Tap.Server.csproj
index b369e6d..7bfe40c 100644
--- a/src/Tap.Server/Tap.Server.csproj
+++ b/src/backend/Tap.Server/Tap.Server.csproj
@@ -18,7 +18,7 @@
serves a fully self-contained app. The ui/ directory is the source; the dist
is copied into wwwroot here. The Web SDK picks up wwwroot automatically. -->
- $(MSBuildThisFileDirectory)../../ui
+ $(MSBuildThisFileDirectory)../../ui-inspector
$(TapUiDir)/dist
$(MSBuildThisFileDirectory)wwwroot
diff --git a/src/Tap.Server/TapInspectorHost.cs b/src/backend/Tap.Server/TapInspectorHost.cs
similarity index 98%
rename from src/Tap.Server/TapInspectorHost.cs
rename to src/backend/Tap.Server/TapInspectorHost.cs
index 1036da6..bffe381 100644
--- a/src/Tap.Server/TapInspectorHost.cs
+++ b/src/backend/Tap.Server/TapInspectorHost.cs
@@ -95,6 +95,11 @@ public static WebApplication Build(string[] args, TapInspectorOptions options)
$"http://{options.ProxyHost}:{options.ProxyPort}",
$"http://{options.UiHost}:{options.UiPort}");
+ // The inspector is a transparent proxy — body-size policy belongs to the upstream API,
+ // not to Tap. Kestrel's default 30 MB cap would otherwise reject large uploads (videos,
+ // datasets, etc.) with 413 before they ever reach the upstream.
+ builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = null);
+
if (options.Quiet)
{
// Suppress per-request and lifetime chatter — the CLI renders its own log.
diff --git a/src/Tap.Server/UpstreamErrorPageMiddleware.cs b/src/backend/Tap.Server/UpstreamErrorPageMiddleware.cs
similarity index 100%
rename from src/Tap.Server/UpstreamErrorPageMiddleware.cs
rename to src/backend/Tap.Server/UpstreamErrorPageMiddleware.cs
diff --git a/src/Tap.Server/WebSocketProxy.cs b/src/backend/Tap.Server/WebSocketProxy.cs
similarity index 100%
rename from src/Tap.Server/WebSocketProxy.cs
rename to src/backend/Tap.Server/WebSocketProxy.cs
diff --git a/src/Tap.Server/appsettings.json b/src/backend/Tap.Server/appsettings.json
similarity index 100%
rename from src/Tap.Server/appsettings.json
rename to src/backend/Tap.Server/appsettings.json
diff --git a/src/Tap.Server/wwwroot/.gitkeep b/src/backend/Tap.Server/wwwroot/.gitkeep
similarity index 100%
rename from src/Tap.Server/wwwroot/.gitkeep
rename to src/backend/Tap.Server/wwwroot/.gitkeep
diff --git a/src/backend/Tap.Studio/Ai/AiProviderFactory.cs b/src/backend/Tap.Studio/Ai/AiProviderFactory.cs
new file mode 100644
index 0000000..9ff2ad6
--- /dev/null
+++ b/src/backend/Tap.Studio/Ai/AiProviderFactory.cs
@@ -0,0 +1,51 @@
+namespace Tap.Studio.Ai;
+
+///
+/// Resolves the active from persisted AI settings, with a small
+/// cache keyed on the settings signature (mirrors mango's getProvider /
+/// invalidateProvider ). Also exposes so the /test
+/// endpoint can validate draft settings without persisting them.
+///
+public sealed class AiProviderFactory
+{
+ private readonly SystemSettingsStore _settings;
+ private readonly Lock _gate = new();
+ private IAiProvider? _cached;
+ private string? _signature;
+
+ public AiProviderFactory(SystemSettingsStore settings)
+ {
+ _settings = settings;
+ _settings.Changed += Invalidate;
+ }
+
+ public static IAiProvider FromConfig(string provider, string? model, string? copilotCliPath, string? claudeCliPath)
+ => provider switch
+ {
+ ClaudeCodeProvider.ProviderName => new ClaudeCodeProvider(model, claudeCliPath),
+ _ => new CopilotCliProvider(model, copilotCliPath),
+ };
+
+ public IAiProvider Get()
+ {
+ var stored = _settings.GetAiSettings();
+ var name = stored?.Provider ?? CopilotCliProvider.ProviderName;
+ var signature = string.Join('|', name, stored?.Model, stored?.CopilotCliPath, stored?.ClaudeCliPath);
+ lock (_gate)
+ {
+ if (_cached is not null && _signature == signature) return _cached;
+ _signature = signature;
+ _cached = FromConfig(name, stored?.Model, stored?.CopilotCliPath, stored?.ClaudeCliPath);
+ return _cached;
+ }
+ }
+
+ public void Invalidate()
+ {
+ lock (_gate)
+ {
+ _cached = null;
+ _signature = null;
+ }
+ }
+}
diff --git a/src/backend/Tap.Studio/Ai/AiRequestAssistant.cs b/src/backend/Tap.Studio/Ai/AiRequestAssistant.cs
new file mode 100644
index 0000000..491aa2b
--- /dev/null
+++ b/src/backend/Tap.Studio/Ai/AiRequestAssistant.cs
@@ -0,0 +1,230 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Tap.Studio.Contracts;
+
+namespace Tap.Studio.Ai;
+
+///
+/// Builds the system prompt that teaches the model Tap's request schema and parses the
+/// ```tap-request JSON block the model emits back into a .
+/// The assistant never writes files — it proposes a spec the UI previews and the user applies.
+///
+public static partial class AiRequestAssistant
+{
+ /// Context handed to the model so edits are incremental and reference real
+ /// variables / auth profiles / collection settings instead of inventing them.
+ public sealed record AssistContext(
+ RequestSpecDto? CurrentSpec,
+ CollectionInfo? Collection,
+ IReadOnlyList AuthProfiles,
+ IReadOnlyList EnvironmentNames,
+ IReadOnlyList Variables);
+
+ /// The collection that owns the current request — its base URL, default auth,
+ /// shared headers, and stages all influence how a request should be crafted.
+ public sealed record CollectionInfo(
+ string Name,
+ string Path,
+ string? BaseUrl,
+ string? DefaultAuth,
+ IReadOnlyList DefaultHeaderNames,
+ IReadOnlyList StageNames,
+ string? DefaultStage);
+
+ /// A reusable auth profile the request can point at via its auth field.
+ public sealed record AuthProfileInfo(
+ string Path,
+ string? Name,
+ string Type,
+ IReadOnlyList Scopes,
+ IReadOnlyList HeaderNames);
+
+ /// A variable the model may reference with {{name}} , tagged with the
+ /// cascade scope it resolves from and whether it carries a secret (never inline its value).
+ public sealed record VariableInfo(
+ string Name,
+ string Scope,
+ bool Secret,
+ string? Description,
+ string? Example);
+
+ public static string BuildSystemPrompt(AssistContext ctx)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("You are Tap Studio's request assistant — you help craft and edit HTTP requests for the Tap workbench.");
+ sb.AppendLine("Tap stores each request as a `*.req.md` file. You never write files; instead you propose a structured request that the user previews and applies.");
+ sb.AppendLine();
+ sb.AppendLine("## How to respond");
+ sb.AppendLine("Write a short, friendly markdown explanation of what you changed and why (1-3 sentences).");
+ sb.AppendLine("When the user wants to create or change the request, ALSO emit exactly ONE fenced code block tagged `tap-request` containing a JSON object the UI can apply.");
+ sb.AppendLine("Omit the block entirely when the user only asks a question and no change is needed.");
+ sb.AppendLine();
+ sb.AppendLine("## The tap-request JSON shape");
+ sb.AppendLine("```");
+ sb.AppendLine("{");
+ sb.AppendLine(" \"name\": \"string — short human title\",");
+ sb.AppendLine(" \"method\": \"GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS\",");
+ sb.AppendLine(" \"url\": \"string — may contain {{variables}}; absolute or collection-relative\",");
+ sb.AppendLine(" \"headers\": [ { \"name\": \"Header-Name\", \"value\": \"value, may use {{vars}}\" } ],");
+ sb.AppendLine(" \"requestBody\": \"string | null — raw request body (JSON/text/etc.)\",");
+ sb.AppendLine(" \"auth\": \"string | null — relative path to a .auth.md profile, or 'none' to opt out\",");
+ sb.AppendLine(" \"protocol\": \"http | websocket — omit or 'http' for normal requests\",");
+ sb.AppendLine(" \"tags\": [ \"optional\", \"labels\" ],");
+ sb.AppendLine(" \"body\": \"markdown documentation shown under the request — see the Docs rule below\"");
+ sb.AppendLine("}");
+ sb.AppendLine("```");
+ sb.AppendLine("Rules: use `{{variableName}}` for values that should come from variables (never hardcode secrets like tokens or API keys — reference a variable instead). Set a `Content-Type` header when you set a JSON/form body. Only include fields you are changing plus the required `name`, `method`, `url`. Keep existing values you aren't changing.");
+ sb.AppendLine();
+ sb.AppendLine("## Docs (the `body` field)");
+ sb.AppendLine("Whenever you create a request — or change what it does in a meaningful way — ALWAYS set `body` to concise markdown documentation. The body is plain markdown WITHOUT any frontmatter and WITHOUT a fenced `http` block (Tap renders the request itself); just write prose.");
+ sb.AppendLine("Good docs cover: a one-line summary of what the endpoint does, notable path/query params and what they mean, the request body shape when there is one, which auth/variables it relies on, and the expected response. Keep it tight (a short paragraph plus optional bullet list). When you're only making a tiny tweak and docs already exist, refine them rather than rewriting from scratch — and preserve any existing notes the user wrote.");
+ sb.AppendLine();
+
+ if (ctx.CurrentSpec is { } cur)
+ {
+ sb.AppendLine("## Current request (edit this — keep what the user didn't ask to change)");
+ sb.AppendLine("```json");
+ sb.AppendLine(JsonSerializer.Serialize(new
+ {
+ name = cur.Name,
+ method = cur.Method,
+ url = cur.Url,
+ headers = cur.Headers?.Select(h => new { name = h.Name, value = h.Value }),
+ requestBody = cur.RequestBody,
+ auth = cur.Auth,
+ protocol = cur.Protocol,
+ tags = cur.Tags,
+ body = cur.Body,
+ }, new JsonSerializerOptions { WriteIndented = true }));
+ sb.AppendLine("```");
+ sb.AppendLine();
+ }
+ else
+ {
+ sb.AppendLine("There is no current request — you are helping author a new one.");
+ sb.AppendLine();
+ }
+
+ if (ctx.Collection is { } col)
+ {
+ sb.AppendLine("## Collection this request belongs to");
+ sb.AppendLine($"Name: {col.Name} ({col.Path}).");
+ if (!string.IsNullOrWhiteSpace(col.BaseUrl))
+ sb.AppendLine($"Base URL: {col.BaseUrl} — prefer a path relative to this base (e.g. `/users/{{{{id}}}}`) over repeating the host; an absolute `url` overrides it.");
+ else
+ sb.AppendLine("Base URL: none — requests in this collection use absolute URLs.");
+ if (!string.IsNullOrWhiteSpace(col.DefaultAuth))
+ sb.AppendLine($"Default auth: {col.DefaultAuth} is applied automatically — only set the request `auth` to override it or set `none` to opt out.");
+ if (col.DefaultHeaderNames.Count > 0)
+ sb.AppendLine($"Collection headers added to every request (don't duplicate these unless overriding): {string.Join(", ", col.DefaultHeaderNames)}.");
+ if (col.StageNames.Count > 0)
+ sb.AppendLine($"Stages (environments) available: {string.Join(", ", col.StageNames)}{(col.DefaultStage is { } ds ? $" (default: {ds})" : "")}. Vary host/values across stages with stage-scoped variables, not by hardcoding.");
+ sb.AppendLine();
+ }
+
+ if (ctx.AuthProfiles.Count > 0)
+ {
+ sb.AppendLine("## Auth profiles (set the request `auth` to one of these relative paths)");
+ foreach (var a in ctx.AuthProfiles)
+ {
+ var bits = new List { $"type {a.Type}" };
+ if (a.Scopes.Count > 0) bits.Add($"scopes: {string.Join(' ', a.Scopes)}");
+ if (a.HeaderNames.Count > 0) bits.Add($"sets headers: {string.Join(", ", a.HeaderNames)}");
+ var label = string.IsNullOrWhiteSpace(a.Name) ? a.Path : $"{a.Name} ({a.Path})";
+ sb.AppendLine($"- {label} — {string.Join("; ", bits)}");
+ }
+ sb.AppendLine("Reference a profile by `auth` instead of hand-writing Authorization headers.");
+ sb.AppendLine();
+ }
+
+ if (ctx.EnvironmentNames.Count > 0)
+ sb.AppendLine($"Workspace environments: {string.Join(", ", ctx.EnvironmentNames)}.");
+
+ if (ctx.Variables.Count > 0)
+ {
+ sb.AppendLine("## Variables you can reference with `{{name}}` (don't invent others; never inline a secret's value)");
+ foreach (var v in ctx.Variables)
+ {
+ var bits = new List { v.Scope };
+ if (v.Secret) bits.Add("secret");
+ if (!string.IsNullOrWhiteSpace(v.Description)) bits.Add(v.Description!.Trim());
+ else if (!string.IsNullOrWhiteSpace(v.Example)) bits.Add($"e.g. {v.Example!.Trim()}");
+ sb.AppendLine($"- {{{{{v.Name}}}}} — {string.Join("; ", bits)}");
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ [GeneratedRegex("```tap-request\\s*\\n(.*?)```", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
+ private static partial Regex TapRequestBlock();
+
+ /// Pulls the first ```tap-request block out of the reply and merges it
+ /// onto (so untouched fields survive). Returns null when the
+ /// reply has no block or the JSON is unusable.
+ public static RequestSpecDto? TryParseProposal(string reply, string path, RequestSpecDto? current)
+ {
+ var match = TapRequestBlock().Match(reply ?? string.Empty);
+ if (!match.Success) return null;
+ var json = match.Groups[1].Value.Trim();
+ if (json.Length == 0) return null;
+
+ ProposedRequest? p;
+ try { p = JsonSerializer.Deserialize(json, Options); }
+ catch { return null; }
+ if (p is null) return null;
+
+ var headers = p.Headers?
+ .Where(h => !string.IsNullOrWhiteSpace(h.Name))
+ .Select(h => new HttpHeaderSpecDto(h.Name!.Trim(), h.Value ?? string.Empty))
+ .ToArray();
+
+ var protocol = string.Equals(p.Protocol, "websocket", StringComparison.OrdinalIgnoreCase) ? "websocket" : null;
+
+ return new RequestSpecDto
+ {
+ Path = path,
+ Id = current?.Id,
+ Name = !string.IsNullOrWhiteSpace(p.Name) ? p.Name!.Trim() : (current?.Name ?? "New request"),
+ Method = !string.IsNullOrWhiteSpace(p.Method) ? p.Method!.Trim().ToUpperInvariant() : (current?.Method ?? "GET"),
+ Url = p.Url ?? current?.Url ?? string.Empty,
+ Headers = headers is { Length: > 0 } ? headers : (p.Headers is null ? current?.Headers : null),
+ RequestBody = p.RequestBody ?? (p.HasRequestBody ? null : current?.RequestBody),
+ Auth = p.Auth ?? current?.Auth,
+ Protocol = protocol ?? current?.Protocol,
+ Tags = p.Tags ?? current?.Tags,
+ Vars = current?.Vars,
+ Secrets = current?.Secrets,
+ Body = p.Body ?? current?.Body,
+ };
+ }
+
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private sealed record ProposedRequest
+ {
+ public string? Name { get; init; }
+ public string? Method { get; init; }
+ public string? Url { get; init; }
+ public List? Headers { get; init; }
+ public string? RequestBody { get; init; }
+ public string? Auth { get; init; }
+ public string? Protocol { get; init; }
+ public List? Tags { get; init; }
+ public string? Body { get; init; }
+
+ // Distinguish "requestBody omitted" from "requestBody: null (clear it)" isn't possible
+ // with System.Text.Json defaults, so a present-but-null is treated as "leave as-is".
+ public bool HasRequestBody => RequestBody is not null;
+ }
+
+ private sealed record ProposedHeader
+ {
+ public string? Name { get; init; }
+ public string? Value { get; init; }
+ }
+}
diff --git a/src/backend/Tap.Studio/Ai/ClaudeCodeProvider.cs b/src/backend/Tap.Studio/Ai/ClaudeCodeProvider.cs
new file mode 100644
index 0000000..45f200d
--- /dev/null
+++ b/src/backend/Tap.Studio/Ai/ClaudeCodeProvider.cs
@@ -0,0 +1,180 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Tap.Studio.Ai;
+
+///
+/// Drives the Claude Code CLI (claude ) as a subprocess. Unlike Copilot it accepts a
+/// real --system-prompt ; the user prompt is piped on stdin. Output comes back as a
+/// single JSON object (--output-format json ) carrying result + modelUsage .
+/// Tools are disabled (--allowed-tools "" ) and session persistence is off so each call
+/// is a clean one-shot.
+///
+public sealed class ClaudeCodeProvider : IAiProvider
+{
+ public const string ProviderName = "claude-code";
+ private const string EnvOverride = "TAP_CLAUDE_CLI";
+
+ private static readonly IReadOnlyList BuiltInModels = new[]
+ {
+ new AiModelOption("sonnet", "Claude Sonnet (latest)"),
+ new AiModelOption("opus", "Claude Opus (latest)"),
+ new AiModelOption("haiku", "Claude Haiku (latest)"),
+ new AiModelOption("claude-sonnet-4-6", "Claude Sonnet 4.6"),
+ new AiModelOption("claude-opus-4-6", "Claude Opus 4.6"),
+ new AiModelOption("claude-haiku-4-5", "Claude Haiku 4.5"),
+ };
+
+ private readonly LocatedCli _cli;
+ private readonly bool _authFound;
+
+ public ClaudeCodeProvider(string? model, string? cliPath)
+ {
+ Model = string.IsNullOrWhiteSpace(model) ? "sonnet" : model.Trim();
+ _cli = Locate(cliPath);
+ _authFound = DetectAuth();
+ SetupHint = BuildSetupHint(_cli, _authFound);
+ }
+
+ public string Name => ProviderName;
+ public bool Configured => _cli.Found && _authFound;
+ public string Model { get; }
+ public string SetupHint { get; }
+
+ internal static LocatedCli Locate(string? overridePath)
+ {
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var candidates = new[]
+ {
+ Path.Combine(home, ".claude", "local", "claude"),
+ Path.Combine(home, ".npm-global", "bin", "claude"),
+ Path.Combine(home, ".bun", "bin", "claude"),
+ Path.Combine(home, ".local", "bin", "claude"),
+ "/usr/local/bin/claude",
+ "/opt/homebrew/bin/claude",
+ };
+ return CliLocator.Locate("claude", candidates, Environment.GetEnvironmentVariable(EnvOverride), overridePath);
+ }
+
+ private static bool DetectAuth()
+ {
+ if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"))) return true;
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var candidates = new[]
+ {
+ Path.Combine(home, ".claude"),
+ Path.Combine(home, ".config", "claude"),
+ Path.Combine(home, "Library", "Application Support", "Claude"),
+ };
+ return candidates.Any(Directory.Exists);
+ }
+
+ private static string BuildSetupHint(LocatedCli cli, bool authFound)
+ {
+ if (!cli.Found)
+ {
+ return cli.Source is CliSource.Override or CliSource.Env
+ ? $"Claude Code CLI path \"{cli.Command}\" does not exist. Set the full path in AI settings or {EnvOverride}."
+ : "Install the Claude Code CLI (https://claude.com/claude-code) and sign in once. "
+ + $"Tap checks common locations and PATH; you can also set the full path in AI settings or {EnvOverride}.";
+ }
+ return authFound
+ ? $"Claude Code CLI found at {cli.Command}. Tap will spawn it for each request."
+ : $"Claude Code CLI found at {cli.Command}. Set ANTHROPIC_API_KEY, or run the CLI once to sign in.";
+ }
+
+ public async Task> ValidateAsync(CancellationToken ct)
+ {
+ var detection = await DetectAsync(_cli.Command, ct).ConfigureAwait(false);
+ if (!detection.Ok) throw new InvalidOperationException(detection.Error ?? "Claude Code CLI not found.");
+ return new Dictionary
+ {
+ ["cliPath"] = detection.Path,
+ ["cliVersion"] = detection.Version,
+ };
+ }
+
+ public Task> ListModelsAsync(CancellationToken ct)
+ => Task.FromResult(BuiltInModels);
+
+ public async Task ChatAsync(AiChatInput input, CancellationToken ct)
+ {
+ var useModel = string.IsNullOrWhiteSpace(input.Model) ? Model : input.Model!.Trim();
+ var userPrompt = FlattenConversation(input.Messages);
+
+ var args = new List
+ {
+ "-p",
+ "--output-format", "json",
+ "--system-prompt", input.SystemPrompt,
+ "--allowed-tools", "",
+ "--no-session-persistence",
+ };
+ if (!string.IsNullOrWhiteSpace(useModel)) { args.Add("--model"); args.Add(useModel); }
+
+ var (code, stdout, stderr) = await CliLocator
+ .RunAsync(_cli.Command, args, stdin: userPrompt, TimeSpan.FromMinutes(3), ct)
+ .ConfigureAwait(false);
+
+ if (code != 0 && stdout.Trim().Length == 0)
+ throw new InvalidOperationException($"Claude CLI exited with {code}: {(stderr.Trim().Length > 0 ? stderr.Trim() : "(no stderr)")}");
+
+ ClaudeResult? parsed;
+ try { parsed = JsonSerializer.Deserialize(stdout.Trim(), ClaudeJson.Default.ClaudeResult); }
+ catch (Exception e)
+ {
+ throw new InvalidOperationException($"Claude CLI output was not valid JSON: {e.Message}. First 200 chars: {stdout[..Math.Min(200, stdout.Length)]}");
+ }
+
+ if (parsed is null) throw new InvalidOperationException("Claude CLI returned no result.");
+ if (parsed.IsError) throw new InvalidOperationException($"Claude CLI reported an error: {parsed.Result ?? "(no detail)"}");
+
+ var text = parsed.Result ?? "";
+ var modelOut = parsed.ModelUsage is { Count: > 0 } ? parsed.ModelUsage.Keys.First() : useModel;
+ return new AiChatResult(text, modelOut, []);
+ }
+
+ private static string FlattenConversation(IReadOnlyList messages)
+ {
+ var lastUser = messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
+ if (lastUser is null) return "";
+ if (messages.Count <= 1) return lastUser.Content;
+ var history = string.Join("\n\n", messages.Select(m =>
+ string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase) ? $"User: {m.Content}" : $"Assistant: {m.Content}"));
+ return $"Conversation so far:\n\n{history}\n\nReply to the latest user message in markdown.";
+ }
+
+ public static async Task DetectAsync(string? overridePath, CancellationToken ct)
+ {
+ var cli = Locate(overridePath);
+ if (!cli.Found)
+ {
+ return new CliDetection(false, cli.Source == CliSource.Fallback ? null : cli.Command, cli.Source, null,
+ cli.Source == CliSource.Fallback
+ ? "Could not find Claude Code CLI in common install paths or PATH."
+ : $"Claude Code CLI path \"{cli.Command}\" does not exist or is not executable.");
+ }
+ try
+ {
+ var version = await CliLocator.GetVersionAsync(cli.Command,
+ line => line.Contains("claude", StringComparison.OrdinalIgnoreCase), ct).ConfigureAwait(false);
+ return new CliDetection(true, cli.Command, cli.Source, version, null);
+ }
+ catch (Exception e)
+ {
+ return new CliDetection(false, cli.Command, cli.Source, null, e.Message);
+ }
+ }
+}
+
+internal sealed record ClaudeResult
+{
+ [JsonPropertyName("type")] public string? Type { get; init; }
+ [JsonPropertyName("is_error")] public bool IsError { get; init; }
+ [JsonPropertyName("result")] public string? Result { get; init; }
+ [JsonPropertyName("modelUsage")] public Dictionary? ModelUsage { get; init; }
+}
+
+[JsonSerializable(typeof(ClaudeResult))]
+[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
+internal partial class ClaudeJson : JsonSerializerContext;
diff --git a/src/backend/Tap.Studio/Ai/CliLocator.cs b/src/backend/Tap.Studio/Ai/CliLocator.cs
new file mode 100644
index 0000000..bb08ad0
--- /dev/null
+++ b/src/backend/Tap.Studio/Ai/CliLocator.cs
@@ -0,0 +1,207 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Tap.Studio.Ai;
+
+/// How a CLI path was resolved — surfaced to the UI so users can tell a configured
+/// override apart from a lucky PATH hit.
+public enum CliSource { Override, Env, Candidate, Where, Path, Fallback }
+
+/// A located CLI binary plus whether it actually exists/executes.
+public sealed record LocatedCli(string Command, bool Found, CliSource Source);
+
+/// Result of probing a CLI: ok + version, or an actionable error.
+public sealed record CliDetection(bool Ok, string? Path, CliSource Source, string? Version, string? Error);
+
+///
+/// Shared logic for finding a locally-installed CLI (Copilot / Claude Code), mirroring
+/// mango's locateCli : explicit override → env var → well-known per-user install
+/// paths → where /command -v on a login shell → bare PATH scan → fallback to
+/// the bare name. Also hosts the small process-spawning helpers the providers share.
+///
+public static class CliLocator
+{
+ public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ public static bool IsExecutableFile(string candidate)
+ {
+ try
+ {
+ if (!File.Exists(candidate)) return false;
+ if (OperatingSystem.IsWindows()) return true; // Windows has no X bit; existence is enough.
+ var mode = File.GetUnixFileMode(candidate);
+ return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// Base CLI name, e.g. copilot or claude .
+ /// Well-known absolute install paths to probe, in priority order.
+ /// Value of the provider's env override (already read by the caller).
+ /// Explicit path the user configured in settings.
+ public static LocatedCli Locate(string binaryName, IReadOnlyList candidates, string? envValue, string? overridePath)
+ {
+ var cleanedOverride = overridePath?.Trim();
+ if (!string.IsNullOrEmpty(cleanedOverride))
+ return new LocatedCli(cleanedOverride, IsExecutableFile(cleanedOverride), CliSource.Override);
+
+ var cleanedEnv = envValue?.Trim();
+ if (!string.IsNullOrEmpty(cleanedEnv))
+ return new LocatedCli(cleanedEnv, IsExecutableFile(cleanedEnv), CliSource.Env);
+
+ foreach (var c in candidates)
+ if (IsExecutableFile(c)) return new LocatedCli(c, true, CliSource.Candidate);
+
+ if (FindWithWhere(binaryName) is { } whereHit)
+ return new LocatedCli(whereHit, true, CliSource.Where);
+
+ if (FindOnPath(binaryName) is { } pathHit)
+ return new LocatedCli(pathHit, true, CliSource.Path);
+
+ return new LocatedCli(binaryName, false, CliSource.Fallback);
+ }
+
+ private static string? FindOnPath(string binaryName)
+ {
+ var names = IsWindows ? new[] { binaryName + ".cmd", binaryName + ".exe", binaryName } : new[] { binaryName };
+ var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
+ foreach (var dir in path.Split(Path.PathSeparator))
+ {
+ if (string.IsNullOrEmpty(dir)) continue;
+ foreach (var name in names)
+ {
+ var candidate = Path.Combine(dir, name);
+ if (IsExecutableFile(candidate)) return candidate;
+ }
+ }
+ return null;
+ }
+
+ private static string? FindWithWhere(string binaryName)
+ {
+ var attempts = IsWindows
+ ? new (string, string[])[] { ("where", new[] { binaryName }) }
+ : new (string, string[])[]
+ {
+ ("zsh", new[] { "-lc", $"where {binaryName}" }),
+ ("sh", new[] { "-lc", $"command -v {binaryName}" }),
+ };
+
+ foreach (var (command, args) in attempts)
+ {
+ try
+ {
+ var psi = NewStartInfo(command, args);
+ using var proc = Process.Start(psi);
+ if (proc is null) continue;
+ var stdout = proc.StandardOutput.ReadToEnd();
+ proc.WaitForExit(5_000);
+ if (proc.ExitCode != 0) continue;
+ var candidate = stdout
+ .Split('\n', '\r')
+ .Select(l => l.Trim())
+ .FirstOrDefault(l => l.Length > 0 && IsExecutableFile(l));
+ if (candidate is not null) return candidate;
+ }
+ catch
+ {
+ // Shell not present / blocked — fall through to the next attempt.
+ }
+ }
+ return null;
+ }
+
+ /// Run the CLI's --version and return the first matching line.
+ public static async Task GetVersionAsync(string cliPath, Func? lineMatch, CancellationToken ct)
+ {
+ var (code, stdout, stderr) = await RunAsync(cliPath, new[] { "--version" }, stdin: null, TimeSpan.FromSeconds(8), ct)
+ .ConfigureAwait(false);
+ var lines = $"{stdout}\n{stderr}".Split('\n', '\r').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray();
+ var version = lineMatch is null
+ ? lines.FirstOrDefault()
+ : (lines.FirstOrDefault(lineMatch) ?? lines.FirstOrDefault());
+ if (code == 0 && version is not null) return version;
+ throw new InvalidOperationException(
+ $"Version check failed with exit {code}: {(stderr.Trim().Length > 0 ? stderr.Trim() : stdout.Trim())}");
+ }
+
+ private static ProcessStartInfo NewStartInfo(string command, IReadOnlyList args)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = command,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ foreach (var a in args) psi.ArgumentList.Add(a);
+ psi.Environment["NO_COLOR"] = "1";
+ return psi;
+ }
+
+ ///
+ /// Spawn with , optionally writing
+ /// , and collect stdout/stderr until exit or .
+ /// Returns the exit code (-1 if killed on timeout).
+ ///
+ public static async Task<(int Code, string Stdout, string Stderr)> RunAsync(
+ string cliPath, IReadOnlyList args, string? stdin, TimeSpan timeout, CancellationToken ct)
+ {
+ var psi = NewStartInfo(cliPath, args);
+
+ using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+ proc.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
+ proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
+
+ try
+ {
+ proc.Start();
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Could not spawn \"{cliPath}\": {ex.Message}");
+ }
+
+ proc.BeginOutputReadLine();
+ proc.BeginErrorReadLine();
+
+ if (stdin is not null)
+ {
+ await proc.StandardInput.WriteAsync(stdin.AsMemory(), ct).ConfigureAwait(false);
+ proc.StandardInput.Close();
+ }
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeoutCts.CancelAfter(timeout);
+ try
+ {
+ await proc.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { /* best effort */ }
+ if (ct.IsCancellationRequested) throw;
+ return (-1, stdout.ToString(), $"Timed out after {timeout.TotalSeconds:0}s.\n{stderr}");
+ }
+
+ return (proc.ExitCode, stdout.ToString(), stderr.ToString());
+ }
+
+ public static string ToWire(this CliSource source) => source switch
+ {
+ CliSource.Override => "override",
+ CliSource.Env => "env",
+ CliSource.Candidate => "candidate",
+ CliSource.Where => "where",
+ CliSource.Path => "path",
+ _ => "fallback",
+ };
+}
diff --git a/src/backend/Tap.Studio/Ai/CopilotCliProvider.cs b/src/backend/Tap.Studio/Ai/CopilotCliProvider.cs
new file mode 100644
index 0000000..468b3e4
--- /dev/null
+++ b/src/backend/Tap.Studio/Ai/CopilotCliProvider.cs
@@ -0,0 +1,263 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Tap.Studio.Ai;
+
+///
+/// Drives the GitHub Copilot CLI (copilot ) as a subprocess. We pass the prompt with
+/// -p , request --output-format json (NDJSON event stream), and read the final
+/// assistant.message event for the reply. Copilot has no --system-prompt flag,
+/// so the system context is inlined as a leading section of the prompt — same trick mango uses.
+///
+public sealed class CopilotCliProvider : IAiProvider
+{
+ public const string ProviderName = "copilot";
+ private const string EnvOverride = "TAP_COPILOT_CLI";
+
+ private static readonly IReadOnlyList BuiltInModels = new[]
+ {
+ new AiModelOption("claude-sonnet-4.6", "Claude Sonnet 4.6"),
+ new AiModelOption("claude-sonnet-4.5", "Claude Sonnet 4.5"),
+ new AiModelOption("claude-haiku-4.5", "Claude Haiku 4.5"),
+ new AiModelOption("claude-opus-4.6", "Claude Opus 4.6"),
+ new AiModelOption("gpt-5.4", "GPT-5.4"),
+ new AiModelOption("gpt-5.3-codex", "GPT-5.3 Codex"),
+ new AiModelOption("gpt-5.1", "GPT-5.1"),
+ new AiModelOption("gpt-5-mini", "GPT-5 Mini"),
+ };
+
+ private readonly LocatedCli _cli;
+ private readonly bool _authFound;
+ private static string? s_isolatedConfigDir;
+
+ public CopilotCliProvider(string? model, string? cliPath)
+ {
+ Model = string.IsNullOrWhiteSpace(model) ? "claude-sonnet-4.5" : model.Trim();
+ _cli = Locate(cliPath);
+ _authFound = DetectAuth();
+ SetupHint = BuildSetupHint(_cli, _authFound);
+ }
+
+ public string Name => ProviderName;
+ public bool Configured => _cli.Found && _authFound;
+ public string Model { get; }
+ public string SetupHint { get; }
+
+ internal static LocatedCli Locate(string? overridePath)
+ {
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var candidates = new[]
+ {
+ "/opt/homebrew/bin/copilot",
+ "/usr/local/bin/copilot",
+ Path.Combine(home, ".npm-global", "bin", "copilot"),
+ Path.Combine(home, ".local", "bin", "copilot"),
+ };
+ return CliLocator.Locate("copilot", candidates, Environment.GetEnvironmentVariable(EnvOverride), overridePath);
+ }
+
+ private static bool DetectAuth()
+ {
+ if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_TOKEN"))) return true;
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var candidates = new[]
+ {
+ Path.Combine(home, ".copilot"),
+ Path.Combine(home, ".config", "github-copilot"),
+ Path.Combine(home, "Library", "Application Support", "GitHub Copilot"),
+ };
+ return candidates.Any(Directory.Exists);
+ }
+
+ private static string BuildSetupHint(LocatedCli cli, bool authFound)
+ {
+ if (!cli.Found)
+ {
+ return cli.Source is CliSource.Override or CliSource.Env
+ ? $"Copilot CLI path \"{cli.Command}\" does not exist. Set the full path in AI settings or {EnvOverride}."
+ : "Install the GitHub Copilot CLI (https://docs.github.com/copilot/github-copilot-cli) and sign in once. "
+ + $"Tap checks common locations and PATH; you can also set the full path in AI settings or {EnvOverride}.";
+ }
+ return authFound
+ ? $"Copilot CLI found at {cli.Command}. Tap will spawn it for each request."
+ : $"Copilot CLI found at {cli.Command}. Set GITHUB_TOKEN with Copilot access, or run the CLI once to sign in.";
+ }
+
+ // Point the CLI at an isolated config dir so it doesn't load the user's MCP servers /
+ // agents on every spawn — that's where most of the startup cost lives.
+ private static string IsolatedConfigDir()
+ {
+ if (s_isolatedConfigDir is not null) return s_isolatedConfigDir;
+ var dir = Path.Combine(Path.GetTempPath(), "tap-copilot-cfg");
+ try
+ {
+ Directory.CreateDirectory(dir);
+ var mcp = Path.Combine(dir, "mcp-config.json");
+ if (!File.Exists(mcp)) File.WriteAllText(mcp, "{}\n");
+ }
+ catch { /* fall back to the user's default config dir */ }
+ s_isolatedConfigDir = dir;
+ return dir;
+ }
+
+ public async Task> ValidateAsync(CancellationToken ct)
+ {
+ var detection = await DetectAsync(_cli.Command, ct).ConfigureAwait(false);
+ if (!detection.Ok) throw new InvalidOperationException(detection.Error ?? "Copilot CLI not found.");
+ return new Dictionary
+ {
+ ["cliPath"] = detection.Path,
+ ["cliVersion"] = detection.Version,
+ };
+ }
+
+ public Task> ListModelsAsync(CancellationToken ct)
+ => Task.FromResult(BuiltInModels);
+
+ public async Task ChatAsync(AiChatInput input, CancellationToken ct)
+ {
+ var useModel = string.IsNullOrWhiteSpace(input.Model) ? Model : input.Model!.Trim();
+ var userPrompt = FlattenConversation(input.Messages);
+ var combined = $"## Instructions\n\n{input.SystemPrompt}\n\n## Request\n\n{userPrompt}";
+
+ var args = new List
+ {
+ "-p", combined,
+ "--output-format", "json",
+ "--no-color",
+ "--allow-all-tools",
+ "--config-dir", IsolatedConfigDir(),
+ };
+ if (!string.IsNullOrWhiteSpace(useModel)) { args.Add("--model"); args.Add(useModel); }
+
+ var (code, stdout, stderr) = await CliLocator
+ .RunAsync(_cli.Command, args, stdin: null, TimeSpan.FromMinutes(3), ct)
+ .ConfigureAwait(false);
+
+ string assistant = "";
+ string modelOut = useModel;
+ string? errored = null;
+ var toolStarts = new Dictionary(StringComparer.Ordinal);
+ var toolCalls = new List();
+ foreach (var line in stdout.Split('\n'))
+ {
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0) continue;
+ CopilotEvent? evt;
+ try { evt = JsonSerializer.Deserialize(trimmed, CopilotJson.Default.CopilotEvent); }
+ catch { continue; }
+ if (evt is null) continue;
+ switch (evt.Type)
+ {
+ case "assistant.message" when evt.Data?.Content is { } content:
+ assistant = content;
+ break;
+ case "error" or "session.error" when evt.Data?.Message is { } msg:
+ errored = msg;
+ break;
+ case "tool.execution_start" when evt.Data?.ToolName is { } name:
+ // Record start keyed by id so the matching complete can fill in success.
+ var summary = SummarizeToolArgs(name, evt.Data.Arguments);
+ var entry = new ToolStart(name, summary, toolCalls.Count);
+ toolCalls.Add(new AiToolCall(name, summary, null));
+ if (!string.IsNullOrEmpty(evt.Data.ToolCallId))
+ toolStarts[evt.Data.ToolCallId] = entry;
+ break;
+ case "tool.execution_complete" when evt.Data?.ToolCallId is { } id && toolStarts.TryGetValue(id, out var started):
+ toolCalls[started.Index] = new AiToolCall(started.Name, started.Summary, evt.Data.Success);
+ break;
+ }
+ }
+
+ if (errored is not null) throw new InvalidOperationException($"Copilot CLI: {errored}");
+ if (code != 0 && assistant.Length == 0)
+ throw new InvalidOperationException($"Copilot CLI exited with {code}: {(stderr.Trim().Length > 0 ? stderr.Trim() : "(no stderr)")}");
+
+ return new AiChatResult(assistant, modelOut, toolCalls);
+ }
+
+ private readonly record struct ToolStart(string Name, string? Summary, int Index);
+
+ /// Builds a short, human-readable summary of a tool call's arguments for the UI.
+ /// Prefers the most descriptive field for known tools, falls back to the first scalar.
+ private static string? SummarizeToolArgs(string toolName, JsonElement? args)
+ {
+ if (args is not { ValueKind: JsonValueKind.Object } obj) return null;
+
+ foreach (var key in new[] { "command", "description", "query", "url", "path", "pattern", "intent", "prompt" })
+ {
+ if (obj.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String)
+ {
+ var s = v.GetString();
+ if (!string.IsNullOrWhiteSpace(s)) return Truncate(s!.Trim(), 160);
+ }
+ }
+ foreach (var prop in obj.EnumerateObject())
+ {
+ if (prop.Value.ValueKind == JsonValueKind.String)
+ {
+ var s = prop.Value.GetString();
+ if (!string.IsNullOrWhiteSpace(s)) return Truncate(s!.Trim(), 160);
+ }
+ }
+ return null;
+ }
+
+ private static string Truncate(string s, int max)
+ {
+ s = s.ReplaceLineEndings(" ");
+ return s.Length <= max ? s : s[..max] + "…";
+ }
+
+ private static string FlattenConversation(IReadOnlyList messages)
+ {
+ var lastUser = messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
+ if (lastUser is null) return "";
+ if (messages.Count <= 1) return lastUser.Content;
+ var history = string.Join("\n\n", messages.Select(m =>
+ string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase) ? $"User: {m.Content}" : $"Assistant: {m.Content}"));
+ return $"Conversation so far:\n\n{history}\n\nReply to the latest user message.";
+ }
+
+ public static async Task DetectAsync(string? overridePath, CancellationToken ct)
+ {
+ var cli = Locate(overridePath);
+ if (!cli.Found)
+ {
+ return new CliDetection(false, cli.Source == CliSource.Fallback ? null : cli.Command, cli.Source, null,
+ cli.Source == CliSource.Fallback
+ ? "Could not find Copilot CLI in common install paths or PATH."
+ : $"Copilot CLI path \"{cli.Command}\" does not exist or is not executable.");
+ }
+ try
+ {
+ var version = await CliLocator.GetVersionAsync(cli.Command,
+ line => line.StartsWith("GitHub Copilot CLI", StringComparison.OrdinalIgnoreCase), ct).ConfigureAwait(false);
+ return new CliDetection(true, cli.Command, cli.Source, version, null);
+ }
+ catch (Exception e)
+ {
+ return new CliDetection(false, cli.Command, cli.Source, null, e.Message);
+ }
+ }
+}
+
+internal sealed record CopilotEvent
+{
+ [JsonPropertyName("type")] public string? Type { get; init; }
+ [JsonPropertyName("data")] public CopilotEventData? Data { get; init; }
+}
+
+internal sealed record CopilotEventData
+{
+ [JsonPropertyName("content")] public string? Content { get; init; }
+ [JsonPropertyName("message")] public string? Message { get; init; }
+ [JsonPropertyName("toolCallId")] public string? ToolCallId { get; init; }
+ [JsonPropertyName("toolName")] public string? ToolName { get; init; }
+ [JsonPropertyName("arguments")] public JsonElement? Arguments { get; init; }
+ [JsonPropertyName("success")] public bool? Success { get; init; }
+}
+
+[JsonSerializable(typeof(CopilotEvent))]
+[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
+internal partial class CopilotJson : JsonSerializerContext;
diff --git a/src/backend/Tap.Studio/Ai/IAiProvider.cs b/src/backend/Tap.Studio/Ai/IAiProvider.cs
new file mode 100644
index 0000000..5026f37
--- /dev/null
+++ b/src/backend/Tap.Studio/Ai/IAiProvider.cs
@@ -0,0 +1,51 @@
+namespace Tap.Studio.Ai;
+
+/// One selectable model exposed by a provider.
+public sealed record AiModelOption(string Id, string? Name = null);
+
+/// A single turn in the assistant conversation. Role is user or assistant .
+public sealed record AiChatMessage(string Role, string Content);
+
+/// Input to : a system prompt plus the running
+/// conversation. Model overrides the provider default when set.
+public sealed record AiChatInput(string SystemPrompt, IReadOnlyList Messages, string? Model);
+
+/// Result of a chat call — the assistant reply text, the model that produced it,
+/// and any tool calls the CLI ran along the way (empty when the provider runs without tools).
+public sealed record AiChatResult(string Text, string Model, IReadOnlyList ToolCalls);
+
+/// A single tool invocation surfaced from the CLI's event stream, shown in the UI so
+/// the user can see what the assistant did (e.g. ran a shell command, fetched a URL).
+public sealed record AiToolCall(string Name, string? Summary, bool? Success);
+
+///
+/// Abstraction over a locally-installed AI coding CLI (GitHub Copilot CLI or Claude Code).
+/// We spawn the user's installed binary per request instead of bundling an SDK — the same
+/// approach mango uses — so Tap.Studio carries no per-platform native dependencies and the
+/// user's existing CLI auth is reused as-is.
+///
+public interface IAiProvider
+{
+ /// Stable provider id: copilot or claude-code .
+ string Name { get; }
+
+ /// True when both the CLI binary and an auth signal were found — i.e. a chat
+ /// call has a reasonable chance of succeeding without further setup.
+ bool Configured { get; }
+
+ /// The default model this provider will use when a request doesn't pick one.
+ string Model { get; }
+
+ /// Human-readable next step shown in the UI when something needs configuring.
+ string SetupHint { get; }
+
+ /// Cheap liveness probe — runs the CLI's --version and returns diagnostics.
+ /// Throws when the CLI can't be found or run.
+ Task> ValidateAsync(CancellationToken ct);
+
+ /// Send the conversation to the CLI and return its reply.
+ Task ChatAsync(AiChatInput input, CancellationToken ct);
+
+ /// List the models the user can pick. Best-effort — falls back to a built-in catalog.
+ Task> ListModelsAsync(CancellationToken ct);
+}
diff --git a/src/backend/Tap.Studio/Auth/AuthFieldResolver.cs b/src/backend/Tap.Studio/Auth/AuthFieldResolver.cs
new file mode 100644
index 0000000..1fbcc9e
--- /dev/null
+++ b/src/backend/Tap.Studio/Auth/AuthFieldResolver.cs
@@ -0,0 +1,61 @@
+using Tap.Workspace;
+using Tap.Workspace.Model;
+using Tap.Workspace.Rendering;
+using Tap.Workspace.Variables;
+
+namespace Tap.Studio.Auth;
+
+///
+/// Expands {{var}} + {{provider:name}} references inside auth-profile fields.
+/// Auth profiles aren't bound to a request, so the cascade here is just workspace + default env
+/// — the same prefix the renderer would build for a request, minus the api/stage/request
+/// layers. The provider registry is consulted for anything not in the cascade.
+///
+/// Without this pass, a tokenUrl like http://{{DEMO_API_URL}}/connect/token would
+/// be sent verbatim and the runner would 500 with an invalid-URI.
+///
+public sealed class AuthFieldResolver
+{
+ private readonly LoadedWorkspace _workspace;
+ private readonly VariableProviderRegistry _registry;
+ private readonly IReadOnlyDictionary _cascade;
+
+ public AuthFieldResolver(LoadedWorkspace workspace, VariableProviderRegistry registry, EnvFile? env)
+ {
+ _workspace = workspace;
+ _registry = registry;
+
+ // workspace < env. Skipped nulls so an unset value doesn't write the literal "null".
+ var cascade = new Dictionary(StringComparer.Ordinal);
+ if (workspace.Manifest is not null)
+ {
+ foreach (var (k, v) in workspace.Manifest.Vars)
+ if (v.Default is { } d) cascade[k] = d;
+ }
+ if (env is not null)
+ {
+ foreach (var (k, v) in env.Vars)
+ if (v.Default is { } d) cascade[k] = d;
+ }
+ _cascade = cascade;
+ }
+
+ /// Expand {{var}} + {{provider:name}} in one call. Async because
+ /// providers can be remote (azkv).
+ public async ValueTask AllAsync(string? input, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(input)) return input;
+ return await Interpolation.ExpandAsync(input!, _cascade, _registry, ct).ConfigureAwait(false);
+ }
+
+ /// Same as but expanded for
+ /// every entry in a list — used for scopes .
+ public async ValueTask> AllAsync(IReadOnlyList input, CancellationToken ct)
+ {
+ if (input.Count == 0) return input;
+ var result = new string[input.Count];
+ for (int i = 0; i < input.Count; i++)
+ result[i] = await AllAsync(input[i], ct).ConfigureAwait(false) ?? string.Empty;
+ return result;
+ }
+}
diff --git a/src/backend/Tap.Studio/Auth/AuthFlowStore.cs b/src/backend/Tap.Studio/Auth/AuthFlowStore.cs
new file mode 100644
index 0000000..92ba766
--- /dev/null
+++ b/src/backend/Tap.Studio/Auth/AuthFlowStore.cs
@@ -0,0 +1,90 @@
+using System.Collections.Concurrent;
+
+namespace Tap.Studio.Auth;
+
+///
+/// In-memory store of in-progress OAuth2 authorization-code flows. A flow is short-lived —
+/// the user opens the consent popup, signs in, gets redirected to /api/auth/callback ,
+/// the backend exchanges the code for a token, the UI's poll picks up the completion, and
+/// then the flow is done. Persistence would be over-engineering at this stage.
+///
+/// Expired flows (older than ) are pruned lazily on add.
+///
+public sealed class AuthFlowStore
+{
+ private static readonly TimeSpan ExpiresAfter = TimeSpan.FromMinutes(10);
+ private readonly ConcurrentDictionary _flows = new();
+
+ public AuthFlow Create(AuthFlow flow)
+ {
+ PruneExpired();
+ _flows[flow.Id] = flow;
+ return flow;
+ }
+
+ public AuthFlow? Get(string id) => _flows.TryGetValue(id, out var f) ? f : null;
+
+ public void Update(string id, Action mutate)
+ {
+ if (!_flows.TryGetValue(id, out var f)) return;
+ mutate(f);
+ }
+
+ public void Remove(string id) => _flows.TryRemove(id, out _);
+
+ private void PruneExpired()
+ {
+ var now = DateTimeOffset.UtcNow;
+ foreach (var (id, flow) in _flows)
+ {
+ if (flow.CreatedAt + ExpiresAfter < now) _flows.TryRemove(id, out _);
+ }
+ }
+}
+
+///
+/// State for one in-progress flow. Stored in-memory; not persisted. Fields are settable so
+/// the callback can write back the result without rebuilding the whole record.
+///
+public sealed class AuthFlow
+{
+ public required string Id { get; init; }
+ /// Workspace-relative path of the auth profile this flow is for.
+ public required string AuthPath { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+
+ /// PKCE code verifier — only kept until token exchange completes.
+ public required string CodeVerifier { get; init; }
+ /// Nonce sent on the authorize request; must match on the id_token.
+ public required string Nonce { get; init; }
+ public required string Authority { get; init; }
+ public required string TokenEndpoint { get; init; }
+ public required string ClientId { get; init; }
+ public string? ClientSecret { get; init; }
+ public required string RedirectUri { get; init; }
+ public required IReadOnlyList Scopes { get; init; }
+
+ public AuthFlowStatus Status { get; set; } = AuthFlowStatus.Pending;
+ public string? AccessToken { get; set; }
+ public string? IdToken { get; set; }
+ public string? RefreshToken { get; set; }
+ public string? TokenType { get; set; }
+ public DateTimeOffset? ExpiresAt { get; set; }
+ public string? Error { get; set; }
+
+ // Device-code only. Populated by the runner when it kicks off RFC 8628; the UI shows
+ // these to the user while polling for completion. Null for every other grant.
+ public string? UserCode { get; set; }
+ public string? VerificationUri { get; set; }
+ public string? VerificationUriComplete { get; set; }
+}
+
+public enum AuthFlowStatus
+{
+ /// Authorize URL handed to UI; awaiting callback.
+ Pending,
+ /// Callback received and token exchange succeeded.
+ Completed,
+ /// Identity provider returned an error, or token exchange failed.
+ Failed,
+}
diff --git a/src/backend/Tap.Studio/Auth/AuthRunner.cs b/src/backend/Tap.Studio/Auth/AuthRunner.cs
new file mode 100644
index 0000000..b873b12
--- /dev/null
+++ b/src/backend/Tap.Studio/Auth/AuthRunner.cs
@@ -0,0 +1,1165 @@
+using System.Diagnostics;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Tap.Workspace.Model;
+
+namespace Tap.Studio.Auth;
+
+///
+/// Executes an auth profile end-to-end. Field values (urls, client ids, scopes…) go through
+/// first so workspace {{var}} + ${{secret}}
+/// refs resolve before any HTTP call.
+///
+/// Grant matrix:
+///
+/// client_credentials — m2m, sync. Posts to the token endpoint.
+/// password (ROPC) — username/password, sync. Useful for smoke tests.
+/// authorization_code / authorization_code_pkce — interactive popup
+/// hand-off via ; the callback finishes the exchange.
+/// device_code — polls the token endpoint after the user authenticates on
+/// a second device. +
+/// are surfaced for the UI to display.
+/// refresh_token — opportunistic; swaps a
+/// near-expiry token for a fresh one without re-prompting.
+/// azure-cli — shells out to az account get-access-token ; no HTTP
+/// credentials kept in the profile.
+/// azure-cli-obo — On-Behalf-Of: an upstream API exchanges the user's
+/// az-cli token (or any inbound JWT) for a downstream token via the
+/// urn:ietf:params:oauth:grant-type:jwt-bearer grant.
+///
+///
+public sealed class AuthRunner
+{
+ private readonly HttpClient _http;
+ private readonly OidcDiscoveryClient _discovery;
+ private readonly AuthFlowStore _flows;
+ private readonly AuthTokenStore _tokens;
+ private readonly WorkspaceService _ws;
+ private readonly IHttpContextAccessor _httpCtx;
+ private readonly ILogger _logger;
+
+ public AuthRunner(
+ HttpClient http,
+ OidcDiscoveryClient discovery,
+ AuthFlowStore flows,
+ AuthTokenStore tokens,
+ WorkspaceService ws,
+ IHttpContextAccessor httpCtx,
+ ILogger logger)
+ {
+ _http = http;
+ _discovery = discovery;
+ _flows = flows;
+ _tokens = tokens;
+ _ws = ws;
+ _httpCtx = httpCtx;
+ _logger = logger;
+ }
+
+ ///
+ /// Start the auth flow for the given profile. Returns either a completed result (sync
+ /// flows + cache hits) or a /
+ /// for the UI to drive an interactive step.
+ ///
+ public async Task ExecuteAsync(string authPath, bool forceReauthenticate, CancellationToken ct)
+ {
+ var workspace = _ws.Current;
+ if (workspace.FindByPath(authPath) is not AuthFile auth)
+ return ExecuteAuthResult.Failed($"Auth profile '{authPath}' not in workspace.");
+
+ if (!forceReauthenticate)
+ {
+ var cached = _tokens.Get(_ws.RootDirectory, authPath);
+ if (cached is not null && (cached.ExpiresAt is null || cached.ExpiresAt > DateTimeOffset.UtcNow + TimeSpan.FromSeconds(30)))
+ {
+ return ExecuteAuthResult.FromTokens(cached, fromCache: true);
+ }
+
+ // Token cached but stale — try to refresh silently before re-prompting.
+ if (cached is { RefreshToken: not null } && auth.Type == "oauth2")
+ {
+ var refreshed = await RefreshIfPossibleAsync(auth, authPath, cached, ct).ConfigureAwait(false);
+ if (refreshed is not null) return ExecuteAuthResult.FromTokens(refreshed, fromCache: false);
+ }
+ }
+
+ var resolver = CreateResolver();
+ try
+ {
+ return auth.Type switch
+ {
+ "oauth2" => await ExecuteOAuth2Async(auth, authPath, resolver, ct).ConfigureAwait(false),
+ "azure-cli" => await ExecuteAzureAsync(auth, authPath, resolver, ct).ConfigureAwait(false),
+ "jwt" => await ExecuteJwtAsync(auth, authPath, resolver, ct).ConfigureAwait(false),
+ "github" => await ExecuteGithubAsync(auth, authPath, resolver, ct).ConfigureAwait(false),
+ "basic" or "bearer" or "apiKey" or "custom" or "none" =>
+ ExecuteAuthResult.SyntheticHeaders(await BuildHeadersForAsync(auth, resolver, ct).ConfigureAwait(false)),
+ _ => ExecuteAuthResult.Failed($"Auth type '{auth.Type}' is not supported by the runner yet."),
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Auth profile '{Path}' execution failed", authPath);
+ return ExecuteAuthResult.Failed(ex.Message);
+ }
+ }
+
+ public async Task CompleteAuthCodeAsync(string flowId, string code, CancellationToken ct)
+ {
+ var flow = _flows.Get(flowId);
+ if (flow is null) return ExecuteAuthResult.Failed("Flow not found or expired.");
+
+ try
+ {
+ var token = await ExchangeCodeAsync(flow, code, ct).ConfigureAwait(false);
+ flow.AccessToken = token.AccessToken;
+ flow.IdToken = token.IdToken;
+ flow.RefreshToken = token.RefreshToken;
+ flow.TokenType = token.TokenType;
+ flow.ExpiresAt = token.ExpiresIn is { } s ? DateTimeOffset.UtcNow.AddSeconds(s) : null;
+ flow.Status = AuthFlowStatus.Completed;
+
+ var entry = new AuthTokenEntry
+ {
+ AccessToken = token.AccessToken,
+ IdToken = token.IdToken,
+ RefreshToken = token.RefreshToken,
+ TokenType = token.TokenType,
+ ExpiresAt = flow.ExpiresAt,
+ ObtainedAt = DateTimeOffset.UtcNow,
+ TokenEndpoint = flow.TokenEndpoint,
+ ClientId = flow.ClientId,
+ Scopes = flow.Scopes,
+ };
+ _tokens.Save(_ws.RootDirectory, flow.AuthPath, entry);
+ return ExecuteAuthResult.FromTokens(entry, fromCache: false);
+ }
+ catch (Exception ex)
+ {
+ flow.Status = AuthFlowStatus.Failed;
+ flow.Error = ex.Message;
+ return ExecuteAuthResult.Failed(ex.Message);
+ }
+ }
+
+ private AuthFieldResolver CreateResolver()
+ {
+ var ws = _ws.Current;
+ EnvFile? env = null;
+ if (ws.Manifest?.DefaultEnv is { } defaultRef)
+ env = ws.Resolve(defaultRef) as EnvFile;
+ return new AuthFieldResolver(ws, _ws.CreateRegistry(), env);
+ }
+
+ // ---- OAuth2 ----------------------------------------------------------------------
+
+ private async Task ExecuteOAuth2Async(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ var grantType = (auth.Fields.GetValueOrDefault("flow")
+ ?? auth.Fields.GetValueOrDefault("grantType")
+ ?? "authorization_code_pkce").Trim();
+ var useDiscovery = string.Equals(auth.Fields.GetValueOrDefault("useDiscovery"), "true", StringComparison.OrdinalIgnoreCase);
+
+ // Resolve every templated field up-front. tokenUrl/authorizeUrl/etc. may carry
+ // {{DEMO_API_URL}} placeholders or ${{env:…}} secret refs.
+ var authority = await resolver.AllAsync(auth.Fields.GetValueOrDefault("authority"), ct).ConfigureAwait(false);
+ var tokenEndpoint = await resolver.AllAsync(auth.Fields.GetValueOrDefault("tokenUrl"), ct).ConfigureAwait(false) ?? string.Empty;
+ var authorizeEndpoint = await resolver.AllAsync(auth.Fields.GetValueOrDefault("authorizeUrl"), ct).ConfigureAwait(false) ?? string.Empty;
+ var deviceEndpoint = await resolver.AllAsync(auth.Fields.GetValueOrDefault("deviceAuthorizationUrl"), ct).ConfigureAwait(false) ?? string.Empty;
+
+ if (useDiscovery && !string.IsNullOrWhiteSpace(authority))
+ {
+ try
+ {
+ var doc = await _discovery.FetchAsync(authority!, ct).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(tokenEndpoint)) tokenEndpoint = doc.TokenEndpoint;
+ if (string.IsNullOrWhiteSpace(authorizeEndpoint)) authorizeEndpoint = doc.AuthorizationEndpoint;
+ // device_authorization_endpoint isn't on our DTO; pull it lazily from the well-known
+ // when the user picks device_code without an explicit URL.
+ if (string.IsNullOrWhiteSpace(deviceEndpoint) && grantType == "device_code")
+ deviceEndpoint = await TryDiscoverDeviceEndpointAsync(authority!, ct).ConfigureAwait(false) ?? string.Empty;
+ }
+ catch (Exception ex)
+ {
+ return ExecuteAuthResult.Failed($"OIDC discovery failed: {ex.Message}");
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(tokenEndpoint) && grantType != "device_code")
+ return ExecuteAuthResult.Failed("OAuth2: tokenUrl is missing (set it explicitly or enable Use Discovery with an authority).");
+
+ var clientId = await resolver.AllAsync(auth.Fields.GetValueOrDefault("clientId"), ct).ConfigureAwait(false);
+ var clientSecret = await resolver.AllAsync(auth.Fields.GetValueOrDefault("clientSecret"), ct).ConfigureAwait(false);
+ var audience = await resolver.AllAsync(auth.Fields.GetValueOrDefault("audience"), ct).ConfigureAwait(false);
+ var username = await resolver.AllAsync(auth.Fields.GetValueOrDefault("username"), ct).ConfigureAwait(false);
+ var password = await resolver.AllAsync(auth.Fields.GetValueOrDefault("password"), ct).ConfigureAwait(false);
+ // Redirect URI is owned by the runtime, not the auth profile — Aspire allocates a
+ // fresh port every boot, so any value pinned in the workspace file is stale by the
+ // next restart. We always derive it from the inbound HttpContext; anything written
+ // to the file is ignored. The UI shows the live value read-only so the user knows
+ // what to register with their identity provider.
+ var redirectUri = ResolveCallbackUri();
+ var scopes = await ResolveScopesAsync(auth, resolver, ct).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(clientId))
+ return ExecuteAuthResult.Failed("OAuth2: clientId is required.");
+
+ return grantType switch
+ {
+ "client_credentials" =>
+ await ClientCredentialsAsync(authPath, tokenEndpoint, clientId!, clientSecret, scopes, audience, ct).ConfigureAwait(false),
+ "password" or "resource_owner" or "ropc" =>
+ await PasswordAsync(authPath, tokenEndpoint, clientId!, clientSecret, username, password, scopes, audience, ct).ConfigureAwait(false),
+ "device_code" or "device" =>
+ await DeviceCodeAsync(authPath, deviceEndpoint, tokenEndpoint, clientId!, clientSecret, scopes, audience, ct).ConfigureAwait(false),
+ "authorization_code" or "authorization_code_pkce" =>
+ StartAuthCodeFlow(authPath, authorizeEndpoint, tokenEndpoint, authority, clientId!, clientSecret, redirectUri!, scopes, audience),
+ _ => ExecuteAuthResult.Failed($"OAuth2 grant '{grantType}' is not supported yet."),
+ };
+ }
+
+ private async Task ClientCredentialsAsync(
+ string authPath, string tokenEndpoint, string clientId, string? clientSecret,
+ IReadOnlyList scopes, string? audience, CancellationToken ct)
+ {
+ var form = new Dictionary
+ {
+ ["grant_type"] = "client_credentials",
+ ["client_id"] = clientId,
+ };
+ if (!string.IsNullOrEmpty(clientSecret)) form["client_secret"] = clientSecret;
+ if (scopes.Count > 0) form["scope"] = string.Join(' ', scopes);
+ if (!string.IsNullOrEmpty(audience)) form["audience"] = audience!;
+
+ var token = await PostTokenAsync(tokenEndpoint, form, ct).ConfigureAwait(false);
+ return SaveAndReturn(authPath, tokenEndpoint, clientId, scopes, token);
+ }
+
+ private async Task PasswordAsync(
+ string authPath, string tokenEndpoint, string clientId, string? clientSecret,
+ string? username, string? password, IReadOnlyList scopes, string? audience, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
+ return ExecuteAuthResult.Failed("OAuth2 password grant: username + password are required (set on the auth profile).");
+
+ var form = new Dictionary
+ {
+ ["grant_type"] = "password",
+ ["client_id"] = clientId,
+ ["username"] = username!,
+ ["password"] = password!,
+ };
+ if (!string.IsNullOrEmpty(clientSecret)) form["client_secret"] = clientSecret;
+ if (scopes.Count > 0) form["scope"] = string.Join(' ', scopes);
+ if (!string.IsNullOrEmpty(audience)) form["audience"] = audience!;
+
+ var token = await PostTokenAsync(tokenEndpoint, form, ct).ConfigureAwait(false);
+ return SaveAndReturn(authPath, tokenEndpoint, clientId, scopes, token);
+ }
+
+ private async Task DeviceCodeAsync(
+ string authPath, string deviceEndpoint, string tokenEndpoint, string clientId, string? clientSecret,
+ IReadOnlyList scopes, string? audience, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(deviceEndpoint))
+ return ExecuteAuthResult.Failed("OAuth2 device_code: deviceAuthorizationUrl is missing (set it or enable Use Discovery).");
+
+ var form = new Dictionary { ["client_id"] = clientId };
+ if (scopes.Count > 0) form["scope"] = string.Join(' ', scopes);
+ if (!string.IsNullOrEmpty(audience)) form["audience"] = audience!;
+
+ using var resp = await _http.PostAsync(deviceEndpoint, new FormUrlEncodedContent(form), ct).ConfigureAwait(false);
+ var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ return ExecuteAuthResult.Failed($"Device authorization endpoint returned {(int)resp.StatusCode}: {body}");
+
+ var dev = JsonSerializer.Deserialize(body, TokenJson.Default.DeviceCodeResponse);
+ if (dev is null || string.IsNullOrEmpty(dev.DeviceCode))
+ return ExecuteAuthResult.Failed("Device endpoint returned an unrecognized response.");
+
+ // Stand up a flow record so the UI can poll. Polling the token endpoint happens on
+ // the same flow id — keeps the contract identical with the auth-code popup case.
+ var flowId = Guid.NewGuid().ToString("N");
+ var flow = _flows.Create(new AuthFlow
+ {
+ Id = flowId,
+ AuthPath = authPath,
+ CreatedAt = DateTimeOffset.UtcNow,
+ CodeVerifier = string.Empty,
+ Nonce = string.Empty,
+ Authority = string.Empty,
+ TokenEndpoint = tokenEndpoint,
+ ClientId = clientId,
+ ClientSecret = clientSecret,
+ RedirectUri = string.Empty,
+ Scopes = scopes,
+ });
+ flow.UserCode = dev.UserCode;
+ flow.VerificationUri = dev.VerificationUri;
+ flow.VerificationUriComplete = dev.VerificationUriComplete;
+
+ // Background poll loop. Best-effort — if the runner shuts down the user can
+ // re-execute the profile and it'll start over.
+ _ = Task.Run(async () =>
+ {
+ var pollInterval = TimeSpan.FromSeconds(Math.Max(dev.Interval ?? 5, 1));
+ var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(Math.Max(dev.ExpiresIn ?? 600, 60));
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ try { await Task.Delay(pollInterval, CancellationToken.None).ConfigureAwait(false); } catch { return; }
+ try
+ {
+ var pollForm = new Dictionary
+ {
+ ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
+ ["device_code"] = dev.DeviceCode!,
+ ["client_id"] = clientId,
+ };
+ if (!string.IsNullOrEmpty(clientSecret)) pollForm["client_secret"] = clientSecret;
+
+ using var pollResp = await _http.PostAsync(tokenEndpoint, new FormUrlEncodedContent(pollForm), CancellationToken.None).ConfigureAwait(false);
+ var pollBody = await pollResp.Content.ReadAsStringAsync(CancellationToken.None).ConfigureAwait(false);
+ if (pollResp.IsSuccessStatusCode)
+ {
+ var tok = JsonSerializer.Deserialize(pollBody, TokenJson.Default.TokenResponse);
+ if (tok is not null)
+ {
+ flow.AccessToken = tok.AccessToken;
+ flow.IdToken = tok.IdToken;
+ flow.RefreshToken = tok.RefreshToken;
+ flow.TokenType = tok.TokenType;
+ flow.ExpiresAt = tok.ExpiresIn is { } s ? DateTimeOffset.UtcNow.AddSeconds(s) : null;
+ flow.Status = AuthFlowStatus.Completed;
+ _tokens.Save(_ws.RootDirectory, authPath, new AuthTokenEntry
+ {
+ AccessToken = tok.AccessToken,
+ IdToken = tok.IdToken,
+ RefreshToken = tok.RefreshToken,
+ TokenType = tok.TokenType,
+ ExpiresAt = flow.ExpiresAt,
+ ObtainedAt = DateTimeOffset.UtcNow,
+ TokenEndpoint = tokenEndpoint,
+ ClientId = clientId,
+ Scopes = scopes,
+ });
+ }
+ return;
+ }
+
+ // RFC 8628 standard pending/slow_down/expired_token signalling.
+ using var doc = JsonDocument.Parse(pollBody);
+ var err = doc.RootElement.TryGetProperty("error", out var ee) ? ee.GetString() : null;
+ if (err == "authorization_pending") continue;
+ if (err == "slow_down") { pollInterval = pollInterval.Add(TimeSpan.FromSeconds(5)); continue; }
+ if (err == "expired_token" || err == "access_denied")
+ {
+ flow.Status = AuthFlowStatus.Failed;
+ flow.Error = err;
+ return;
+ }
+ flow.Status = AuthFlowStatus.Failed;
+ flow.Error = pollBody;
+ return;
+ }
+ catch (Exception ex)
+ {
+ flow.Status = AuthFlowStatus.Failed;
+ flow.Error = ex.Message;
+ return;
+ }
+ }
+ flow.Status = AuthFlowStatus.Failed;
+ flow.Error = "Device code expired before the user completed authentication.";
+ }, CancellationToken.None);
+
+ return new ExecuteAuthResult
+ {
+ FlowId = flowId,
+ UserCode = dev.UserCode,
+ VerificationUri = dev.VerificationUriComplete ?? dev.VerificationUri,
+ VerificationUriComplete = dev.VerificationUriComplete,
+ DeviceCodeExpiresIn = dev.ExpiresIn,
+ Pending = true,
+ };
+ }
+
+ private ExecuteAuthResult StartAuthCodeFlow(
+ string authPath, string authorizeEndpoint, string tokenEndpoint, string? authority,
+ string clientId, string? clientSecret, string redirectUri, IReadOnlyList scopes, string? audience)
+ {
+ if (string.IsNullOrWhiteSpace(authorizeEndpoint))
+ return ExecuteAuthResult.Failed("OAuth2: authorizeUrl is missing.");
+
+ var flowId = Guid.NewGuid().ToString("N");
+ var codeVerifier = RandomUrlSafe(64);
+ var codeChallenge = Base64UrlSha256(codeVerifier);
+ var nonce = RandomUrlSafe(32);
+
+ var flow = _flows.Create(new AuthFlow
+ {
+ Id = flowId,
+ AuthPath = authPath,
+ CreatedAt = DateTimeOffset.UtcNow,
+ CodeVerifier = codeVerifier,
+ Nonce = nonce,
+ Authority = authority ?? string.Empty,
+ TokenEndpoint = tokenEndpoint,
+ ClientId = clientId,
+ ClientSecret = clientSecret,
+ RedirectUri = redirectUri,
+ Scopes = scopes,
+ });
+
+ var query = new Dictionary
+ {
+ ["response_type"] = "code",
+ ["client_id"] = clientId,
+ ["redirect_uri"] = redirectUri,
+ ["state"] = flowId,
+ ["nonce"] = nonce,
+ ["code_challenge"] = codeChallenge,
+ ["code_challenge_method"] = "S256",
+ };
+ if (scopes.Count > 0) query["scope"] = string.Join(' ', scopes);
+ if (!string.IsNullOrEmpty(audience)) query["audience"] = audience;
+ var loginUrl = AppendQuery(authorizeEndpoint, query);
+
+ return new ExecuteAuthResult
+ {
+ FlowId = flow.Id,
+ LoginUrl = loginUrl,
+ Pending = true,
+ };
+ }
+
+ private async Task ExchangeCodeAsync(AuthFlow flow, string code, CancellationToken ct)
+ {
+ var form = new Dictionary
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = code,
+ ["redirect_uri"] = flow.RedirectUri,
+ ["client_id"] = flow.ClientId,
+ ["code_verifier"] = flow.CodeVerifier,
+ };
+ if (!string.IsNullOrEmpty(flow.ClientSecret)) form["client_secret"] = flow.ClientSecret;
+ return await PostTokenAsync(flow.TokenEndpoint, form, ct).ConfigureAwait(false);
+ }
+
+ private async Task RefreshIfPossibleAsync(AuthFile auth, string authPath, AuthTokenEntry stale, CancellationToken ct)
+ {
+ if (stale.RefreshToken is null || stale.TokenEndpoint is null) return null;
+ var resolver = CreateResolver();
+ var clientId = await resolver.AllAsync(auth.Fields.GetValueOrDefault("clientId"), ct).ConfigureAwait(false);
+ var clientSecret = await resolver.AllAsync(auth.Fields.GetValueOrDefault("clientSecret"), ct).ConfigureAwait(false);
+ if (string.IsNullOrEmpty(clientId)) return null;
+
+ try
+ {
+ var form = new Dictionary
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = stale.RefreshToken,
+ ["client_id"] = clientId!,
+ };
+ if (!string.IsNullOrEmpty(clientSecret)) form["client_secret"] = clientSecret;
+ if (stale.Scopes is { Count: > 0 } s) form["scope"] = string.Join(' ', s);
+
+ var token = await PostTokenAsync(stale.TokenEndpoint, form, ct).ConfigureAwait(false);
+ var entry = new AuthTokenEntry
+ {
+ AccessToken = token.AccessToken,
+ IdToken = token.IdToken ?? stale.IdToken,
+ RefreshToken = token.RefreshToken ?? stale.RefreshToken,
+ TokenType = token.TokenType ?? stale.TokenType,
+ ExpiresAt = token.ExpiresIn is { } sec ? DateTimeOffset.UtcNow.AddSeconds(sec) : null,
+ ObtainedAt = DateTimeOffset.UtcNow,
+ TokenEndpoint = stale.TokenEndpoint,
+ ClientId = stale.ClientId,
+ Scopes = stale.Scopes,
+ };
+ _tokens.Save(_ws.RootDirectory, authPath, entry);
+ return entry;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Silent refresh failed for {Path}; falling through to interactive.", authPath);
+ return null;
+ }
+ }
+
+ // ---- Azure CLI ------------------------------------------------------------------
+
+ ///
+ /// Dispatches on the profile's flow field — mirrors how the OAuth2 entry point
+ /// picks between client_credentials / password / authorization_code / etc. Default
+ /// flow is direct (plain az-cli token); on_behalf_of chains the az-cli
+ /// step with a JWT-bearer exchange against AAD.
+ ///
+ private Task ExecuteAzureAsync(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ var flow = (auth.Fields.GetValueOrDefault("flow") ?? "direct").Trim();
+ return flow switch
+ {
+ "on_behalf_of" or "obo" => ExecuteAzureCliOboAsync(auth, authPath, resolver, ct),
+ "direct" or "" => ExecuteAzureCliAsync(auth, authPath, resolver, ct),
+ _ => Task.FromResult(ExecuteAuthResult.Failed($"Azure CLI flow '{flow}' is not supported (use 'direct' or 'on_behalf_of').")),
+ };
+ }
+
+ private async Task ExecuteAzureCliAsync(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ // Either `resource` (v1 audience) or `scope` (v2). Pass through whichever the
+ // user provided — `az` accepts both.
+ var resource = await resolver.AllAsync(auth.Fields.GetValueOrDefault("resource"), ct).ConfigureAwait(false);
+ var scope = await resolver.AllAsync(auth.Fields.GetValueOrDefault("scope"), ct).ConfigureAwait(false);
+ var tenant = await resolver.AllAsync(auth.Fields.GetValueOrDefault("tenant") ?? auth.Fields.GetValueOrDefault("tenantId"), ct).ConfigureAwait(false);
+ var subscription = await resolver.AllAsync(auth.Fields.GetValueOrDefault("subscription"), ct).ConfigureAwait(false);
+
+ var result = await RunAzCliAsync(resource, scope, tenant, subscription, ct).ConfigureAwait(false);
+ if (result.Error is not null) return ExecuteAuthResult.Failed(result.Error);
+
+ var entry = new AuthTokenEntry
+ {
+ AccessToken = result.Token!,
+ TokenType = "Bearer",
+ ExpiresAt = result.ExpiresAt,
+ ObtainedAt = DateTimeOffset.UtcNow,
+ };
+ _tokens.Save(_ws.RootDirectory, authPath, entry);
+ return ExecuteAuthResult.FromTokens(entry, fromCache: false);
+ }
+
+ private async Task ExecuteAzureCliOboAsync(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ // OBO = Azure-CLI user token → downstream API token via the JWT-bearer grant.
+ // The middle-tier client (clientId/clientSecret) presents the user assertion to the
+ // identity provider and gets back a new access token whose audience is the
+ // downstream API (scopes/audience).
+ var resource = await resolver.AllAsync(auth.Fields.GetValueOrDefault("userResource"), ct).ConfigureAwait(false);
+ var userScope = await resolver.AllAsync(auth.Fields.GetValueOrDefault("userScope"), ct).ConfigureAwait(false);
+ var tenant = await resolver.AllAsync(auth.Fields.GetValueOrDefault("tenant") ?? auth.Fields.GetValueOrDefault("tenantId"), ct).ConfigureAwait(false);
+ var subscription = await resolver.AllAsync(auth.Fields.GetValueOrDefault("subscription"), ct).ConfigureAwait(false);
+
+ var userToken = await RunAzCliAsync(resource, userScope, tenant, subscription, ct).ConfigureAwait(false);
+ if (userToken.Error is not null) return ExecuteAuthResult.Failed("Azure CLI step failed: " + userToken.Error);
+
+ var clientId = await resolver.AllAsync(auth.Fields.GetValueOrDefault("clientId"), ct).ConfigureAwait(false);
+ var clientSecret = await resolver.AllAsync(auth.Fields.GetValueOrDefault("clientSecret"), ct).ConfigureAwait(false);
+ var tokenEndpoint = await resolver.AllAsync(auth.Fields.GetValueOrDefault("tokenUrl"), ct).ConfigureAwait(false);
+
+ // Default to the Microsoft v2 endpoint when only a tenant is given — saves the user
+ // from spelling out the URL when targeting AAD.
+ if (string.IsNullOrWhiteSpace(tokenEndpoint) && !string.IsNullOrWhiteSpace(tenant))
+ tokenEndpoint = $"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token";
+
+ if (string.IsNullOrWhiteSpace(tokenEndpoint))
+ return ExecuteAuthResult.Failed("Azure-CLI OBO: tokenUrl is required (or set tenant to default to the AAD v2 endpoint).");
+ if (string.IsNullOrWhiteSpace(clientId))
+ return ExecuteAuthResult.Failed("Azure-CLI OBO: clientId (middle-tier app) is required.");
+
+ var scopes = await ResolveScopesAsync(auth, resolver, ct).ConfigureAwait(false);
+ var form = new Dictionary
+ {
+ ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
+ ["client_id"] = clientId!,
+ ["assertion"] = userToken.Token!,
+ ["requested_token_use"] = "on_behalf_of",
+ };
+ if (!string.IsNullOrEmpty(clientSecret)) form["client_secret"] = clientSecret;
+ if (scopes.Count > 0) form["scope"] = string.Join(' ', scopes);
+
+ var token = await PostTokenAsync(tokenEndpoint!, form, ct).ConfigureAwait(false);
+ return SaveAndReturn(authPath, tokenEndpoint!, clientId!, scopes, token);
+ }
+
+ private async Task RunAzCliAsync(string? resource, string? scope, string? tenant, string? subscription, CancellationToken ct)
+ {
+ var args = new List { "account", "get-access-token", "--output", "json" };
+ if (!string.IsNullOrEmpty(resource)) { args.Add("--resource"); args.Add(resource!); }
+ if (!string.IsNullOrEmpty(scope)) { args.Add("--scope"); args.Add(scope!); }
+ if (!string.IsNullOrEmpty(tenant)) { args.Add("--tenant"); args.Add(tenant!); }
+ if (!string.IsNullOrEmpty(subscription)) { args.Add("--subscription"); args.Add(subscription!); }
+
+ // `az` on Windows is a cmd shim — wrap accordingly. On Unix the binary is on PATH.
+ var psi = new ProcessStartInfo
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ if (OperatingSystem.IsWindows())
+ {
+ psi.FileName = "cmd.exe";
+ psi.ArgumentList.Add("/c");
+ psi.ArgumentList.Add("az");
+ foreach (var a in args) psi.ArgumentList.Add(a);
+ }
+ else
+ {
+ psi.FileName = "az";
+ foreach (var a in args) psi.ArgumentList.Add(a);
+ }
+
+ try
+ {
+ using var proc = Process.Start(psi)
+ ?? throw new InvalidOperationException("Failed to start `az`. Is the Azure CLI installed and on PATH?");
+ var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
+ var stderrTask = proc.StandardError.ReadToEndAsync(ct);
+ await proc.WaitForExitAsync(ct).ConfigureAwait(false);
+ var stdout = await stdoutTask.ConfigureAwait(false);
+ var stderr = await stderrTask.ConfigureAwait(false);
+ if (proc.ExitCode != 0)
+ return new AzCliResult { Error = $"az exited with {proc.ExitCode}: {stderr.Trim()}" };
+
+ var parsed = JsonSerializer.Deserialize(stdout, TokenJson.Default.AzCliTokenResponse);
+ if (parsed?.AccessToken is null)
+ return new AzCliResult { Error = "az returned no accessToken." };
+
+ DateTimeOffset? expires = null;
+ if (DateTimeOffset.TryParse(parsed.ExpiresOn, out var parsedExpires))
+ expires = parsedExpires;
+ else if (parsed.ExpiresOnTimestamp is { } unix)
+ expires = DateTimeOffset.FromUnixTimeSeconds(unix);
+
+ return new AzCliResult { Token = parsed.AccessToken, ExpiresAt = expires };
+ }
+ catch (System.ComponentModel.Win32Exception)
+ {
+ return new AzCliResult { Error = "Could not run `az` — install the Azure CLI and `az login` first." };
+ }
+ }
+
+ // ---- JWT mint -------------------------------------------------------------------
+
+ ///
+ /// Build a signed JWT from auth-profile fields and return it as a bearer header.
+ /// Mirrors dreamr's JwtIssuer.CreateToken(CreateRawJwtTokenRequest) : the user
+ /// supplies algorithm + issuer + audience + expiresIn + signing key + an optional
+ /// JSON blob of custom claims, and we emit header.payload.signature . No identity
+ /// provider involved — useful when an upstream accepts a self-signed JWT (e.g. service-to-service
+ /// "client assertion" patterns, dev-mode JWT bearer auth, or signed webhook callbacks).
+ ///
+ /// Supports HS256/HS384/HS512 (symmetric HMAC, key = UTF-8 bytes of the field), and
+ /// RS256/RS384/RS512 + ES256/ES384/ES512 / PS256/PS384/PS512 (asymmetric, key = PEM-
+ /// encoded private key). The standard iss/exp/iat/jti/sub/aud claims are auto-filled;
+ /// anything in payload overrides them.
+ ///
+ private async Task ExecuteJwtAsync(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ var algorithm = ((await resolver.AllAsync(auth.Fields.GetValueOrDefault("algorithm"), ct).ConfigureAwait(false)) ?? "HS256").Trim().ToUpperInvariant();
+ var issuer = await resolver.AllAsync(auth.Fields.GetValueOrDefault("issuer"), ct).ConfigureAwait(false);
+ var audience = await resolver.AllAsync(auth.Fields.GetValueOrDefault("audience"), ct).ConfigureAwait(false);
+ var subject = await resolver.AllAsync(auth.Fields.GetValueOrDefault("subject"), ct).ConfigureAwait(false);
+ var keyId = await resolver.AllAsync(auth.Fields.GetValueOrDefault("keyId") ?? auth.Fields.GetValueOrDefault("kid"), ct).ConfigureAwait(false);
+ var key = await resolver.AllAsync(auth.Fields.GetValueOrDefault("key"), ct).ConfigureAwait(false);
+ var payloadJson = await resolver.AllAsync(auth.Fields.GetValueOrDefault("payload"), ct).ConfigureAwait(false);
+ var expiresInRaw = auth.Fields.GetValueOrDefault("expiresIn");
+ var expiresIn = int.TryParse(expiresInRaw, out var seconds) ? seconds : 3600;
+
+ if (string.IsNullOrEmpty(key))
+ return ExecuteAuthResult.Failed("JWT: a signing 'key' is required (HMAC secret or PEM-encoded private key).");
+
+ try
+ {
+ var jwt = JwtMinter.Mint(new JwtMintRequest
+ {
+ Algorithm = algorithm,
+ Issuer = issuer,
+ Audience = audience,
+ Subject = subject,
+ KeyId = keyId,
+ Key = key!,
+ PayloadJson = payloadJson,
+ ExpiresIn = expiresIn,
+ });
+
+ var entry = new AuthTokenEntry
+ {
+ AccessToken = jwt,
+ TokenType = "Bearer",
+ ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn),
+ ObtainedAt = DateTimeOffset.UtcNow,
+ };
+ _tokens.Save(_ws.RootDirectory, authPath, entry);
+ return ExecuteAuthResult.FromTokens(entry, fromCache: false);
+ }
+ catch (Exception ex)
+ {
+ return ExecuteAuthResult.Failed($"JWT minting failed: {ex.Message}");
+ }
+ }
+
+ // ---- GitHub --------------------------------------------------------------------
+
+ private const string GithubApiVersion = "2022-11-28";
+ private const string GithubAuthorize = "https://github.com/login/oauth/authorize";
+ private const string GithubToken = "https://github.com/login/oauth/access_token";
+
+ ///
+ /// Multi-mode GitHub auth. Dispatches on the profile's mode field. Mirrors how
+ /// the Azure CLI entry point picks between direct + OBO — the front door is one auth
+ /// type with several sub-flows, so users don't drown in a dozen near-identical types.
+ ///
+ private async Task ExecuteGithubAsync(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ var mode = (auth.Fields.GetValueOrDefault("mode") ?? "pat").Trim();
+ return mode switch
+ {
+ "pat" => await ExecuteGithubPatAsync(auth, authPath, resolver, ct).ConfigureAwait(false),
+ "gh-cli" or "ghcli" or "cli"
+ => await ExecuteGithubCliAsync(authPath, ct).ConfigureAwait(false),
+ "app" => await ExecuteGithubAppAsync(auth, authPath, resolver, ct).ConfigureAwait(false),
+ "oauth" => await ExecuteGithubOAuthAsync(auth, authPath, resolver, ct).ConfigureAwait(false),
+ _ => ExecuteAuthResult.Failed($"GitHub mode '{mode}' is not supported (use pat, gh-cli, app, or oauth)."),
+ };
+ }
+
+ private async Task ExecuteGithubPatAsync(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ var token = await resolver.AllAsync(auth.Fields.GetValueOrDefault("token"), ct).ConfigureAwait(false);
+ if (string.IsNullOrEmpty(token))
+ return ExecuteAuthResult.Failed("GitHub PAT: 'token' is required.");
+
+ var entry = new AuthTokenEntry
+ {
+ AccessToken = token!,
+ TokenType = "Bearer",
+ ObtainedAt = DateTimeOffset.UtcNow,
+ };
+ _tokens.Save(_ws.RootDirectory, authPath, entry);
+ return WithGithubExtras(ExecuteAuthResult.FromTokens(entry, fromCache: false));
+ }
+
+ private async Task ExecuteGithubCliAsync(string authPath, CancellationToken ct)
+ {
+ var (token, error) = await RunGhCliAsync(ct).ConfigureAwait(false);
+ if (error is not null) return ExecuteAuthResult.Failed(error);
+
+ var entry = new AuthTokenEntry
+ {
+ AccessToken = token!,
+ TokenType = "Bearer",
+ ObtainedAt = DateTimeOffset.UtcNow,
+ };
+ _tokens.Save(_ws.RootDirectory, authPath, entry);
+ return WithGithubExtras(ExecuteAuthResult.FromTokens(entry, fromCache: false));
+ }
+
+ ///
+ /// GitHub App installation token: mint a 9-minute RS256 JWT signed with the App's PEM
+ /// private key, POST it to /app/installations/{id}/access_tokens with a Bearer header,
+ /// cache the returned ghs_* installation token. The token typically expires after
+ /// an hour — we honor whatever expires_at GitHub returns.
+ ///
+ private async Task ExecuteGithubAppAsync(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ var appId = await resolver.AllAsync(auth.Fields.GetValueOrDefault("appId"), ct).ConfigureAwait(false);
+ var installationId = await resolver.AllAsync(auth.Fields.GetValueOrDefault("installationId"), ct).ConfigureAwait(false);
+ var privateKey = await resolver.AllAsync(auth.Fields.GetValueOrDefault("privateKey"), ct).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(appId))
+ return ExecuteAuthResult.Failed("GitHub App: 'appId' is required.");
+ if (string.IsNullOrWhiteSpace(installationId))
+ return ExecuteAuthResult.Failed("GitHub App: 'installationId' is required.");
+ if (string.IsNullOrWhiteSpace(privateKey))
+ return ExecuteAuthResult.Failed("GitHub App: 'privateKey' (PEM) is required.");
+
+ string assertion;
+ try
+ {
+ assertion = JwtMinter.Mint(new JwtMintRequest
+ {
+ Algorithm = "RS256",
+ Issuer = appId!,
+ Key = privateKey!,
+ ExpiresIn = 540, // GH caps the App JWT at 10 minutes; 9 leaves clock-skew room.
+ });
+ }
+ catch (Exception ex)
+ {
+ return ExecuteAuthResult.Failed($"GitHub App JWT minting failed: {ex.Message}");
+ }
+
+ using var req = new HttpRequestMessage(HttpMethod.Post,
+ $"https://api.github.com/app/installations/{Uri.EscapeDataString(installationId!)}/access_tokens");
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", assertion);
+ req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+ req.Headers.Add("X-GitHub-Api-Version", GithubApiVersion);
+ req.Headers.UserAgent.Add(new System.Net.Http.Headers.ProductInfoHeaderValue("tap-studio", "1.0"));
+
+ using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
+ var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ return ExecuteAuthResult.Failed($"GitHub App installation token endpoint returned {(int)resp.StatusCode}: {body}");
+
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+ var token = root.TryGetProperty("token", out var t) ? t.GetString() : null;
+ if (string.IsNullOrEmpty(token))
+ return ExecuteAuthResult.Failed("GitHub App installation token response missing 'token'.");
+
+ DateTimeOffset? expiresAt = null;
+ if (root.TryGetProperty("expires_at", out var ex) && ex.ValueKind == JsonValueKind.String
+ && DateTimeOffset.TryParse(ex.GetString(), out var parsed))
+ expiresAt = parsed;
+
+ var entry = new AuthTokenEntry
+ {
+ AccessToken = token!,
+ TokenType = "Bearer",
+ ExpiresAt = expiresAt,
+ ObtainedAt = DateTimeOffset.UtcNow,
+ };
+ _tokens.Save(_ws.RootDirectory, authPath, entry);
+ return WithGithubExtras(ExecuteAuthResult.FromTokens(entry, fromCache: false));
+ }
+ catch (JsonException ex)
+ {
+ return ExecuteAuthResult.Failed($"GitHub App installation token response was not valid JSON: {ex.Message}");
+ }
+ }
+
+ ///
+ /// GitHub OAuth App: delegate to with github.com endpoints
+ /// preset. PKCE is enabled by default — GitHub supports it for confidential and public
+ /// clients alike.
+ ///
+ private async Task ExecuteGithubOAuthAsync(AuthFile auth, string authPath, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ var clientId = await resolver.AllAsync(auth.Fields.GetValueOrDefault("clientId"), ct).ConfigureAwait(false);
+ var clientSecret = await resolver.AllAsync(auth.Fields.GetValueOrDefault("clientSecret"), ct).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(clientId))
+ return ExecuteAuthResult.Failed("GitHub OAuth: clientId is required.");
+
+ var scopes = await ResolveScopesAsync(auth, resolver, ct).ConfigureAwait(false);
+ var redirectUri = ResolveCallbackUri();
+ return StartAuthCodeFlow(authPath, GithubAuthorize, GithubToken,
+ authority: "https://github.com", clientId!, clientSecret, redirectUri, scopes, audience: null);
+ }
+
+ /// Attach the bearer Authorization header for a GitHub token result so every
+ /// mode (PAT, gh-cli, App, OAuth) produces the same request shape.
+ private static ExecuteAuthResult WithGithubExtras(ExecuteAuthResult result)
+ {
+ if (result.AccessToken is null) return result;
+ var extras = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Authorization"] = "Bearer " + result.AccessToken,
+ };
+ return result with { Headers = extras };
+ }
+
+ private static async Task<(string? Token, string? Error)> RunGhCliAsync(CancellationToken ct)
+ {
+ var psi = new ProcessStartInfo
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ if (OperatingSystem.IsWindows())
+ {
+ psi.FileName = "cmd.exe";
+ psi.ArgumentList.Add("/c");
+ psi.ArgumentList.Add("gh");
+ psi.ArgumentList.Add("auth");
+ psi.ArgumentList.Add("token");
+ }
+ else
+ {
+ psi.FileName = "gh";
+ psi.ArgumentList.Add("auth");
+ psi.ArgumentList.Add("token");
+ }
+
+ try
+ {
+ using var proc = Process.Start(psi)
+ ?? throw new InvalidOperationException("Failed to start `gh`. Is the GitHub CLI installed and on PATH?");
+ var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
+ var stderrTask = proc.StandardError.ReadToEndAsync(ct);
+ await proc.WaitForExitAsync(ct).ConfigureAwait(false);
+ var stdout = (await stdoutTask.ConfigureAwait(false)).Trim();
+ var stderr = (await stderrTask.ConfigureAwait(false)).Trim();
+ if (proc.ExitCode != 0)
+ return (null, $"gh exited with {proc.ExitCode}: {stderr}");
+ if (string.IsNullOrEmpty(stdout))
+ return (null, "gh auth token returned no output — run `gh auth login` first.");
+ return (stdout, null);
+ }
+ catch (System.ComponentModel.Win32Exception)
+ {
+ return (null, "Could not run `gh` — install the GitHub CLI and `gh auth login` first.");
+ }
+ }
+
+ // ---- Token endpoint helpers -----------------------------------------------------
+
+ private static async Task TryDiscoverDeviceEndpointAsync(string authority, CancellationToken ct)
+ {
+ // Cheap one-off — fetch the raw discovery doc and pick out device_authorization_endpoint
+ // since our OidcDiscoveryDocument doesn't surface it (yet).
+ try
+ {
+ using var http = new HttpClient();
+ var url = authority.TrimEnd('/') + "/.well-known/openid-configuration";
+ using var stream = await http.GetStreamAsync(url, ct).ConfigureAwait(false);
+ using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false);
+ return doc.RootElement.TryGetProperty("device_authorization_endpoint", out var de) ? de.GetString() : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private ExecuteAuthResult SaveAndReturn(string authPath, string tokenEndpoint, string clientId,
+ IReadOnlyList scopes, TokenResponse token)
+ {
+ var entry = new AuthTokenEntry
+ {
+ AccessToken = token.AccessToken,
+ IdToken = token.IdToken,
+ RefreshToken = token.RefreshToken,
+ TokenType = token.TokenType,
+ ExpiresAt = token.ExpiresIn is { } s ? DateTimeOffset.UtcNow.AddSeconds(s) : null,
+ ObtainedAt = DateTimeOffset.UtcNow,
+ TokenEndpoint = tokenEndpoint,
+ ClientId = clientId,
+ Scopes = scopes,
+ };
+ _tokens.Save(_ws.RootDirectory, authPath, entry);
+ return ExecuteAuthResult.FromTokens(entry, fromCache: false);
+ }
+
+ private async Task PostTokenAsync(string tokenEndpoint, Dictionary form, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(tokenEndpoint) || !Uri.TryCreate(tokenEndpoint, UriKind.Absolute, out _))
+ throw new InvalidOperationException($"Token endpoint URL is invalid: '{tokenEndpoint}'.");
+
+ using var req = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
+ {
+ Content = new FormUrlEncodedContent(form),
+ };
+ req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
+ var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ throw new InvalidOperationException($"Token endpoint returned {(int)resp.StatusCode}: {body}");
+
+ var parsed = JsonSerializer.Deserialize(body, TokenJson.Default.TokenResponse)
+ ?? throw new InvalidOperationException("Token endpoint returned empty body.");
+ return parsed;
+ }
+
+ private static async ValueTask> ResolveScopesAsync(AuthFile auth, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ IReadOnlyList raw;
+ if (auth.Scopes.Count > 0) raw = auth.Scopes;
+ else
+ {
+ var combined = auth.Fields.GetValueOrDefault("scopes");
+ raw = string.IsNullOrWhiteSpace(combined)
+ ? []
+ : combined.Split([' ', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+ return await resolver.AllAsync(raw, ct).ConfigureAwait(false);
+ }
+
+ private async ValueTask> BuildHeadersForAsync(AuthFile auth, AuthFieldResolver resolver, CancellationToken ct)
+ {
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ switch (auth.Type)
+ {
+ case "bearer":
+ {
+ var t = await resolver.AllAsync(auth.Fields.GetValueOrDefault("token"), ct).ConfigureAwait(false);
+ if (!string.IsNullOrEmpty(t)) headers["Authorization"] = "Bearer " + t;
+ break;
+ }
+ case "basic":
+ {
+ var u = await resolver.AllAsync(auth.Fields.GetValueOrDefault("username"), ct).ConfigureAwait(false);
+ var p = await resolver.AllAsync(auth.Fields.GetValueOrDefault("password"), ct).ConfigureAwait(false);
+ if (!string.IsNullOrEmpty(u) && !string.IsNullOrEmpty(p))
+ headers["Authorization"] = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{u}:{p}"));
+ break;
+ }
+ case "apiKey":
+ {
+ var location = auth.Fields.GetValueOrDefault("in") ?? "header";
+ if (string.Equals(location, "header", StringComparison.OrdinalIgnoreCase))
+ {
+ var name = await resolver.AllAsync(auth.Fields.GetValueOrDefault("apiKeyName") ?? auth.Fields.GetValueOrDefault("name"), ct).ConfigureAwait(false);
+ var val = await resolver.AllAsync(auth.Fields.GetValueOrDefault("apiKeyValue") ?? auth.Fields.GetValueOrDefault("value"), ct).ConfigureAwait(false);
+ if (!string.IsNullOrEmpty(name) && val is not null) headers[name!] = val;
+ }
+ break;
+ }
+ case "custom":
+ foreach (var (k, v) in auth.Headers)
+ {
+ var expanded = await resolver.AllAsync(v, ct).ConfigureAwait(false);
+ if (expanded is not null) headers[k] = expanded;
+ }
+ break;
+ }
+ return headers;
+ }
+
+ /// Fallback redirect URI when there's no live HttpContext (e.g. background
+ /// callers / tests). The Studio's actual base URL is preferred — see .
+ public const string DefaultRedirectUri = "http://localhost:5298/api/auth/callback";
+
+ ///
+ /// Build the OAuth redirect URI from whichever URL the user is currently hitting Studio at.
+ /// Aspire reroutes through a dynamic port per run, so the redirect must follow that —
+ /// the alternative (hard-coding) breaks every other restart. The result is the absolute
+ /// URL of /api/auth/callback on the same scheme+host the inbound request used.
+ /// Falls back to when no HttpContext is available.
+ ///
+ /// When running under the desktop shell (TAP_STUDIO_DESKTOP=1 ) the Tauri sidecar
+ /// binds to a random localhost port, so the OS-registered tap-studio://callback
+ /// scheme is used instead — stable across launches and registerable with any IdP.
+ ///
+ public string ResolveCallbackUri()
+ {
+ if (string.Equals(Environment.GetEnvironmentVariable("TAP_STUDIO_DESKTOP"), "1", StringComparison.Ordinal))
+ {
+ return "tap-studio://callback";
+ }
+ var req = _httpCtx.HttpContext?.Request;
+ if (req is null || !req.Host.HasValue) return DefaultRedirectUri;
+ return $"{req.Scheme}://{req.Host.Value}/api/auth/callback";
+ }
+
+ private static string RandomUrlSafe(int bytes)
+ {
+ Span buf = stackalloc byte[bytes];
+ RandomNumberGenerator.Fill(buf);
+ return Convert.ToBase64String(buf).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ }
+
+ private static string Base64UrlSha256(string input)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
+ return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ }
+
+ private static string AppendQuery(string url, IDictionary query)
+ {
+ var sb = new StringBuilder(url);
+ sb.Append(url.Contains('?') ? '&' : '?');
+ var first = true;
+ foreach (var (k, v) in query)
+ {
+ if (v is null) continue;
+ if (!first) sb.Append('&');
+ sb.Append(Uri.EscapeDataString(k)).Append('=').Append(Uri.EscapeDataString(v));
+ first = false;
+ }
+ return sb.ToString();
+ }
+
+ private readonly record struct AzCliResult
+ {
+ public string? Token { get; init; }
+ public DateTimeOffset? ExpiresAt { get; init; }
+ public string? Error { get; init; }
+ }
+}
+
+internal sealed record TokenResponse
+{
+ [JsonPropertyName("access_token")] public string AccessToken { get; init; } = string.Empty;
+ [JsonPropertyName("id_token")] public string? IdToken { get; init; }
+ [JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; }
+ [JsonPropertyName("token_type")] public string? TokenType { get; init; }
+ [JsonPropertyName("expires_in")] public int? ExpiresIn { get; init; }
+ [JsonPropertyName("scope")] public string? Scope { get; init; }
+}
+
+internal sealed record DeviceCodeResponse
+{
+ [JsonPropertyName("device_code")] public string? DeviceCode { get; init; }
+ [JsonPropertyName("user_code")] public string? UserCode { get; init; }
+ [JsonPropertyName("verification_uri")] public string? VerificationUri { get; init; }
+ [JsonPropertyName("verification_uri_complete")] public string? VerificationUriComplete { get; init; }
+ [JsonPropertyName("expires_in")] public int? ExpiresIn { get; init; }
+ [JsonPropertyName("interval")] public int? Interval { get; init; }
+}
+
+/// Shape of az account get-access-token --output json .
+internal sealed record AzCliTokenResponse
+{
+ [JsonPropertyName("accessToken")] public string? AccessToken { get; init; }
+ [JsonPropertyName("expiresOn")] public string? ExpiresOn { get; init; }
+ [JsonPropertyName("expires_on")] public long? ExpiresOnTimestamp { get; init; }
+ [JsonPropertyName("tokenType")] public string? TokenType { get; init; }
+ [JsonPropertyName("subscription")] public string? Subscription { get; init; }
+ [JsonPropertyName("tenant")] public string? Tenant { get; init; }
+}
+
+[JsonSerializable(typeof(TokenResponse))]
+[JsonSerializable(typeof(DeviceCodeResponse))]
+[JsonSerializable(typeof(AzCliTokenResponse))]
+internal partial class TokenJson : JsonSerializerContext
+{
+}
+
+///
+/// Either the populated tokens (sync grants and cached entries), the popup hand-off info
+/// (auth-code grant), or the device-code prompt (device flow). is set
+/// when execution failed.
+///
+public sealed record ExecuteAuthResult
+{
+ public string? AccessToken { get; init; }
+ public string? IdToken { get; init; }
+ public string? RefreshToken { get; init; }
+ public string? TokenType { get; init; }
+ public DateTimeOffset? ExpiresAt { get; init; }
+ public bool FromCache { get; init; }
+
+ public string? FlowId { get; init; }
+ public string? LoginUrl { get; init; }
+ public bool Pending { get; init; }
+
+ // Device-code fields. Surfaced when the runner chose the RFC 8628 grant — UI displays
+ // these to the user and polls the flow id for completion.
+ public string? UserCode { get; init; }
+ public string? VerificationUri { get; init; }
+ public string? VerificationUriComplete { get; init; }
+ public int? DeviceCodeExpiresIn { get; init; }
+
+ /// Headers an auth profile would inject directly (basic/bearer/apiKey/custom).
+ public IReadOnlyDictionary? Headers { get; init; }
+
+ public string? Error { get; init; }
+
+ public static ExecuteAuthResult FromTokens(AuthTokenEntry entry, bool fromCache) => new()
+ {
+ AccessToken = entry.AccessToken,
+ IdToken = entry.IdToken,
+ RefreshToken = entry.RefreshToken,
+ TokenType = entry.TokenType,
+ ExpiresAt = entry.ExpiresAt,
+ FromCache = fromCache,
+ };
+
+ public static ExecuteAuthResult SyntheticHeaders(IReadOnlyDictionary headers) => new()
+ {
+ Headers = headers,
+ };
+
+ public static ExecuteAuthResult Failed(string message) => new() { Error = message };
+}
diff --git a/src/backend/Tap.Studio/Auth/AuthTokenStore.cs b/src/backend/Tap.Studio/Auth/AuthTokenStore.cs
new file mode 100644
index 0000000..3590fde
--- /dev/null
+++ b/src/backend/Tap.Studio/Auth/AuthTokenStore.cs
@@ -0,0 +1,120 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Tap.Studio.Auth;
+
+///
+/// Persisted store of OAuth tokens obtained for auth profiles. Keyed by
+/// {workspaceRoot}::{authRelativePath} so multiple workspaces don't collide.
+///
+/// Tokens are sensitive — this file lives under the user's state folder
+/// (~/.tap/auth-tokens.json ), not in the workspace itself (which is checked into Git).
+/// Re-running the auth flow overwrites the entry; explicit logout removes it.
+///
+public sealed class AuthTokenStore
+{
+ private readonly string _path;
+ private readonly Lock _gate = new();
+ private readonly Dictionary _entries;
+
+ public AuthTokenStore(StudioOptions options)
+ {
+ var dir = Path.GetDirectoryName(options.StatePath) ?? Path.GetTempPath();
+ _path = Path.Combine(dir, "auth-tokens.json");
+ _entries = Load();
+ }
+
+ public AuthTokenEntry? Get(string workspaceRoot, string authPath)
+ {
+ lock (_gate)
+ {
+ return _entries.GetValueOrDefault(Key(workspaceRoot, authPath));
+ }
+ }
+
+ public void Save(string workspaceRoot, string authPath, AuthTokenEntry entry)
+ {
+ lock (_gate)
+ {
+ _entries[Key(workspaceRoot, authPath)] = entry;
+ Persist();
+ }
+ }
+
+ public void Remove(string workspaceRoot, string authPath)
+ {
+ lock (_gate)
+ {
+ _entries.Remove(Key(workspaceRoot, authPath));
+ Persist();
+ }
+ }
+
+ private Dictionary Load()
+ {
+ if (!File.Exists(_path)) return new();
+ try
+ {
+ var json = File.ReadAllText(_path);
+ return JsonSerializer.Deserialize(json, AuthTokenJson.Default.DictionaryStringAuthTokenEntry)
+ ?? new Dictionary();
+ }
+ catch
+ {
+ // Corrupt or unreadable — start fresh rather than crashing the server. The user
+ // can always re-authenticate.
+ return new();
+ }
+ }
+
+ private void Persist()
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
+ File.WriteAllText(_path, JsonSerializer.Serialize(_entries, AuthTokenJson.Default.DictionaryStringAuthTokenEntry));
+ TryRestrictPermissions(_path);
+ }
+
+ ///
+ /// Best-effort tightening of the token file to user-only read/write (0600 on Unix).
+ /// Silent on Windows — NTFS ACLs are managed differently and the system-dir is already
+ /// inside the user profile. Silent on failure: a wide-open token file is preferable to a
+ /// crashed Studio process; the warning in covers misconfigured
+ /// machines well enough.
+ ///
+ private static void TryRestrictPermissions(string path)
+ {
+ if (OperatingSystem.IsWindows()) return;
+ try
+ {
+ File.SetUnixFileMode(path,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ catch
+ {
+ // ignore — the file is still owned by the current user
+ }
+ }
+
+ private static string Key(string workspaceRoot, string authPath)
+ => $"{workspaceRoot}::{authPath}";
+}
+
+public sealed record AuthTokenEntry
+{
+ public required string AccessToken { get; init; }
+ public string? IdToken { get; init; }
+ public string? RefreshToken { get; init; }
+ public string? TokenType { get; init; }
+ public DateTimeOffset? ExpiresAt { get; init; }
+ public required DateTimeOffset ObtainedAt { get; init; }
+ /// Token endpoint that issued this — used for refresh.
+ public string? TokenEndpoint { get; init; }
+ public string? ClientId { get; init; }
+ public IReadOnlyList? Scopes { get; init; }
+}
+
+[JsonSerializable(typeof(Dictionary))]
+[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+internal partial class AuthTokenJson : JsonSerializerContext
+{
+}
diff --git a/src/backend/Tap.Studio/Auth/JwtMinter.cs b/src/backend/Tap.Studio/Auth/JwtMinter.cs
new file mode 100644
index 0000000..c3fbf75
--- /dev/null
+++ b/src/backend/Tap.Studio/Auth/JwtMinter.cs
@@ -0,0 +1,151 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace Tap.Studio.Auth;
+
+///
+/// Mints a signed JWT from raw parameters. Mirrors dreamr's JwtIssuer.CreateToken
+/// but supports both symmetric (HMAC) and asymmetric (RSA / ECDSA) signing algorithms so
+/// the same auth profile shape can drive service-to-service "client assertion" patterns
+/// as well as dev-mode HMAC tokens.
+///
+/// Standard claims (iss , aud , sub , exp , iat , jti )
+/// are auto-filled from the request; any keys present in PayloadJson override them.
+/// We assemble the JWT by hand (base64url(header).base64url(payload).base64url(sig))
+/// instead of pulling in System.IdentityModel.Tokens.Jwt — the dependency would
+/// be a 600KB tax for ~30 lines of code.
+///
+internal static class JwtMinter
+{
+ public static string Mint(JwtMintRequest req)
+ {
+ var alg = req.Algorithm.ToUpperInvariant();
+
+ // Header. kid is optional — if the caller passes one we include it verbatim,
+ // otherwise (for HMAC) we derive a stable kid from the key bytes so different
+ // secrets can be told apart in logs without leaking them.
+ var header = new Dictionary { ["alg"] = alg, ["typ"] = "JWT" };
+ if (!string.IsNullOrEmpty(req.KeyId)) header["kid"] = req.KeyId;
+ else if (IsHmac(alg))
+ header["kid"] = Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(req.Key)));
+
+ // Payload. Start with the well-known claims, then merge whatever the user provided
+ // — later keys win, so caller can override iss/aud/etc via PayloadJson.
+ var now = DateTimeOffset.UtcNow;
+ var payload = new Dictionary
+ {
+ ["iat"] = now.ToUnixTimeSeconds(),
+ ["exp"] = now.AddSeconds(req.ExpiresIn).ToUnixTimeSeconds(),
+ ["jti"] = Guid.NewGuid().ToString("N"),
+ };
+ if (!string.IsNullOrEmpty(req.Issuer)) payload["iss"] = req.Issuer;
+ if (!string.IsNullOrEmpty(req.Audience)) payload["aud"] = req.Audience;
+ if (!string.IsNullOrEmpty(req.Subject)) payload["sub"] = req.Subject;
+
+ if (!string.IsNullOrWhiteSpace(req.PayloadJson))
+ {
+ try
+ {
+ var extras = JsonSerializer.Deserialize>(req.PayloadJson!);
+ if (extras is not null)
+ {
+ foreach (var kv in extras) payload[kv.Key] = kv.Value;
+ }
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException($"'payload' is not valid JSON: {ex.Message}");
+ }
+ }
+
+ var headerJson = JsonSerializer.Serialize(header);
+ var payloadJson = JsonSerializer.Serialize(payload);
+ var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
+ var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
+ var unsigned = $"{headerB64}.{payloadB64}";
+
+ var signature = Sign(alg, unsigned, req.Key);
+ return $"{unsigned}.{Base64UrlEncode(signature)}";
+ }
+
+ private static bool IsHmac(string alg) => alg is "HS256" or "HS384" or "HS512";
+
+ private static byte[] Sign(string alg, string data, string key)
+ {
+ var dataBytes = Encoding.UTF8.GetBytes(data);
+ return alg switch
+ {
+ "HS256" => SignHmac(dataBytes, Encoding.UTF8.GetBytes(key), HashAlgorithmName.SHA256),
+ "HS384" => SignHmac(dataBytes, Encoding.UTF8.GetBytes(key), HashAlgorithmName.SHA384),
+ "HS512" => SignHmac(dataBytes, Encoding.UTF8.GetBytes(key), HashAlgorithmName.SHA512),
+
+ "RS256" => SignRsa(dataBytes, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1),
+ "RS384" => SignRsa(dataBytes, key, HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1),
+ "RS512" => SignRsa(dataBytes, key, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1),
+
+ "PS256" => SignRsa(dataBytes, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pss),
+ "PS384" => SignRsa(dataBytes, key, HashAlgorithmName.SHA384, RSASignaturePadding.Pss),
+ "PS512" => SignRsa(dataBytes, key, HashAlgorithmName.SHA512, RSASignaturePadding.Pss),
+
+ "ES256" => SignEcdsa(dataBytes, key, HashAlgorithmName.SHA256),
+ "ES384" => SignEcdsa(dataBytes, key, HashAlgorithmName.SHA384),
+ "ES512" => SignEcdsa(dataBytes, key, HashAlgorithmName.SHA512),
+
+ _ => throw new NotSupportedException(
+ $"JWT signing algorithm '{alg}' is not supported. " +
+ "Choose one of: HS256/384/512, RS256/384/512, PS256/384/512, ES256/384/512."),
+ };
+ }
+
+ private static byte[] SignHmac(byte[] data, byte[] key, HashAlgorithmName alg)
+ {
+ using HMAC h = alg.Name switch
+ {
+ nameof(HashAlgorithmName.SHA256) => new HMACSHA256(key),
+ nameof(HashAlgorithmName.SHA384) => new HMACSHA384(key),
+ nameof(HashAlgorithmName.SHA512) => new HMACSHA512(key),
+ _ => throw new NotSupportedException(alg.Name),
+ };
+ return h.ComputeHash(data);
+ }
+
+ private static byte[] SignRsa(byte[] data, string pem, HashAlgorithmName alg, RSASignaturePadding padding)
+ {
+ using var rsa = RSA.Create();
+ rsa.ImportFromPem(pem);
+ return rsa.SignData(data, alg, padding);
+ }
+
+ ///
+ /// JWT ES* signatures use raw R || S (P1363 format), not the ASN.1 DER form
+ /// returns by default.
+ ///
+ private static byte[] SignEcdsa(byte[] data, string pem, HashAlgorithmName alg)
+ {
+ using var ec = ECDsa.Create();
+ ec.ImportFromPem(pem);
+ return ec.SignData(data, alg, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
+ }
+
+ public static string Base64UrlEncode(byte[] input)
+ => Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+}
+
+/// Input record for . Mirrors dreamr's
+/// CreateRawJwtTokenRequest with a few extra fields (Subject, KeyId) that fall out
+/// naturally now that we don't have the symmetric-only restriction.
+internal sealed record JwtMintRequest
+{
+ public required string Algorithm { get; init; }
+ public required string Key { get; init; }
+ public string? Issuer { get; init; }
+ public string? Audience { get; init; }
+ public string? Subject { get; init; }
+ public string? KeyId { get; init; }
+ /// Optional raw JSON of additional claims. Merged onto the auto-filled
+ /// claims; same-named entries override.
+ public string? PayloadJson { get; init; }
+ /// Seconds from now for the exp claim.
+ public required int ExpiresIn { get; init; }
+}
diff --git a/src/backend/Tap.Studio/Auth/OidcDiscovery.cs b/src/backend/Tap.Studio/Auth/OidcDiscovery.cs
new file mode 100644
index 0000000..804d4ec
--- /dev/null
+++ b/src/backend/Tap.Studio/Auth/OidcDiscovery.cs
@@ -0,0 +1,89 @@
+using System.Collections.Concurrent;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Tap.Studio.Auth;
+
+///
+/// Fetches and caches OpenID Connect discovery documents from
+/// {authority}/.well-known/openid-configuration . Used to auto-fill the
+/// token / authorize endpoints when the user toggles "Use Discovery" on an OAuth2
+/// auth profile. Mirrors dreamr's OAuthClient.GetWellknownOpenIdConnectConfiguration
+/// but returns just the fields the UI cares about.
+///
+/// Results are cached for 10 minutes per authority — these documents change rarely and the
+/// UI typically asks again right after the user types.
+///
+public sealed class OidcDiscoveryClient
+{
+ private readonly HttpClient _http;
+ private readonly ConcurrentDictionary _cache = new();
+ private static readonly TimeSpan TimeToLive = TimeSpan.FromMinutes(10);
+
+ public OidcDiscoveryClient(HttpClient http) => _http = http;
+
+ public async Task FetchAsync(string authority, CancellationToken ct)
+ {
+ var key = authority.TrimEnd('/');
+ if (_cache.TryGetValue(key, out var hit) && hit.Expires > DateTimeOffset.UtcNow)
+ return hit.Document;
+
+ var url = key + "/.well-known/openid-configuration";
+ // Use a forgiving deserializer — only a handful of fields matter and providers add
+ // many extras we don't care about.
+ var raw = await _http.GetFromJsonAsync(url, OidcJson.Default.OidcRawDocument, ct).ConfigureAwait(false)
+ ?? throw new InvalidOperationException($"OIDC discovery at {url} returned an empty body.");
+
+ var doc = new OidcDiscoveryDocument(
+ Issuer: raw.Issuer ?? key,
+ AuthorizationEndpoint: raw.AuthorizationEndpoint ?? string.Empty,
+ TokenEndpoint: raw.TokenEndpoint ?? string.Empty,
+ UserInfoEndpoint: raw.UserInfoEndpoint,
+ JwksUri: raw.JwksUri,
+ EndSessionEndpoint: raw.EndSessionEndpoint,
+ ScopesSupported: raw.ScopesSupported ?? [],
+ ResponseTypesSupported: raw.ResponseTypesSupported ?? [],
+ GrantTypesSupported: raw.GrantTypesSupported ?? [],
+ CodeChallengeMethodsSupported: raw.CodeChallengeMethodsSupported ?? []);
+
+ _cache[key] = new CacheEntry(doc, DateTimeOffset.UtcNow + TimeToLive);
+ return doc;
+ }
+
+ private readonly record struct CacheEntry(OidcDiscoveryDocument Document, DateTimeOffset Expires);
+}
+
+/// Subset of an OIDC discovery document we surface to the UI.
+public sealed record OidcDiscoveryDocument(
+ string Issuer,
+ string AuthorizationEndpoint,
+ string TokenEndpoint,
+ string? UserInfoEndpoint,
+ string? JwksUri,
+ string? EndSessionEndpoint,
+ IReadOnlyList ScopesSupported,
+ IReadOnlyList ResponseTypesSupported,
+ IReadOnlyList GrantTypesSupported,
+ IReadOnlyList CodeChallengeMethodsSupported);
+
+/// Wire shape — mirrors the raw JSON from .well-known. snake_case via [JsonPropertyName].
+internal sealed record OidcRawDocument
+{
+ [JsonPropertyName("issuer")] public string? Issuer { get; init; }
+ [JsonPropertyName("authorization_endpoint")] public string? AuthorizationEndpoint { get; init; }
+ [JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; init; }
+ [JsonPropertyName("userinfo_endpoint")] public string? UserInfoEndpoint { get; init; }
+ [JsonPropertyName("jwks_uri")] public string? JwksUri { get; init; }
+ [JsonPropertyName("end_session_endpoint")] public string? EndSessionEndpoint { get; init; }
+ [JsonPropertyName("scopes_supported")] public IReadOnlyList? ScopesSupported { get; init; }
+ [JsonPropertyName("response_types_supported")] public IReadOnlyList? ResponseTypesSupported { get; init; }
+ [JsonPropertyName("grant_types_supported")] public IReadOnlyList? GrantTypesSupported { get; init; }
+ [JsonPropertyName("code_challenge_methods_supported")] public IReadOnlyList? CodeChallengeMethodsSupported { get; init; }
+}
+
+[JsonSerializable(typeof(OidcRawDocument))]
+[JsonSourceGenerationOptions(WriteIndented = false)]
+internal partial class OidcJson : JsonSerializerContext
+{
+}
diff --git a/src/backend/Tap.Studio/BrowserLauncher.cs b/src/backend/Tap.Studio/BrowserLauncher.cs
new file mode 100644
index 0000000..fa53058
--- /dev/null
+++ b/src/backend/Tap.Studio/BrowserLauncher.cs
@@ -0,0 +1,288 @@
+using System.Diagnostics;
+using System.Text.Json;
+using Tap.Studio.Contracts;
+
+namespace Tap.Studio;
+
+///
+/// Discovers the browsers (and their profiles) installed on the host and launches a URL in a
+/// chosen one. Used by the OAuth "open sign-in" flow so a token can be acquired under a specific
+/// browser + profile — handy when testing auth against different accounts/tenants.
+///
+/// Ported from mango's server/src/browser.ts . Runs on the machine hosting Tap.Studio
+/// (the desktop sidecar or the local Aspire studio-api), which is the same box as the user's
+/// browser in every supported deployment.
+///
+public static class BrowserLauncher
+{
+ private static string Home => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+
+ private static string? ReadIfExists(string path)
+ {
+ try { return File.Exists(path) ? File.ReadAllText(path) : null; }
+ catch { return null; }
+ }
+
+ // ---- Profile parsing -------------------------------------------------
+
+ /// Parse a Chromium (Chrome/Edge) Local State file's profile.info_cache .
+ public static IReadOnlyList ParseChromiumProfiles(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (!doc.RootElement.TryGetProperty("profile", out var profile)
+ || !profile.TryGetProperty("info_cache", out var cache)
+ || cache.ValueKind != JsonValueKind.Object)
+ return [];
+
+ var list = new List();
+ var index = 0;
+ foreach (var entry in cache.EnumerateObject())
+ {
+ var key = entry.Name;
+ var name = entry.Value.TryGetProperty("name", out var n) ? n.GetString()?.Trim() : null;
+ var email = entry.Value.TryGetProperty("user_name", out var u) ? u.GetString()?.Trim() : null;
+ if (string.IsNullOrEmpty(name)) name = key;
+ var label = string.IsNullOrEmpty(email) ? name! : $"{name} ({email})";
+ list.Add(new BrowserProfileDto(key, label, index == 0 || key == "Default"));
+ index++;
+ }
+ return list;
+ }
+ catch { return []; }
+ }
+
+ /// Parse a Firefox profiles.ini into its named profiles.
+ public static IReadOnlyList ParseFirefoxProfiles(string ini)
+ {
+ var profiles = new List();
+ string? currentName = null;
+ var isDefault = false;
+
+ void Flush()
+ {
+ if (currentName is null) return;
+ profiles.Add(new BrowserProfileDto(
+ currentName,
+ isDefault ? $"{currentName} (Default)" : currentName,
+ isDefault));
+ }
+
+ foreach (var raw in ini.Split('\n'))
+ {
+ var line = raw.Trim();
+ if (line.Length == 0) continue;
+ if (line.StartsWith("[Profile", StringComparison.Ordinal) && line.EndsWith(']'))
+ {
+ Flush();
+ currentName = null;
+ isDefault = false;
+ continue;
+ }
+ if (line.StartsWith("Name=", StringComparison.Ordinal))
+ {
+ var value = line["Name=".Length..].Trim();
+ currentName = value.Length > 0 ? value : null;
+ continue;
+ }
+ if (line == "Default=1") isDefault = true;
+ }
+ Flush();
+ return profiles;
+ }
+
+ // ---- Executable + profile discovery ---------------------------------
+
+ private static string? ResolveCommand(IEnumerable candidates)
+ {
+ foreach (var candidate in candidates)
+ {
+ if (candidate.Contains(Path.DirectorySeparatorChar) || candidate.Contains('/'))
+ {
+ if (File.Exists(candidate)) return candidate;
+ continue;
+ }
+ // Bare command name — scan PATH (append .exe on Windows).
+ var paths = (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator);
+ var names = OperatingSystem.IsWindows() ? new[] { candidate + ".exe", candidate } : new[] { candidate };
+ foreach (var dir in paths)
+ foreach (var name in names)
+ {
+ if (string.IsNullOrEmpty(dir)) continue;
+ var full = Path.Combine(dir, name);
+ if (File.Exists(full)) return full;
+ }
+ }
+ return null;
+ }
+
+ private static string Env(string name) => Environment.GetEnvironmentVariable(name) ?? "";
+
+ private static IEnumerable ChromiumLocalStatePaths(string browser)
+ {
+ if (OperatingSystem.IsMacOS())
+ return [Path.Combine(Home, "Library", "Application Support",
+ browser == "chrome" ? "Google/Chrome" : "Microsoft Edge", "Local State")];
+ if (OperatingSystem.IsWindows())
+ return [Path.Combine(Env("LOCALAPPDATA"),
+ browser == "chrome" ? "Google/Chrome/User Data" : "Microsoft/Edge/User Data", "Local State")];
+ if (OperatingSystem.IsLinux())
+ return
+ [
+ Path.Combine(Home, ".config", browser == "chrome" ? "google-chrome" : "microsoft-edge", "Local State"),
+ Path.Combine(Home, ".config", browser == "chrome" ? "chromium" : "microsoft-edge-beta", "Local State"),
+ ];
+ return [];
+ }
+
+ private static IEnumerable FirefoxProfilesPaths()
+ {
+ if (OperatingSystem.IsMacOS())
+ return [Path.Combine(Home, "Library", "Application Support", "Firefox", "profiles.ini")];
+ if (OperatingSystem.IsWindows())
+ return [Path.Combine(Env("APPDATA"), "Mozilla", "Firefox", "profiles.ini")];
+ if (OperatingSystem.IsLinux())
+ return [Path.Combine(Home, ".mozilla", "firefox", "profiles.ini")];
+ return [];
+ }
+
+ private static IReadOnlyList GetChromiumProfiles(string browser)
+ {
+ foreach (var path in ChromiumLocalStatePaths(browser))
+ {
+ var raw = ReadIfExists(path);
+ if (raw is null) continue;
+ var profiles = ParseChromiumProfiles(raw);
+ if (profiles.Count > 0) return profiles;
+ }
+ return [];
+ }
+
+ private static IReadOnlyList GetFirefoxProfiles()
+ {
+ foreach (var path in FirefoxProfilesPaths())
+ {
+ var raw = ReadIfExists(path);
+ if (raw is null) continue;
+ var profiles = ParseFirefoxProfiles(raw);
+ if (profiles.Count > 0) return profiles;
+ }
+ return [];
+ }
+
+ private static string? ResolveExecutable(string browser) => browser switch
+ {
+ "chrome" => ResolveCommand(OperatingSystem.IsMacOS()
+ ?
+ [
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ ]
+ : OperatingSystem.IsWindows()
+ ?
+ [
+ Path.Combine(Env("PROGRAMFILES"), "Google", "Chrome", "Application", "chrome.exe"),
+ Path.Combine(Env("PROGRAMFILES(X86)"), "Google", "Chrome", "Application", "chrome.exe"),
+ Path.Combine(Env("LOCALAPPDATA"), "Google", "Chrome", "Application", "chrome.exe"),
+ ]
+ : ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]),
+ "edge" => ResolveCommand(OperatingSystem.IsMacOS()
+ ?
+ [
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+ "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
+ "/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
+ ]
+ : OperatingSystem.IsWindows()
+ ?
+ [
+ Path.Combine(Env("PROGRAMFILES"), "Microsoft", "Edge", "Application", "msedge.exe"),
+ Path.Combine(Env("PROGRAMFILES(X86)"), "Microsoft", "Edge", "Application", "msedge.exe"),
+ ]
+ : ["microsoft-edge", "microsoft-edge-stable", "microsoft-edge-beta", "microsoft-edge-dev"]),
+ "firefox" => ResolveCommand(OperatingSystem.IsMacOS()
+ ?
+ [
+ "/Applications/Firefox.app/Contents/MacOS/firefox",
+ "/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox",
+ "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",
+ ]
+ : OperatingSystem.IsWindows()
+ ?
+ [
+ Path.Combine(Env("PROGRAMFILES"), "Mozilla Firefox", "firefox.exe"),
+ Path.Combine(Env("PROGRAMFILES(X86)"), "Mozilla Firefox", "firefox.exe"),
+ ]
+ : ["firefox"]),
+ "safari" => OperatingSystem.IsMacOS()
+ ? ResolveCommand(["/Applications/Safari.app/Contents/MacOS/Safari"])
+ : null,
+ _ => null,
+ };
+
+ // ---- Public surface --------------------------------------------------
+
+ public static IReadOnlyList ListAvailableBrowsers() =>
+ [
+ new("chrome", "Google Chrome", ResolveExecutable("chrome") is not null, true, GetChromiumProfiles("chrome")),
+ new("edge", "Microsoft Edge", ResolveExecutable("edge") is not null, true, GetChromiumProfiles("edge")),
+ new("firefox", "Firefox", ResolveExecutable("firefox") is not null, true, GetFirefoxProfiles()),
+ new("safari", "Safari", ResolveExecutable("safari") is not null, false, []),
+ ];
+
+ /// Launch in the chosen browser + profile, or the system
+ /// default when is null/empty. Throws when the browser/profile
+ /// isn't available.
+ public static void Open(string url, string? browser, string? profile)
+ {
+ if (string.IsNullOrWhiteSpace(browser))
+ {
+ OpenWithDefaultBrowser(url);
+ return;
+ }
+
+ var option = ListAvailableBrowsers().FirstOrDefault(b => b.Id == browser)
+ ?? throw new InvalidOperationException($"Unknown browser '{browser}'.");
+ if (!option.Available)
+ throw new InvalidOperationException($"{option.Label} is not available on this machine.");
+
+ if (browser == "safari")
+ {
+ Start("open", ["-a", "Safari", url]);
+ return;
+ }
+
+ var executable = ResolveExecutable(browser)
+ ?? throw new InvalidOperationException($"Could not resolve the {option.Label} executable.");
+
+ var args = new List();
+ if (!string.IsNullOrWhiteSpace(profile))
+ {
+ var match = option.Profiles.FirstOrDefault(p => p.Key == profile)
+ ?? throw new InvalidOperationException($"{option.Label} profile \"{profile}\" is not available.");
+ if (browser == "firefox") { args.Add("-P"); args.Add(match.Key); }
+ else args.Add($"--profile-directory={match.Key}");
+ }
+ args.Add(url);
+ Start(executable, args);
+ }
+
+ private static void OpenWithDefaultBrowser(string url)
+ {
+ if (OperatingSystem.IsMacOS()) Start("open", [url]);
+ else if (OperatingSystem.IsWindows()) Start("explorer.exe", [url]);
+ else if (OperatingSystem.IsLinux()) Start("xdg-open", [url]);
+ else throw new InvalidOperationException($"Unsupported platform.");
+ }
+
+ /// Fire-and-forget launch — the browser process stays alive as its own window, so we
+ /// don't wait for exit (that would block). A bad executable throws from Process.Start.
+ private static void Start(string command, IReadOnlyList args)
+ {
+ var psi = new ProcessStartInfo(command) { UseShellExecute = false };
+ foreach (var a in args) psi.ArgumentList.Add(a);
+ Process.Start(psi);
+ }
+}
diff --git a/src/backend/Tap.Studio/Contracts/Dtos.cs b/src/backend/Tap.Studio/Contracts/Dtos.cs
new file mode 100644
index 0000000..57180d5
--- /dev/null
+++ b/src/backend/Tap.Studio/Contracts/Dtos.cs
@@ -0,0 +1,850 @@
+using System.Text.Json.Serialization;
+using Tap.Workspace.Model;
+
+namespace Tap.Studio.Contracts;
+
+///
+/// Wire DTOs for the REST API. Kept separate from so the on-disk
+/// model can evolve without breaking the UI contract. Source-generated JSON via
+/// per the conventions in CLAUDE.md .
+///
+public sealed record WorkspaceInfoDto(
+ string Name,
+ string Root,
+ string? DefaultEnv,
+ IReadOnlyList Providers,
+ IReadOnlyList Errors);
+
+/// Entry in the workspace switcher dropdown. Available is false if the
+/// folder no longer contains a .tap/ directory — the UI greys it out. Git is
+/// filled when an enclosing git repository was discovered at add-time (and is still on
+/// disk); the UI shows branch + origin chips.
+public sealed record KnownWorkspaceDto(string Path, string Label, bool IsActive, bool Available, GitInfoDto? Git);
+
+public sealed record AddWorkspaceDto(string Path);
+public sealed record ActivateWorkspaceDto(string Path);
+
+/// Snapshot of the git repository enclosing a workspace. Root is the
+/// working-directory root (the folder containing .git/ ), which may sit several
+/// levels above the workspace path itself.
+public sealed record GitInfoDto(
+ string Root,
+ string Branch,
+ bool IsDetached,
+ string? OriginUrl,
+ IReadOnlyList Remotes);
+
+public sealed record GitRemoteDto(string Name, string Url);
+
+/// Snapshot of the active workspace's git repo. drives the
+/// pull/push UI affordances; / are null when the
+/// current branch has no upstream.
+public sealed record GitStatusDto(
+ string Root,
+ string Branch,
+ bool IsDetached,
+ bool HasRemote,
+ string? Upstream,
+ int? Ahead,
+ int? Behind,
+ int StagedCount,
+ int UnstagedCount);
+
+/// One changed path. is null when the file has no
+/// staged change; is null when the working tree matches the
+/// index. Values: added | modified | deleted | renamed | typechange | untracked |
+/// conflicted .
+public sealed record GitFileChangeDto(string Path, string? IndexStatus, string? WorkingStatus);
+
+public sealed record GitBranchDto(string Name, bool IsCurrent, bool IsRemote, string? Upstream, string? Tip);
+
+public sealed record GitStagePathsDto(IReadOnlyList Paths);
+public sealed record GitCommitRequestDto(string Message);
+public sealed record GitCommitResultDto(string Sha, string ShortSha, string Message);
+public sealed record GitCreateBranchDto(string Name, bool Checkout);
+public sealed record GitCheckoutDto(string Name);
+public sealed record GitPushRequestDto(bool SetUpstream);
+public sealed record GitSetRemoteDto(string Name, string Url);
+public sealed record GitCommandResultDto(int ExitCode, string Stdout, string Stderr);
+
+/// One subdirectory in the picker. HasTap is set when the entry already
+/// contains a .tap/ directory, so the UI can draw a workspace badge.
+public sealed record DirectoryEntryDto(string Name, string Path, bool HasTap);
+
+/// Body for POST /api/fs/folders . Creates {Parent}/{Name} .
+/// must be a single leaf — path separators and .. are rejected.
+public sealed record CreateDirectoryDto(string Parent, string Name);
+
+/// Response from POST /api/fs/folders . The picker uses
+/// to descend straight into the new folder.
+public sealed record CreateDirectoryResponseDto(string Path);
+
+/// Response from GET /api/fs/browse . Parent is null at the filesystem
+/// root. IsWorkspace says the listed directory itself contains a .tap/ —
+/// the picker enables the confirm button on that signal.
+public sealed record BrowseResponseDto(
+ string Path,
+ string? Parent,
+ string Home,
+ bool IsWorkspace,
+ string? GitRoot,
+ IReadOnlyList Entries);
+
+/// Body for POST /api/workspace/folders — creates an empty directory under
+/// .tap/ . Folders are pure grouping for the explorer tree; no spec file is written.
+public sealed record CreateFolderDto(string Path);
+
+/// Body for POST /api/workspace/move — moves a file or directory. Paths are
+/// workspace-relative (under .tap/ ); the server creates any missing parent directories
+/// of the destination.
+public sealed record MoveItemDto(string From, string To);
+
+public sealed record WorkspaceErrorDto(string Code, string Message, string? Path, int? Line);
+
+public sealed record TreeNodeDto(
+ string Path,
+ string Kind,
+ string Name,
+ string? Id,
+ IReadOnlyList Children);
+
+public sealed record RequestSummaryDto(
+ string Path,
+ string Name,
+ string? Id,
+ string? Auth,
+ IReadOnlyList Tags);
+
+public sealed record RequestDetailDto(
+ string Path,
+ string Name,
+ string? Id,
+ string? Auth,
+ IReadOnlyList Tags,
+ /// HTTP method parsed from the http block. Always present even for
+ /// WebSocket requests (the upgrade handshake uses a method, conventionally GET ).
+ string Method,
+ string Url,
+ IReadOnlyList Headers,
+ string? RequestBody,
+ /// Markdown body around (or after) the HTTP block.
+ string Body,
+ IReadOnlyDictionary Vars,
+ string Source,
+ /// Wire protocol — "http" (default) or "websocket" . Drives the
+ /// renderer's baseUrl scheme normalization and the executor's transport selection.
+ string Protocol);
+
+public sealed record AuthDetailDto(
+ string Path,
+ string Name,
+ string? Id,
+ string Type,
+ IReadOnlyDictionary Fields,
+ IReadOnlyDictionary Headers,
+ IReadOnlyDictionary Query,
+ IReadOnlyList Scopes,
+ IReadOnlyList Tags,
+ string Body,
+ string Source);
+
+public sealed record EnvDetailDto(
+ string Path,
+ string Name,
+ string? Id,
+ /// Env-scoped variables (the same VarSpec shape used by all other scopes —
+ /// each carries an optional Default + the Secret flag).
+ IReadOnlyDictionary Vars,
+ IReadOnlyList Tags,
+ string Body,
+ string Source);
+
+/// Listing-row representation of a collection. is false when
+/// the directory collections/<slug>/ is present on disk but has no
+/// _collection.md yet — the editor uses that to render a create-on-save form.
+public sealed record CollectionSummaryDto(
+ string Slug,
+ string Name,
+ string? Id,
+ bool Exists,
+ string BaseUrl,
+ /// Stage names defined on this collection — full stage detail lives on
+ /// CollectionDetailDto.Stages. Empty when the collection has none.
+ IReadOnlyList StageNames,
+ string? DefaultStage);
+
+/// One named stage inside a collection. Stage fields override the parent
+/// collection's defaults; variables here override workspace + collection scopes but are
+/// still overridden by env / request scopes.
+public sealed record CollectionStageDto(
+ string Name,
+ string? BaseUrl,
+ string? DefaultAuth,
+ IReadOnlyDictionary Vars);
+
+/// Detail view of a collection. is the directory name under
+/// collections/ ; the metadata file lives at
+/// collections/<slug>/_collection.md . Collections own the baseUrl, optional
+/// stages, default auth/headers, plus their own vars / tags / markdown body — what used
+/// to live on a separate api file.
+public sealed record CollectionDetailDto(
+ string Slug,
+ string Name,
+ string? Id,
+ bool Exists,
+ string BaseUrl,
+ string? DefaultAuth,
+ IReadOnlyDictionary DefaultHeaders,
+ IReadOnlyDictionary Vars,
+ IReadOnlyList Tags,
+ string Body,
+ string Source,
+ IReadOnlyList Stages,
+ string? DefaultStage);
+
+/// Structured PUT spec for a collection. The server resolves the on-disk path
+/// from