Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ee7bbc3
feat(tunnel-hub): slice 1 — Backend.Owner + owner-filtered List (no a…
claude Jul 27, 2026
775db82
docs(proposal): add audit logging & usage tracking to hub-auth
claude Jul 27, 2026
2c762a9
feat(tunnel-hub): slice 2a — audit sink (append-only JSONL)
claude Jul 27, 2026
d5eb454
hub-auth slice 2: GitHub OAuth web flow + session cookie
claude Jul 27, 2026
3154597
hub-auth slice 2: wire OAuth auth into the hub server + CLI
claude Jul 27, 2026
fb6b261
hub-auth slice 3: hub API keys + register-by-key (stamp Owner)
claude Jul 27, 2026
853a86e
docs(hub-auth): mark slice 3 done in the proposal
claude Jul 27, 2026
ddcd853
hub-auth slice 4: client device-flow login + X-Hub-Key registration
claude Jul 27, 2026
1896607
docs(hub-auth): slice 4 done — run-local auth section + proposal
claude Jul 27, 2026
4d2a296
hub-auth: fix review findings (anon listing leak + hardening)
claude Jul 27, 2026
e06015e
Merge pull request #50 from ako/claude/mxbuild-diagnostics-spike-emta6h
ako Jul 28, 2026
dddcdb3
hub-auth: show signed-in GitHub identity on the admin page
claude Jul 28, 2026
b22b5d7
hub-auth: post-deploy fixes from sudoku finding #31
claude Jul 28, 2026
23de09c
hub-auth: durable key store so keys survive restarts
claude Jul 28, 2026
a3d5ece
hub-auth: browser key issuance; retire the device flow
claude Jul 28, 2026
75420f5
hub-auth: rotate keys by default + browser key management
claude Jul 28, 2026
736041c
hub-auth: drop stale device-flow wording from help + doc comments
claude Jul 28, 2026
1a470b3
docs(hub-auth): update site/skill/proposal/CLAUDE.md to as-built model
claude Jul 28, 2026
d8f383a
Merge pull request #51 from ako/claude/mxbuild-diagnostics-spike-emta6h
ako Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions .claude/skills/mendix/run-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ Launch `run --local` as the **sole** command in its invocation (don't chain a tr
|------|---------|---------|
| `--local` | — | Required; run without Docker (implied by `--hub`) |
| `--hub` | — | Expose the app in a browser at a tunnel-hub URL (see below) |
| `--hub-secret` | — | Shared auth (`user:pass`) matching the hub's `--secret` |
| `--hub-secret` | — | Shared auth (`user:pass`) matching an **open** hub's `--secret` |
| *(hub API key)* | — | For an **authenticated** hub: get one from `https://<hub>/cli`, set `MXCLI_HUB_KEY` (see below) |
| `--watch` | off | Rebuild + hot-apply on each change |
| `--ensure-db` | off | Provision local Postgres + app database if missing |
| `--setup` | off | Cache MxBuild+runtime + ensure DB, then exit (SessionStart bring-up) |
Expand Down Expand Up @@ -268,9 +269,41 @@ sortable overview at `https://hub.example.com/`. Each `run --hub` self-registers
with `--watch` for the full loop: edit here → hot-apply → refresh the browser.

**Hub setup:** wildcard `*.example.com` A record (+ `hub.example.com`) → the VPS; inbound
80+443 open (per-subdomain Let's Encrypt on demand). **Security:** this version uses one
shared `--secret` and open registration, so keep the hub to people you trust and don't
expose it publicly (per-tenant auth is a follow-up).
80+443 open (per-subdomain Let's Encrypt on demand).

### Authenticated hub (GitHub)

A self-hosted hub started **without** the GitHub flags is open — the shared `--hub-secret`
is all `run --hub` needs. A hub started **with** `--github-oauth-client-id` (see
`mxcli tunnel-hub --help`) adds per-user isolation: browsers sign in with GitHub and see
only their own previews, and registration needs a **per-user hub API key** instead of the
shared secret.

**Get a key from the hub's browser page** (works from any device — desktop, or Claude
Code web/mobile, whose container can't reach GitHub's device endpoints):

1. Open `https://hub.mxcli.org/cli` in a browser and sign in with GitHub.
2. Click **Create a hub key** and copy it.
3. Set it as an environment/repo secret in your Claude Code environment — it's picked up
automatically and survives container reaping:

```bash
export MXCLI_HUB_KEY=<key>
mxcli run --hub https://hub.mxcli.org -p app.mpr # registers previews as you
```

The key is **durable** (no expiry, survives hub restarts) — you set it once. It stays valid
until you revoke it (sign out on the hub, or `mxcli auth hub logout`).

Headless alternative (CI, or a machine with a GitHub token): `mxcli auth hub login --token
<github-pat>` mints and caches a key in `~/.mxcli/auth.json` (mode 0600). The GitHub token
is used once for the mint and never stored.

**Registration failure is non-fatal:** if the hub is unreachable or the key is stale/rejected,
`run --hub` prints a warning and continues as a normal local run (the app boots on localhost;
only the public preview URL is lost). On an authenticated hub the shared `--hub-secret` still
works as a fallback registration credential — a valid key stamps the preview as yours, a valid
secret registers it owner-less.

## Validation checklist

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati
- Docker build integration (`mxcli docker build`) with PAD patching (Phase 1)
- Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`): Docker-free `mxbuild --serve` + standalone runtime, hot `reload_model` for behavioural changes and restart+DDL for structural ones (chosen from the serve build's `restartRequired`). Bundles the browser client (`web/dist/` via mxbuild's rollup runner, which the serve Deploy target skips) so Mendix 11.x apps render in a browser. `--watch` keeps an incremental rollup bundler hot (CHOKIDAR_USEPOLLING for container fs; ~3-4s page re-bundle, skipped for model-only edits) and watches only model source (`.mpr`+`mprcontents/`). `--ensure-db` provisions the local Postgres + app database if missing; `--setup` does the non-blocking prerequisites (cache mxbuild+runtime, ensure DB) and exits — `mxcli init` wires it into a Claude Code SessionStart hook so a fresh/reaped web session self-bootstraps, and `docs-site/src/tools/bootstrap-prompt.md` is the empty-repo seed prompt. `--screenshot` captures a Playwright PNG each change (pixel-perfect page loop), with `--screenshot-url` deep links (repeatable for multi-page sets, one PNG per page) and `--screenshot-user`/`--screenshot-password` form login (session saved as Playwright storage state, reused via `screenshot --load-storage`). See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md`
- External browser preview (`mxcli run --hub <url>` + `mxcli tunnel-hub`): the app stays local and reverse-tunnels out over a single 443 connection (embedded chisel) to a static relay, so it is reachable in a browser at a public URL — works from egress-only environments (Claude Code web), verified live through the session's MITM egress proxy. `run --hub` implies `--local`, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA/`originURI` work under the public origin), resolves the control proxy honouring `NO_PROXY`, and retries forever. `mxcli tunnel-hub --domain <base>` is the **multi-tenant** relay: a registry keyed by prefix/project/solution/branch/worktree (stable URLs on reconnect) fronts many previews at per-subdomain hosts (`[prefix-]project[-branch].<base>`; main collapses to the project) over one 443 with per-subdomain autocert, a registration API (`/api/register|status|deregister|backends`), and a sortable availability overview at `hub.<base>/`. Client identity flags: `--hub-prefix`/`--hub-project`/`--hub-solution`/`--hub-branch`/`--hub-worktree` (project + branch auto-detected). Package: `cmd/mxcli/tunnelhub/`. See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4)
- Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub): **viewer plane** — GitHub OAuth web flow + HMAC-signed SSO session cookie (`Domain=.<domain>`), owner-checked previews (`--require-auth` default on → 302 to login / 403 non-owner; soft mode filters the listing only), `/api/backends` filtered to the viewer (unauthenticated → 401), admin "signed in as" via `/api/whoami`. **Registration plane** — durable, hashed hub API keys (`--keys-file`, default `~/.mxcli/hub-keys.json`, survive restarts) presented as `X-Hub-Key` → stamps `Backend.Owner`; shared `X-Hub-Secret` still works as an owner-less fallback. **Key issuance** — the hub's `/cli` browser page mints a key from the session cookie (no PAT; the device flow was removed as Claude Code containers block GitHub's device endpoints), rotate-by-default + count + revoke-all; `mxcli auth hub login --token <pat>` is the headless path; `run --hub` reads `MXCLI_HUB_KEY` (env → `~/.mxcli/auth.json`) and degrades to local-only if registration fails. Append-only JSONL audit trail (`--audit-log`, no secrets). Packages: `cmd/mxcli/tunnelhub/` (+`audit/`), `cmd/mxcli/hubauth/`. See `docs/11-proposals/PROPOSAL_hub_authentication.md`
- Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`): `--metrics` registers a Prometheus Micrometer registry at boot (served at `http://127.0.0.1:<admin-port>/prometheus`); `--runtime-setting` merges arbitrary runtime config (e.g. `Metrics.Registries` for otlp/influx/statsd, or `OpenTelemetry._RuntimeSpanFilters`) into mxcli's **single** boot `update_configuration` call — the admin action replaces rather than merges, so folding settings into the one boot call is the only safe way. OTel traces via `--trace` attach the bundled `opentelemetry-javaagent` to the runtime JVM (console exporter → the tee'd runtime log) and ship default `OpenTelemetry._RuntimeSpanFilters` (unfiltered per-activity tracing is ~10× slower); `--trace-service` sets `OTEL_SERVICE_NAME`. The console exporter omits timestamps/parent span IDs (no flame charts), so `--trace-otlp <endpoint>` (implies `--trace`) switches to the OTLP exporter (protocol `http/protobuf`) pointed at a collector; user-set `OTEL_*` env still takes precedence. See `.claude/skills/mendix/run-local.md`
- OQL query execution against running runtime (`mxcli oql`)
- Microflow/nanoflow debugger (`mxcli debug`): set breakpoints **by name** (activity resolved from the model), inspect paused flows + variables, step over/into/out, continue — against a `run --local` runtime. Two M2EE planes wired behind one command (admin `enable/disable/status`, app `/debugger/` session); `run --local --debug` enables it at boot. **Nanoflows** are auto-detected (uses the `nanoflow_name` breakpoint param; paused nanoflows are merged from `poll_events`, which `get_paused_microflows` omits). Nanoflow `LOG` output is rewritten to the `Client_Nanoflow` node in the runtime log. See `.claude/skills/mendix/debug-microflows.md` and `docs/11-proposals/PROPOSAL_microflow_debugger.md`
Expand Down
130 changes: 130 additions & 0 deletions cmd/mxcli/cmd_auth_hub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"fmt"
"os"
"strings"

"github.com/mendixlabs/mxcli/cmd/mxcli/hubauth"
"github.com/spf13/cobra"
)

// authHubCmd groups the tunnel-hub credential commands under `mxcli auth hub`.
// A hub API key authorizes `mxcli run --hub` to register a preview as you; get
// one from the hub's /cli browser page (or --token for headless use).
var authHubCmd = &cobra.Command{
Use: "hub",
Short: "Manage tunnel-hub API keys (mxcli run --hub)",
Long: `Authenticate to a tunnel-hub so 'mxcli run --hub' can register previews
under your GitHub identity.

Get a key from the hub's browser page — open https://<hub>/cli, sign in with
GitHub, and copy the key. This works from any device (including Claude Code on the
web/mobile, whose container cannot reach GitHub's device endpoints). Set it as an
environment/repo secret so every session picks it up:

MXCLI_HUB_KEY=<key> # takes precedence over any stored key

'mxcli auth hub login --token <github-pat>' is the headless alternative (CI): it
mints a key from a GitHub token and caches it in ~/.mxcli/auth.json. 'status' and
'logout' inspect and revoke the stored key.

Open self-hosted hubs need no key — 'run --hub' falls back to the shared
--hub-secret.`,
}

var authHubLoginCmd = &cobra.Command{
Use: "login",
Short: "Get a hub API key (browser page, or --token for headless use)",
RunE: runAuthHubLogin,
}

var authHubStatusCmd = &cobra.Command{
Use: "status",
Short: "Show whether a hub API key is configured",
RunE: runAuthHubStatus,
}

var authHubLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Revoke and remove the stored hub API key",
RunE: runAuthHubLogout,
}

func init() {
for _, c := range []*cobra.Command{authHubLoginCmd, authHubStatusCmd, authHubLogoutCmd} {
c.Flags().String("hub", hubauth.DefaultHubURL, "hub base URL")
}
authHubLoginCmd.Flags().String("token", "", "GitHub token (PAT) to mint the hub key from, for headless use (CI); interactive users mint from the hub's /cli page instead")
authHubCmd.AddCommand(authHubLoginCmd, authHubStatusCmd, authHubLogoutCmd)
authCmd.AddCommand(authHubCmd)
}

func runAuthHubLogin(cmd *cobra.Command, _ []string) error {
hubURL, _ := cmd.Flags().GetString("hub")
token, _ := cmd.Flags().GetString("token")
out := cmd.OutOrStdout()

// The primary way to get a key is the hub's browser page — it works from any
// device (including Claude Code web/mobile, whose container can't run GitHub's
// device flow). This command covers the headless path: mint from a token.
if token == "" {
fmt.Fprintf(out, "To get a hub key, open this in a browser, sign in with GitHub, and copy the key:\n\n")
fmt.Fprintf(out, " %s/cli\n\n", strings.TrimRight(hubURL, "/"))
fmt.Fprintf(out, "Then set it in your environment (a repo/environment secret in Claude Code):\n")
fmt.Fprintf(out, " export %s=<key>\n\n", hubauth.EnvHubKey)
fmt.Fprintf(out, "Or, headless, mint from a GitHub token: mxcli auth hub login --token <github-pat>\n")
return nil
}

client := &hubauth.Client{HubURL: hubURL}
login, err := client.LoginWithToken(cmd.Context(), token)
if err != nil {
return err
}
fmt.Fprintf(out, "✓ Logged in as %s. Hub key saved for %s.\n", login, hubauth.HostOf(hubURL))
fmt.Fprintf(out, " 'mxcli run --hub %s' will now register previews as %s.\n", hubURL, login)
return nil
}

func runAuthHubStatus(cmd *cobra.Command, _ []string) error {
hubURL, _ := cmd.Flags().GetString("hub")
host := hubauth.HostOf(hubURL)
out := cmd.OutOrStdout()

if envKey := os.Getenv(hubauth.EnvHubKey); envKey != "" {
fmt.Fprintf(out, "Hub: %s\n", host)
fmt.Fprintf(out, "Source: env (%s)\n", hubauth.EnvHubKey)
fmt.Fprintln(out, "Status: a key is set via the environment (overrides any stored key).")
return nil
}
if _, ok := hubauth.StoredKey(hubURL); ok {
fmt.Fprintf(out, "Hub: %s\n", host)
fmt.Fprintln(out, "Source: file (~/.mxcli/auth.json)")
fmt.Fprintln(out, "Status: a hub key is stored for this host.")
return nil
}
fmt.Fprintf(out, "Hub: %s\n", host)
fmt.Fprintln(out, "Status: no hub key configured. Run: mxcli auth hub login")
return nil
}

func runAuthHubLogout(cmd *cobra.Command, _ []string) error {
hubURL, _ := cmd.Flags().GetString("hub")
out := cmd.OutOrStdout()

// Best-effort revoke on the hub before dropping the local copy.
if key, ok := hubauth.StoredKey(hubURL); ok {
client := &hubauth.Client{HubURL: hubURL}
if err := client.RevokeHubKey(cmd.Context(), key); err != nil {
fmt.Fprintf(out, "warning: could not revoke on the hub (%v); removing local copy anyway\n", err)
}
}
if err := hubauth.DeleteKey(hubURL); err != nil {
return fmt.Errorf("removing stored hub key: %w", err)
}
fmt.Fprintf(out, "Removed hub key for %s.\n", hubauth.HostOf(hubURL))
return nil
}
8 changes: 8 additions & 0 deletions cmd/mxcli/cmd_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"

"github.com/mendixlabs/mxcli/cmd/mxcli/docker"
"github.com/mendixlabs/mxcli/cmd/mxcli/hubauth"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -69,6 +70,7 @@ Examples:
mxcli run --local -p app.mpr --app-port 8081 --db-name myapp
mxcli run --hub https://hub.example.com -p app.mpr # browser preview
mxcli run --hub https://hub.example.com --hub-secret u:pass -p app.mpr --watch
mxcli auth hub login && mxcli run --hub https://hub.mxcli.org -p app.mpr # authed hub
`,
Run: func(cmd *cobra.Command, args []string) {
local, _ := cmd.Flags().GetBool("local")
Expand All @@ -81,8 +83,13 @@ Examples:
hubWorktree, _ := cmd.Flags().GetString("hub-worktree")
// --hub is a cross-cutting ingress and implies the local serving path (the
// only serving mode wired today; a future PAD path will accept --hub too).
hubKey := ""
if hub != "" {
local = true
// Present a per-user hub API key to an authenticated hub (MXCLI_HUB_KEY
// env → ~/.mxcli/auth.json). Empty for open hubs; the shared --hub-secret
// still applies.
hubKey = hubauth.ResolveKey(hub)
}
if !local {
fmt.Fprintln(os.Stderr, "Error: only --local is supported for now (use 'mxcli docker run' for the container workflow)")
Expand Down Expand Up @@ -131,6 +138,7 @@ Examples:
ProjectPath: projectPath,
Hub: hub,
HubSecret: hubSecret,
HubKey: hubKey,
HubPrefix: hubPrefix,
HubProject: hubProject,
HubSolution: hubSolution,
Expand Down
Loading
Loading