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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/muster/token_seam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ func TestSeamPersonalTokensThroughTheRealServer(t *testing.T) {
// is no CLI or API mint — so this test mints its two through the store,
// as that page does.
asUser := authz.WithPrincipal(ctx, authz.Principal{UserID: uid, Role: authz.RoleMember, Via: authz.ViaSession})
first, _, err := apitokens.NewPG(pool).Create(asUser, "browser-minted", 0)
first, _, err := apitokens.NewPG(pool).Create(asUser, "browser-minted", 0, apitokens.ScopeFull)
if err != nil {
t.Fatal(err)
}
secondTok, secondRec, err := apitokens.NewPG(pool).Create(asUser, "laptop", 0)
secondTok, secondRec, err := apitokens.NewPG(pool).Create(asUser, "laptop", 0, apitokens.ScopeFull)
if err != nil {
t.Fatal(err)
}
Expand Down
49 changes: 49 additions & 0 deletions docs/attention.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,3 +600,52 @@ message. Only your own messages count: a message routed by the chief, the
chief's hand-back turn, a message over the machine door
(`POST /api/agents/{name}/messages`) and anything an agent sends do not. A
reply draft decided outside muster is not cleared by this (it is not an ask).

## The summary API

`GET /api/attention/summary` is what a status bar polls: everything waiting on
you, your mirror hosts' busy and idle Claude windows, and your
[chief nudge](chief-nudges.md) mode, in one JSON document, over HTTPS.

- **Credential:** a [personal API token](auth.md) only (the hook token and a
browser session are refused with 401). It reads as the token's owner, and
nothing of another user's is ever in it. Give a poller a **bar summary
only** token ([auth.md](auth.md)): it reaches this route and nothing else.
- **No stream, no ETag.** Poll it; the body is small. It is never cached
(`Cache-Control: no-store`).
- **What "waiting" means:** exactly the items of the Asks page above — the
same function builds both, so the bar and the page cannot disagree. Busy and
idle never make anything waiting.

```json
{
"ts": 1800000000,
"nudge_mode": "off",
"partial": false,
"hosts": [
{"host": "host-a", "online": true, "status_report": true, "busy": 1, "idle": 0}
],
"items": [
{"key": "ask-5", "kind": "ask", "host": "", "title": "chief", "why": "Ship it?",
"since": 1799999940, "age_secs": 60, "offline": false,
"url": "https://muster.example.test/attention/open/ask-5"}
]
}
```

| field | meaning |
|---|---|
| `ts` | The server's clock when the summary was built, Unix seconds. |
| `nudge_mode` | `off` (chief nudges do not run on this server), `shadow` or `live` for you. |
| `partial` | `true` when part of the queue could not be read, so `items` may be short. Do not read a partial summary as "nothing waits". |
| `hosts[]` | Each of your mirror hosts, by the name it reports (`MUSTER_MIRROR_HOST`), in the hub's order. `online`; `status_report` (it reports its windows' Claude status, so its zeros are measurements); `busy` and `idle`, its Claude (and other agent runtime) windows by status. An offline host counts `0` and `0`: a count is a claim about now. |
| `items[]` | Everything waiting on you, newest first. |
| `items[].key` | The item's id (`ask-<id>`, `session-<hash>`, `draft-<id>`, `task-<id>`). A held question has its window's `session-…` key. |
| `items[].kind` | `ask`, `session`, `question`, `plugin` (a reply draft) or `task`. |
| `items[].host` | The mirror host it is on; `""` for an agent's ask, muster's own, a reply draft and a task. |
| `items[].title`, `items[].why` | What its notification says: the source as a title, and the first line of the notification body. Secrets are redacted, a waiting session is `Waiting: <tool> · <window> @ <host>`, a reply draft is who and where only. Never Claude's last words, an ask's options or its suggested replies. |
| `items[].since`, `items[].age_secs` | When it started waiting (Unix seconds) and `ts` minus that; `0` and `0` when unknown. |
| `items[].offline` | A session item whose host dropped within the last 5 minutes, shown as last seen. |
| `items[].url` | Where to act on it, on `MUSTER_PUBLIC_URL`: the [tap route](push.md) `/attention/open/<key>` for an ask or a session (resolved when opened), the item's own page for a draft or a task. **`""` when `MUSTER_PUBLIC_URL` is not set**: the summary names no origin it was not told. |

Fields may be added; none is renamed or removed.
17 changes: 17 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,23 @@ Two endpoints help with personal tokens:
- `GET /api/tokens` and `DELETE /api/tokens/{id}` manage personal tokens. They
accept a personal token only.

**Token scopes.** When you mint a token you choose its access:

- **Full access** (the default): every route a personal token reaches, as
before scopes existed. Every token minted before them is full access.
- **Bar summary only**: [`GET /api/attention/summary`](attention.md#the-summary-api)
(and `HEAD` on it) and **nothing else**. Every other route a personal token
reaches answers **403** before it runs: the task, agent and flow APIs, the
token API, and `GET /api/whoami` too (the summary's own 200, 401 or 403
says whether the token works). Give a status bar this kind.

The allowed routes are one list in `internal/api/api_tokens.go`
(`tokenScopeRoutes`), checked where every personal token is admitted, so a
route added later is refused to a bar-summary token until someone adds it
there. The token page and `muster token ls` (`"scope": "full"` or
`"summary"`) show each token's scope, and `muster whoami` shows the
presented (full) token's.

From the CLI these are `muster whoami`, `muster token ls` and
`muster token revoke <id>` ([cli.md](cli.md)).

Expand Down
1 change: 1 addition & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ buys only a word.
| `0064` | `chief_nudges`: one row per [stall-nudge](chief-nudges.md) decision — the session (`session_kind` `mirror`/`agent`, `session_key`, its window, or its agent and `chat_session_id` — the one conversation of the agent that stalled), the idle streak it was taken on (`streak_key`, unique per owner and session: the sweep's claim), the decision (`nudge`, `ask`, `leave`, `invalid`), the nudge's message and the quoted owner message (`quote_id`, `quote_at`, `quote_text`) and the action it covers, the ask's question and options, the chief's reason, the `mode` (`shadow`/`live`; a shadow row can hold no ask and no queued message, by CHECK), the `outcome` (a live state, or `suppressed:<guard>`), the live ask and queued message it raised, and the owner's relayed answer. `send_queue.chief_nudge_id` marks a queued nudge or relay, gated by its decision rather than a sign-in (never together with `route_id`). Owned, under row-level security, and re-applied by `db.RLSOn`. Rollback: in the file's header. |
| `0065` | `agent_focus.cwd`: the working directory the session's transcript tail last named when its focus was written, cleaned and absolute, `''` for unknown (`agent_focus_cwd_check`: empty, or starting with `/` and at most 512 characters). Read only by the fleet report's mail door, to decide whether a session is under `MUSTER_FLEET_REPORT_PATHS` ([focus](focus.md#the-working-directory)); never rendered or sent. The column inherits `agent_focus`'s ownership and row-level security; no policy change, not re-applied by `db.RLSOn`. Must apply after `0064`. Rollback: in the file's header. |
| `0067` | `connectors.auth_scheme` gains `postgres`; `connectors.settings` (JSONB, default `{}`): a [postgres connector](connectors.md#postgres-connectors)'s non-secret settings — `database`, `sslmode`, `require_tls`, `statement_timeout_ms`, `max_rows`, `max_bytes` — and `{}` for every other scheme (`connectors_settings_scheme`); `connector_audit.kind` gains `query` (one row per agent query: the SQL's fingerprint and first 120 characters, rows, duration, outcome class; never a parameter value or the password). The columns inherit their tables' ownership and row-level security; not re-applied by `db.RLSOn`. Rollback: in the file's header (delete postgres connectors first). |
| `0068` | `api_tokens.scope` (default `full`, `api_tokens_scope_check`: `full` or `summary`): a [personal token's scope](auth.md) — `full` reaches every route a personal token reaches, `summary` only `GET /api/attention/summary`, enforced at the token door. Every token minted before it is `full`. Inherits the table's row-level security; not re-applied by `db.RLSOn`. 🔴 **Mint `summary` tokens only after the rollout completes**: until the last pre-0068 pod is gone, one minted mid-rollout has full access on every request an old pod answers. 🔴 Rollback: an older image would admit a `summary` token with full access, so revoke every live non-`full` token first, in a transaction with `SET LOCAL muster.scope = 'all'` (the table is under forced row-level security, which binds its owner too, so without it the revoke silently matches nothing, as `muster_app` or as the owner); the steps are in the file's header. |

To change the schema, add a new numbered file.

Expand Down
66 changes: 61 additions & 5 deletions internal/api/api_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ import (
// disabling its owner takes effect on the very next request — tighter than the
// gotrue session tier's DisableSweepInterval, which bounds how long an already
// OPEN stream survives a disable. No route a token can open streams.
//
// 🔴 A NARROWER-SCOPED TOKEN (migration 0068) IS REFUSED AT THE SAME DOOR, BY
// ROUTE. Every wrapper that takes a token goes through admitAPIToken, and it
// admits a token whose scope is not apitokens.ScopeFull only on the mux
// patterns tokenScopeRoutes lists for that scope — ONE table, here — and
// answers anything else 403 ScopedTokenRefusal before the handler runs. It is
// an ALLOWLIST: a route added later is refused to a narrower token the day it
// is registered, until someone writes it into the table. A scope the table
// does not name reaches nothing.

// apiTokensPath is the token-management API (list, revoke; requireAPIToken).
const apiTokensPath = "/api/tokens"
Expand Down Expand Up @@ -132,8 +141,33 @@ func (s *Server) requireAPIToken(next http.HandlerFunc) http.HandlerFunc {
// nothing about which.
const APITokenRefusal = "invalid or revoked API token"

// ScopedTokenRefusal is the 403 body a narrower-scoped token gets on a route
// its scope does not reach.
const ScopedTokenRefusal = "this API token's scope does not reach this route: a bar-summary token can only read GET " +
attentionSummaryPath + " (mint a full-access token at " + accountTokensPath + " for anything else)"

// tokenScopeRoutes is every mux pattern each narrower token scope may reach:
// THE one allowlist (see the file header). The key is the pattern the route
// is registered with (http.Request.Pattern), so a GET pattern also admits
// HEAD — net/http serves HEAD through it — and nothing else. ScopeFull is not
// in it: a full token is not limited.
//
// ⚠ GET /api/whoami IS DELIBERATELY ABSENT. A bar token does not need to know
// who it is; the summary's own 200, 401 or 403 says whether the token works.
var tokenScopeRoutes = map[string]map[string]bool{
apitokens.ScopeSummary: {"GET " + attentionSummaryPath: true},
}

// tokenScopeAdmits reports whether scope reaches the route r was matched to.
// An unknown scope reaches nothing, and a request with no matched pattern (a
// handler called directly) is refused to every scope but ScopeFull.
func tokenScopeAdmits(scope string, r *http.Request) bool {
return scope == apitokens.ScopeFull || tokenScopeRoutes[scope][r.Pattern]
}

// admitAPIToken authenticates a personal token and admits the request as its
// owner, or refuses it 401. See the file header.
// owner, or refuses it 401 — or 403 for a narrower-scoped token on a route its
// scope does not reach. See the file header.
//
// ⚠ NO FAILURE LIMITER, ON PURPOSE: a 256-bit token cannot be guessed, so a
// limiter would only turn 401 into 429. The one gate before the lookup is
Expand All @@ -160,6 +194,10 @@ func (s *Server) admitAPIToken(w http.ResponseWriter, r *http.Request, next http
s.writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "could not check the API token"})
return
}
if !tokenScopeAdmits(t.Scope, r) {
s.writeJSON(w, http.StatusForbidden, map[string]any{"error": ScopedTokenRefusal})
return
}
ctx := context.WithValue(r.Context(), apiTokenCtxKey{}, t)
s.admit(w, r.WithContext(ctx), next, s.principalFor(r.Context(), t.UserID, authz.ViaToken))
}
Expand All @@ -181,7 +219,7 @@ func (s *Server) handleWhoAmI(w http.ResponseWriter, r *http.Request) {
p := authz.MustFrom(r.Context())
out := map[string]any{"userId": p.UserID, "role": p.Role, "via": p.Via}
if t, ok := apiTokenFromContext(r.Context()); ok {
out["token"] = map[string]any{"id": t.ID, "name": t.Name, "createdAt": t.CreatedAt}
out["token"] = map[string]any{"id": t.ID, "name": t.Name, "createdAt": t.CreatedAt, "scope": t.Scope}
}
if actor, ok := serviceActorFromContext(r.Context()); ok {
out["actor"] = actor
Expand Down Expand Up @@ -236,8 +274,9 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
s.renderAccountTokens(w, r, http.StatusInternalServerError, "", "The token could not be created.")
return
}
tok, _, err := s.ext.APITokens.Create(r.Context(), r.PostFormValue("name"), iat)
if errors.Is(err, apitokens.ErrNameTooLong) {
// The scope choice: absent is a full-access token, as before 0068.
tok, _, err := s.ext.APITokens.Create(r.Context(), r.PostFormValue("name"), iat, r.PostFormValue("scope"))
if errors.Is(err, apitokens.ErrNameTooLong) || errors.Is(err, apitokens.ErrBadScope) {
s.renderAccountTokens(w, r, http.StatusBadRequest, "", err.Error())
return
}
Expand Down Expand Up @@ -277,6 +316,12 @@ func (s *Server) handleAccountTokenRevoke(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, accountTokensPath, http.StatusSeeOther)
}

// The scopes as the token page names them.
const (
scopeLabelFull = "Full access"
scopeLabelSummary = "Bar summary only"
)

// renderAccountTokens writes the token page. minted, when set, is the token
// just created — the only time it is ever rendered.
func (s *Server) renderAccountTokens(w http.ResponseWriter, r *http.Request, code int, minted, problem string) {
Expand Down Expand Up @@ -307,6 +352,13 @@ func (s *Server) renderAccountTokens(w http.ResponseWriter, r *http.Request, cod
<label for="token-name" class="text-sm font-medium text-fg">Name</label>
<input id="token-name" name="name" maxlength="` + strconv.Itoa(apitokens.MaxNameLen) + `" placeholder="laptop CLI"
class="min-h-[44px] rounded-md bg-bg px-3 py-2 text-base text-fg ring-1 ring-inset ring-edge focus:outline-none focus:ring-2 focus:ring-focus">
<fieldset class="flex flex-col gap-1" data-testid="token-scope">
<legend class="text-sm font-medium text-fg">Access</legend>
<label class="flex min-h-[44px] items-center gap-2 text-sm text-fg"><input type="radio" name="scope" value="` + apitokens.ScopeFull + `" checked class="h-5 w-5">
` + scopeLabelFull + `</label>
<label class="flex min-h-[44px] items-center gap-2 text-sm text-fg"><input type="radio" name="scope" value="` + apitokens.ScopeSummary + `" class="h-5 w-5">
` + scopeLabelSummary + ` (can read the Attention summary and nothing else)</label>
</fieldset>
<button type="submit" class="press min-h-[44px] rounded-md bg-accent px-3 py-2 text-base font-semibold text-on-accent transition hover:bg-accent/90">Create token</button>
</form>
<ul class="flex flex-col gap-2" data-testid="token-list">
Expand All @@ -319,13 +371,17 @@ func (s *Server) renderAccountTokens(w http.ResponseWriter, r *http.Request, cod
if name == "" {
name = "(unnamed)"
}
scope := scopeLabelFull
if t.Scope != apitokens.ScopeFull {
scope = scopeLabelSummary
}
used := "never used"
if t.LastUsedAt != nil {
used = "last used " + t.LastUsedAt.UTC().Format("2006-01-02 15:04") + " UTC"
}
b.WriteString(`<li class="flex flex-col gap-1 rounded-md bg-bg px-3 py-2 ring-1 ring-inset ring-edge" data-token-id="` + strconv.FormatInt(t.ID, 10) + `">
<span class="break-all text-sm font-semibold text-fg">` + html.EscapeString(name) + `</span>
<span class="text-xs text-fg2">created ` + t.CreatedAt.UTC().Format("2006-01-02 15:04") + ` UTC · ` + used + `</span>
<span class="text-xs text-fg2" data-token-scope="` + html.EscapeString(t.Scope) + `">` + scope + ` · created ` + t.CreatedAt.UTC().Format("2006-01-02 15:04") + ` UTC · ` + used + `</span>
`)
if t.Revoked() {
b.WriteString(`<span class="text-xs font-semibold text-fg2">revoked</span>`)
Expand Down
Loading
Loading