Skip to content
42 changes: 41 additions & 1 deletion docs/rfcs/0001-cli-auth-and-client-provisioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,21 @@
> server-side revoke needs the `POST /auth/revoke` endpoint (backend#887, not built)
> + a CLI `logout`→call (backend#845 shipped only the `revoke()` primitive) —
> §6.3/§7.5/§9/§13/C.6. The earlier "revokes server-side via #845" claim overstated it.
>
> **Rev 8 (2026-07-07)** product deviation on the two zero-prompt inputs (cli#137,
> installer UX v2 — decided by Lukas 2026-07-06):
> - **Name is `<firstname>-NN`, not the hostname.** The auto-generated name is now
> `<slug(first_name)>-NN` from the signed-in identity (fallback: email local-part),
> numbered per account — the hostname is neither stable nor account-scoped (§6.6/§7.7).
> - **Location is optional, not auto-detected.** With no `--location` the CLI omits
> it and the backend records the client with **no location** (an explicit "not set"
> state — backend#993), rather than auto-detecting a zone at provision time. The
> cloud-metadata auto-detect (`internal/geo`, cli#93) is **removed** — the silent
> path no longer detects, and the backend is the source of truth for valid zones
> (a bad `--location` surfaces as its real create error). §6.7/§7.7.
>
> The §6.6/§6.7/§7.7 bodies below are retained as the original design-of-record; the
> inline **Rev 8** callouts mark where the shipped behavior now differs.

## 0. Decisions settled in this revision

Expand All@@ -53,7 +68,7 @@ the review. They are now decided; the rest of the doc assumes them.

| # | Decision | Choice |
|---|---|---|
| D1 | **Setup is silent / auto, not interactive.** | Common path asks **zero questions**: name = sanitized hostname, location = auto-detect, both *surfaced* in progress and correctable with flags — never prompted. (§6.7, §7.7, §8) |
| D1 | **Setup is silent / auto, not interactive.** | Common path asks **zero questions**: name = sanitized hostname, location = auto-detect, both *surfaced* in progress and correctable with flags — never prompted. (§6.7, §7.7, §8) *(Amended Rev 8 / cli#137: name = `<firstname>-NN`; location optional & omitted when unset — see §6.6/§6.7 callouts.)* |
| D2 | **The machine credential is never shown.** | `client create` prints only name + status. The credential is written straight into the cluster secret (mode `0600`) + stored hashed in the backend, and never touches stdout, scrollback, the clipboard, or `~/.tracebloc`. Rotation = delete + recreate. (§7.1, §7.8, §9) |
| D3 | **Clients are referred to by a human handle, never a secret or backend id.** | The handle is the per-account-unique namespace **slug** (e.g. `munich-hospital-radiology`); bare `use` / `delete` open an arrow-key picker. The UUID / username / password are never displayed. (§7.1) |

Expand DownExpand Up@@ -379,6 +394,15 @@ Today there are effectively two names: `first_name` (display) and `namespace`
(k8s). Asking for both is redundant; in the silent flow we ask for **neither**
(§6.7) — we derive both from the hostname.

> **Amended — Rev 8 / cli#137:** the silent flow no longer derives the name from the
> hostname. It auto-generates `<slug(first_name)>-NN` from the signed-in identity
> (the account — and thus the user's first name — is known at create time; the
> hostname is neither stable nor account-scoped). `NN` is the next free two-digit
> number across the account's existing client names/namespaces, so a second machine
> is `lukas-02` rather than a slug `-2` bump. The derived name is already slug-clean,
> so the derive-once / set-both / freeze namespace rule below is unchanged (name =
> namespace by construction). `--name` / `TRACEBLOC_CLIENT_NAME` still override.

> **All of this is net-new.** Today the backend does *no* namespace processing — it
> stores the client-reported `namespace` verbatim (§4.2), with no slug derivation,
> no format validation, and no uniqueness. The slug rule below, setting `namespace`
Expand DownExpand Up@@ -425,6 +449,16 @@ client can be created with no location and `carbon_intensity` defaults to `0`
i.e. it silently reads as "carbon-free", quietly corrupting the exact metric
tracebloc sells.

> **Amended — Rev 8 / cli#137:** location is no longer auto-detected on the silent
> path. With no `--location` the CLI omits it (`CreateClientRequest.location` is
> `omitempty`) and the backend records the client with **no location** — the explicit
> "not set" state (backend#993), not a silent zero and not a provision-time GeoIP
> guess. The cloud-metadata auto-detect (`internal/geo`, cli#93) is **removed**, not
> parked: the silent path never detects, and the backend is the source of truth for
> valid zones (a bad `--location` surfaces as its real create error). The
> "never block / never fake a zero" principle below stands; "auto-detect silently"
> is the part that's gone.

**Proposal: auto-detect the zone and use it silently; never prompt, never block,
never fake a zero.**

Expand DownExpand Up@@ -614,6 +648,12 @@ default / `unset` rather than block (§6.7). Surface the chosen name + zone in
friendly progress (*"Setting up gpu-box-01 in DE"*) — visible but not
interactive; correct later with `--name` / `--location` or in the dashboard.

> **Amended — Rev 8 / cli#137:** name = `<firstname>-NN` from the signed-in identity
> (not the hostname), and location is **optional** — omitted when no `--location` is
> given, leaving the client in the explicit "not set" state (backend#993) rather than
> auto-detected at provision time. See the §6.6 and §6.7 Rev 8 callouts. Both inputs
> stay zero-prompt on the common path; `--name` / `--location` still override.

### 7.8 If nothing is shown, how does the user manage it later? — **[D2]**

**Risk.** "Never show the credential" (D2) is right — but the user still needs to
Expand Down
14 changes: 9 additions & 5 deletions internal/api/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -331,9 +331,10 @@ func (c *Client) PollToken(ctx context.Context, deviceCode string) (string, erro

// Identity is the signed-in user, from GET /userinfo/.
type Identity struct {
Email string `json:"email"`
Type string `json:"type"`
Account string `json:"account"`
Email string `json:"email"`
FirstName string `json:"first_name"`
Type string `json:"type"`
Account string `json:"account"`
}

// WhoAmI fetches the signed-in user from the backend, authenticating with the
Expand DownExpand Up@@ -394,8 +395,11 @@ type ProvisionedClient struct {
type CreateClientRequest struct {
Name string `json:"first_name"`
Namespace string `json:"namespace"`
Location string `json:"location"`
Password string `json:"password"`
// Location is optional (cli#137): omitted when the operator gives no --location,
// so the backend records the client with no location rather than a silent
// default (backend#993). EdgeDevice.location is blank=True server-side.
Location string `json:"location,omitempty"`
Password string `json:"password"`
// ClusterID anchors the client to this cluster (the kube-system namespace UID)
// so create is get-or-create keyed on it (RFC-0001 §7.2 / backend#883). Omitted
// when the cluster identity can't be read (dual-mode / legacy → plain mint).
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/auth.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,13 +109,20 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
cfg.CurrentEnv = env
prof := cfg.Profile(env)
prof.Token = tok
// Clear any identity carried over from a PREVIOUS sign-in on this env
// before the best-effort lookup: if WhoAmI fails here, a re-login as a
// different user on a shared box would otherwise keep the prior user's
// email/first name — and cli#137 would then auto-name the new client
// after the wrong person. Only a successful WhoAmI repopulates these.
prof.Email, prof.FirstName = "", ""
// Confirm the freshly-issued token actually authenticates, and
// capture the account to show + store. Best-effort: don't fail a
// successful sign-in just because this lookup couldn't run.
client.Token = tok
p.Detailf("authorized — confirming the token with the backend …")
if id, werr := client.WhoAmI(ctx); werr == nil {
prof.Email = id.Email
prof.FirstName = id.FirstName
}
if err := cfg.Save(); err != nil {
return &exitError{code: 1, err: err}
Expand Down
38 changes: 38 additions & 0 deletions internal/cli/auth_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,3 +290,41 @@ func TestAuthStatus_NotSignedIn(t *testing.T) {
t.Errorf("got:\n%s", out)
}
}

// TestLogin_ClearsStaleIdentityOnWhoAmIFailure (review #3): a re-login as a
// different user must not inherit the previous user's identity if the WhoAmI
// confirmation fails — otherwise cli#137 would auto-name the new client after the
// wrong person.
func TestLogin_ClearsStaleIdentityOnWhoAmIFailure(t *testing.T) {
withTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/device/code":
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WDJB-MJHT","verification_uri":"https://x/activate","expires_in":600,"interval":5}`))
case "/device/token":
_, _ = w.Write([]byte(`{"token":"bob_tok"}`))
case "/userinfo/":
w.WriteHeader(http.StatusInternalServerError) // confirmation fails
default:
t.Errorf("unexpected request path %s", r.URL.Path)
}
})
// Pre-existing session for a DIFFERENT user (Alice) on this env.
if err := (&config.Config{CurrentEnv: "dev", Profiles: map[string]*config.Profile{
"dev": {Token: "alice_tok", Email: "alice@co", FirstName: "Alice"},
}}).Save(); err != nil {
t.Fatal(err)
}

if _, err := runCmd(t, "login"); err != nil {
t.Fatalf("login should still succeed when WhoAmI fails: %v", err)
}

cfg, _ := config.Load()
prof := cfg.Current()
if prof.Token != "bob_tok" {
t.Errorf("token = %q, want the new bob_tok", prof.Token)
}
if prof.FirstName != "" || prof.Email != "" {
t.Errorf("stale identity leaked: FirstName=%q Email=%q (want both cleared)", prof.FirstName, prof.Email)
}
}
Loading
Loading