# JUJU authentication & authorization


> **0.2.0 update (supersedes older workspace-only grant wording below):** New
> OAuth grants authenticate the JUJU installation; every request still checks
> a separate current-workspace/client scope ACL. Legacy tokens remain bound to
> their original root until a fresh login. Bare `juju` now starts HTTP and a
> private token-protected config listener on 127.0.0.1:21731; subprocess clients
> must explicitly use `juju stdio`. `--allow --all` and `--bypass` are run-only
> execution overrides, never authentication or workspace-ACL bypasses.
> See [ADR-017](adr/017-installation-identity-local-config.md) and the
> [0.2 setup guide](../README.md#quickstart) for the current contract.

JUJU is a local-first, single-owner, project-scoped coding MCP server. Given a
public URL it turns on a full **embedded OAuth 2.1 / OIDC authorization server**,
co-hosted with the MCP resource server on one public origin. The protocol
machinery — PKCE, token formats, refresh rotation, JWKS, discovery — comes from
the maintained [`oidc-provider`](https://github.com/panva/node-oidc-provider)
library (v9.12.2); JUJU never hand-rolls crypto or token formats. What JUJU adds
is the part that makes single-owner mode safe: **consent happens on your terminal
over a private local IPC socket, not in the browser.**

This document describes the real implementation; every claim links to the source.

Relevant files:

- [src/auth/provider.ts](../src/auth/provider.ts) — the `oidc-provider` configuration (issuer, resource indicators, scopes, PKCE, grant reuse, TTLs, ES256).
- [src/auth/verifier.ts](../src/auth/verifier.ts) — resource-server token verification on every MCP call.
- [src/auth/interactions.ts](../src/auth/interactions.ts) — the owner-consent interaction flow.
- [src/auth/clients.ts](../src/auth/clients.ts) — static client registration.
- [src/auth/adapter.ts](../src/auth/adapter.ts) — SQLite persistence adapter for `oidc-provider` models.
- [src/auth/keys.ts](../src/auth/keys.ts) — persistent ES256 signing keys and cookie keys.
- [src/server/appServer.ts](../src/server/appServer.ts) — how the routes are mounted.
- [src/cli/main.ts](../src/cli/main.ts) — the `juju auth …` owner commands.
- [JUJU_IMPLEMENTATION_SPEC.md](../JUJU_IMPLEMENTATION_SPEC.md) §8 — the normative spec.

---

## 1. Roles and terminology

| Term | Meaning | Source |
| --- | --- | --- |
| **Issuer** | The configured public origin, e.g. `https://juju.example.com`. Set once from `--public-url`; **never derived from `Host` or `X-Forwarded-*`**. | [appServer.ts](../src/server/appServer.ts) `issuer = url.origin` |
| **Resource** | The issuer **plus `/mcp`**, e.g. `https://juju.example.com/mcp`. This is the exact `audience` every access token is minted for and checked against. | [provider.ts](../src/auth/provider.ts) `resourceUrl` |
| **Owner** | The single local account. Its `accountId` is the constant `"owner"` ([constants.ts](../src/auth/constants.ts)); there is no sign-up, no password, no SaaS account system. Owner identity is proven by reaching the local IPC socket, not by any credential over HTTP. | [interactions.ts](../src/auth/interactions.ts) |
| **Client** | An OAuth client (e.g. ChatGPT) that the owner **statically pre-registers** with an exact redirect URI. Public (PKCE-only) or confidential (with a secret). | [clients.ts](../src/auth/clients.ts) |
| **Grant** | The durable record of "the owner consented to client _C_ for workspace _W_ with scopes _S_". Tokens hang off a grant; revoking the grant kills them. | [store.putGrant](../src/store/store.ts) |
| **Scopes** | `juju:read`, `juju:write`, `juju:exec` (+ `offline_access` for refresh). | [security/policy.ts](../src/security/policy.ts) `ALL_SCOPES` |

The issuer/resource split is deliberate and load-bearing: the **issuer** identifies
the authorization server for discovery, while the **resource** is the audience the
resource server enforces. A token minted for one workspace's `/mcp` resource is
useless anywhere else (see §4). Until a public URL is configured the server runs
**locked**: `/mcp` returns `401` and none of the auth stack is even mounted (§3).

---

## 2. The authorization flow (code + PKCE S256, consent over local IPC)

JUJU uses the standard **authorization-code grant with PKCE (S256 required)** —
[provider.ts](../src/auth/provider.ts) sets `pkce: { required: () => true }` and
`code_challenge_methods_supported` is advertised as `['S256']` only. The twist is
where human consent lives. There is **no browser "Allow" button** that a remote
party can click. Instead:

```
 Client (browser)                 JUJU public origin              Owner terminal
 ───────────────                 ──────────────────              ──────────────
 1. GET /auth?client_id&           provider validates client,
    redirect_uri&scope&            PKCE, redirect_uri; creates
    code_challenge(S256)  ───────► an interaction bound to a
                                   NEW browser session cookie
                                            │
 2.  ◄───── 302 /interaction/<uid> ─────────┘
 3. GET /interaction/<uid>  ─────► renders a page showing:
                                     • request id  <uid>
                                     • check phrase (3 words)
                                     • client_id
                                     • requested scopes
                                   creates a pending approval
                                   record, TTL 5 min
 4. page polls /interaction/<uid>/status  (session-cookie bound)
                                            │
 5.                                         │      juju auth pending
                                            │  ──► shows id, phrase, client,
                                            │      EXACT callback, workspace
                                            │      root, scopes
                                            │      owner compares to browser
                                            │
 6.                                         │      juju auth approve -- <uid>
                                            │  ──► IPC flips approval → approved
 7. status poll returns "approved"  ◄───────┘
 8. POST /interaction/<uid>/complete
    header: x-juju-interaction: <uid>  ───► verifies session cookie + header +
                                            approved + client match; creates the
                                            Grant; interactionResult(login+consent)
 9.  ◄──── 302 returnTo ─────────────────── resumes /auth
10. provider issues code to the EXACT
    registered redirect_uri  ──────────────► client exchanges code+verifier
                                             at /token for tokens
```

Step by step, against the code:

1. **Client starts / interaction created.** `oidc-provider` validates `client_id`,
   the redirect URI (exact match against the static registration), the `scope`,
   and the PKCE `code_challenge`; missing PKCE is rejected outright. The default
   login+consent policy fires and JUJU renders the prompt itself at
   `/interaction/<uid>` ([provider.ts](../src/auth/provider.ts) `interactions.url`),
   bound to a fresh **browser session cookie** only this browser holds.
2. **Consent page.** [interactions.ts](../src/auth/interactions.ts) shows the
   **request id** (`uid`), a **human-checkable phrase** — three words derived
   deterministically from the id by `phraseFor()`
   ([util/hash.ts](../src/util/hash.ts), e.g. `amber-cedar-nova`) — the client id,
   and the scopes. A pending approval record is stored with a **5-minute TTL**
   (`OAUTH_INTERACTION_TTL_MS`), capped at 20 concurrent requests. The browser
   then polls `GET /interaction/<uid>/status`, which is `interactionDetails`-bound
   and so only ever reports **this browser's own session**.
3. **Owner verifies + approves on the terminal.** `juju auth pending` prints the
   id, phrase, client, **exact callback URL**, workspace root, and scopes; the
   owner confirms the phrase and callback **match the browser page** (the real
   redirect URI, not just a display name) and runs `juju auth approve -- <uid>`,
   which flips the approval to `approved` **only over the private IPC socket**
   (`auth.approve` in [appServer.ts](../src/server/appServer.ts)). Nothing on the
   public port can do this.
4. **Browser completes.** On seeing `approved` it posts
   `/interaction/<uid>/complete`, which requires **together**: the provider
   **session cookie** (via `interactionDetails`), a custom `x-juju-interaction:
   <uid>` **header** (cross-site form posts cannot set it), an approval in state
   `approved`, and a matching `client_id`. It marks the approval `consumed`
   (single-use), creates the `Grant` (`addOIDCScope` + `addResourceScope` binding
   the audience to `/mcp`), persists a JUJU grant row tagged with the workspace,
   and calls `interactionResult` with `login: { accountId: "owner" }` +
   `consent: { grantId }`.
5. **Code issued.** The provider resumes `/auth`; `loadExistingGrant` reuses the
   just-created grant without re-prompting, and the code is issued to the **exact
   registered redirect URI**, exchanged at `/token` with the PKCE verifier for an
   access token (+ refresh token).

### Why there is no "approve from a request id alone" endpoint

The request id is **not a login secret**. A remote attacker can trivially cause a
pending interaction to exist (just start an OAuth flow), so "there is a pending
request with id X" proves nothing about who is asking. Approval therefore
requires **two things the attacker cannot have**:

- The state change (`pending → approved`) happens **only over the local IPC
  socket**, whose trust boundary is _same-OS-user_ (§8). No public route mutates
  approval state.
- The completion (`/complete`) requires the **provider's own session cookie** for
  that interaction **plus** a custom header. A different browser — even one that
  knows the id and sees "approved" — has no cookie and cannot complete it, and a
  cross-site page cannot forge the custom header.

So even the owner clicking "approve" does not let some _other_ session ride the
approval: the browser that completes must be the same one that started, and the
approval must have been flipped from the same machine's terminal. Trusting a
loopback source IP is explicitly **not** treated as consent (spec §8.4).

---

## 3. Discovery endpoints and the 401 challenge

When unlocked, [appServer.ts](../src/server/appServer.ts) mounts
`mcpAuthMetadataRouter` from `@modelcontextprotocol/express`, which serves:

- **`/.well-known/oauth-protected-resource/mcp`** — protected-resource metadata
  for the `/mcp` resource: which authorization server protects it, the supported
  scopes (`juju:read/write/exec`), and `resourceName`. Public metadata carries
  **no workspace paths**.
- **`/.well-known/oauth-authorization-server`** — authorization-server metadata,
  mirroring the hand-built `oauthMetadata` object: `issuer`,
  `authorization_endpoint` (`/auth`), `token_endpoint` (`/token`), `jwks_uri`
  (`/jwks`), `revocation_endpoint` (`/token/revocation`),
  `code_challenge_methods_supported: ['S256']`, `response_types_supported:
  ['code']`, `grant_types_supported: ['authorization_code','refresh_token']`,
  `token_endpoint_auth_methods_supported: ['client_secret_basic',
  'client_secret_post','none']`, `scopes_supported` including
  `offline_access`, and `authorization_response_iss_parameter_supported: true`
  (RFC 9207: `oidc-provider` appends `iss=<issuer>` to every authorization
  response, success and error alike — AUTH-21 proves it. Clients such as ChatGPT
  use it to pick their stable OAuth callback instead of a per-connection one).
  No `registration_endpoint`: see §5.

The provider's own routes (`/auth`, `/token`, `/jwks`, `/token/revocation`,
`/interaction/*`) are mounted via `provider.callback()` and the interaction
router. **Your tunnel must route all of these paths, not just `/mcp`** — forward
only `/mcp` and discovery/callback break.

**The 401 challenge.** `/mcp` is wrapped in `requireBearerAuth({ verifier,
resourceMetadataUrl })`. A call with no/invalid token gets `401` with a
`WWW-Authenticate: Bearer …, resource_metadata="…/.well-known/oauth-protected-resource/mcp"`
header (built via `getOAuthProtectedResourceMetadataUrl`), which is exactly how a
compliant MCP client discovers where to authenticate.

**Locked mode** is different: with no public URL, `/mcp` returns a `401` whose
body is `{ "error": "setup_required", … }` and whose `WWW-Authenticate` says
`error="invalid_token", error_description="server setup incomplete"`, pointing
the operator at `juju init --public-url …`. No auth stack, admin surface, or
discovery is mounted in this mode; everything else 404s.

---

## 4. Token validation on every call

Every MCP request is verified by [verifier.ts](../src/auth/verifier.ts)
(`verifyAccessToken`). The checks, in order:

1. **Signature + standard claims** via `jose.jwtVerify` against the local JWKS:
   `issuer` must equal the configured issuer, `audience` must equal the **exact**
   resource URL (`…/mcp`), the algorithm must be **ES256**, and the token type
   must be `at+jwt`. Expiry (`exp`) is enforced by `jwtVerify`.
2. **Required custom claims present:** `juju_grant` (grant id), `juju_ws`
   (workspace id), `client_id`, and a numeric `exp`. Any missing/malformed → the
   generic `invalid access token`.
3. **Live grant / revocation recheck:** `store.getGrant(juju_grant)` must exist
   and have `revokedAt === null`. This is a **fresh lookup in the durable store on
   every call**, so a revoked grant is rejected **immediately** — well before the
   ~10-minute access token would have expired on its own.
4. **Workspace authorization:** identity-v2 grants require both token
   `juju_auth: 2` and the durable v2 grant marker. Their scopes are intersected
   with the current workspace/client ACL on every request; a new path is
   denied until locally granted. Legacy grants require both the grant's
   `workspaceId` and token `juju_ws` to equal the active workspace. Refusal
   deliberately does not disclose the current root.

The `juju_ws` (and `juju_grant`) claims are injected at mint time by
`extraTokenClaims` in [provider.ts](../src/auth/provider.ts), sourced from the
**app-level grant record**, not from the currently running server — so a token
refreshed after the owner switched roots retains its original workspace claim
and grant version. Legacy tokens are rejected on another root; identity-v2
tokens are subject to that root's separate ACL. On success the verifier returns the `AuthInfo` (client id,
scopes, `expiresAt`, resource, and `grantId`/`workspaceId`/`sub` extras) that the
tool layer intersects with local trust-mode policy.

---

## 5. Static client registration (baseline)

The v1 baseline is **static OAuth client pre-registration** by the local owner
([clients.ts](../src/auth/clients.ts)). The owner copies the **exact** redirect
URI the client's UI displays and registers it with `juju auth add-client`.
Registrations live in SQLite and are resolved live by the `Client` model in
[adapter.ts](../src/auth/adapter.ts), so new clients work **without restarting**
the provider.

Redirect-URI rules enforced at registration:

- Must be a valid absolute URL.
- Must be **`https://`**, except `localhost` / `127.0.0.1` for local testing.
- **No wildcards** (`*` is rejected).
- **No URL fragment.**

Matching at authorize time is **exact** — `oidc-provider` compares the incoming
`redirect_uri` against the registered strings verbatim. Clients are **public**
(`token_endpoint_auth_method: 'none'`, PKCE only) or **confidential**
(`client_secret_basic`, a 256-bit secret printed **once** at registration and
never shown again).

**Why DCR is off and CIMD is deferred.** Dynamic Client Registration
(`features.registration`) is **disabled** in [provider.ts](../src/auth/provider.ts):
current MCP guidance deprecates anonymous DCR endpoints, and an open registration
surface that accepts anything is a liability. Client ID Metadata Documents (CIMD)
are the recommended direction, but the provider feature is still experimental;
per spec §8.3 it stays off until it passes JUJU's compatibility/security tests
with a locked exact dependency and an owner-chosen metadata-origin allowlist
(to prevent SSRF / redirect / private-IP fetches). Until then, static
registration is the whole story.

---

## 6. Grants and tokens

- **Scopes:** `juju:read`, `juju:write`, `juju:exec`. Effective authority is the
  **intersection** of token scope, workspace binding, and local trust-mode policy
  — a token scope is a ceiling, not a grant of power on its own.
- **Access token:** JWT (`at+jwt`), **ES256**, audience `…/mcp`, TTL **600 s
  (~10 min)** (`ttl.AccessToken`, also reflected in `resourceIndicators`).
- **Authorization code:** 60 s, single-use.
- **Refresh token:** issued when `offline_access` is granted; **rotated on every
  use** (`rotateRefreshToken: true`), with **reuse detection provided by the
  library** — replaying a rotated (already-consumed) refresh token triggers
  `oidc-provider`'s reuse handling and drops the family. Refresh/grant lifetime is
  30 days.
- **Consent reuse:** `loadExistingGrant` reuses the grant the owner already
  approved for **this client and this workspace**, so a refresh or a repeat
  authorize does not re-prompt on the terminal. A grant bound to a **different
  workspace is filtered out**, forcing a fresh owner approval (spec §6, AUTH-07).
- **Revocation is immediate.** `juju auth revoke <grantId>` (and the
  `/token/revocation` endpoint) calls `store.revokeGrant` **and**
  `oauthRevokeByGrantId`, so both the durable grant row and the provider's tokens
  die at once. Because §4 rechecks grant state on every call, in-flight access
  tokens stop working on the very next request.

---

## 7. Owner CLI reference

All owner actions go through the workspace's private IPC socket (except
`add-client` / `clients`, which write the DB directly). Run them **on the machine
running JUJU**.

### `juju auth add-client`

```
$ juju auth add-client --redirect-uri https://chatgpt.com/connector_platform_oauth_redirect --name "ChatGPT"
Registered OAuth client (shown ONCE — store it in your client config now):
  client_id:     juju-client-2f9a1c4b7e8d
  client_secret: 8Qw…  (omitted for --public clients)
  redirect URIs: https://chatgpt.com/connector_platform_oauth_redirect
  auth method:   client_secret_basic
```

Pass `--redirect-uri` once per callback (repeatable), `--public` for a PKCE-only
client with no secret, `--name` for a label. Copy the **exact** callback from the
client's UI.

### `juju auth pending` — verify a live authorization request

```
$ juju auth pending
id:       f3q9…                          ← same value shown in the browser
phrase:   amber-cedar-nova   ← must match the browser page
client:   juju-client-2f9a1c4b7e8d
callback: https://chatgpt.com/connector_platform_oauth_redirect
scopes:   juju:read juju:write juju:exec
grants:   workspace /Users/me/project
approve:  juju auth approve -- f3q9…
```

Compare **id**, **phrase**, **client**, and **callback** against the browser page
before approving.

### `juju auth approve -- <id>` / `juju auth deny -- <id>`

```
$ juju auth approve -- f3q9…
approved: f3q9…
The browser page will now finish the sign-in automatically.

$ juju auth deny -- f3q9…
denied: f3q9…
```

Always keep `--` before the ID: provider-generated IDs may start with a hyphen.
Without the separator, Commander treats such an ID as an option and rejects
the command before it reaches JUJU. This syntax already works in 0.6.2;
quoting the ID alone does not fix option parsing. If the request expired while
troubleshooting, start a new login and review the fresh ID and phrase.

### `juju auth list` — grants (no tokens are ever printed)

```
$ juju auth list
g_7x…  client=juju-client-2f9a1c4b7e8d  scopes=juju:read,juju:write,juju:exec  (this workspace)
g_2a…  client=juju-client-9911aa22bb33  scopes=juju:read                        (other workspace)
```

### `juju auth revoke <grantId>`

```
$ juju auth revoke g_7x…
revoked: g_7x…
```

`juju auth clients` lists registered clients (`client_id  name  → redirect URIs`).
Note the two "list" verbs: **`auth list`** shows _grants_, **`auth clients`** shows
_client registrations_.

---

## 8. Security notes

- **Tokens are never logged and never leave the process.** The verifier throws a
  generic `invalid access token` (no token material), error rendering escapes and
  never prints tokens, and per spec §8.5 access tokens are **not passed to child
  processes** — jobs run with a scrubbed environment (`JUJU_CONFIG_DIR` is not
  inherited; there is no token pass-through to any subprocess or upstream API).
- **The issuer is never derived from request headers.** It comes solely from the
  configured `--public-url`. `app.set('trust proxy', 'loopback')` and
  `provider.proxy = true` trust the local tunnel's `X-Forwarded-Proto` for the
  _protocol_ only (so secure-cookie/HTTPS checks reflect the public origin), never
  for identity. `Host` is validated against an allowlist (loopback + configured
  hosts + the issuer hostname) **before the provider sees the request**, and
  `Origin` is validated too (`Origin: null` denied by default).
- **The local IPC socket is the owner-authentication mechanism.** It is a Unix
  socket created `0600` inside the `0700` config dir
  ([ipc/server.ts](../src/ipc/server.ts), [config/paths.ts](../src/config/paths.ts)),
  carrying newline-delimited JSON ([ipc/protocol.ts](../src/ipc/protocol.ts)). It
  is the **only** channel for consent, action approvals, trust changes, and stop —
  no admin surface is ever mounted on the public port. Its **trust boundary is
  same-OS-user**: any process running as the owner's UID can reach it, and that is
  the documented single-owner assumption. Reaching JUJU from loopback (as all
  tunnel traffic does) is **not** authentication and never bypasses the token or
  the terminal approval.
- **Signing keys are persistent and least-privileged.** ES256 P-256 keys and
  cookie keys are generated once and stored `0600`
  ([keys.ts](../src/auth/keys.ts)); a corrupt key store refuses to
  silently regenerate (deleting it invalidates every issued token). Encryption at
  rest does not defend against a same-UID process — that is the same trust
  boundary as the socket.

---

## 9. Using with ChatGPT

JUJU speaks standard OAuth 2.1 + PKCE, so a ChatGPT MCP/connector that supports
the authorization-code flow can connect to your public origin. The end-to-end
path — stand up your own tunnel to `127.0.0.1:<port>` (routing **all** paths,
not just `/mcp`), `juju auth add-client` with the exact callback ChatGPT shows,
start the server, and complete one browser+terminal consent — is captured in the
manual acceptance runbook: **[docs/MANUAL_ACCEPTANCE.md](./MANUAL_ACCEPTANCE.md)**.

Two caveats:

- **The exact ChatGPT menu / plan / feature that exposes custom MCP connectors is
  a manual gate**, not something JUJU can assert or automate. It changes over time
  and varies by plan, so the runbook records it as a **manually verified** step
  (`MANUAL_PASS` / `MANUAL_NOT_RUN`), never auto-marked as passing. Passing the
  local MCP Inspector is **not** the same as passing ChatGPT over a real tunnel.
- ChatGPT authenticates via the **OAuth flow only.** Do not assume you can set an
  arbitrary API-key or service-token header; JUJU issues no such credential and
  accepts no bearer token it did not mint (spec §2.3, §8.3).

## Stop/restart without clearing login (0.4.2)

`juju kill` is a local lifecycle command, not OAuth revocation. It preserves
the installation DB, signing/cookie keys, client registration, grants and path
ACLs. Restart with the same config directory and public issuer/resource URL;
existing valid access tokens and refresh credentials can continue to work.
Expired/revoked refresh grants still require authorization; no lifecycle command
can restore them. A new workspace needs its own existing local client ACL.

The local fixture exercises real OAuth token reuse and refresh after kill and
restart. It does not prove that the ChatGPT UI automatically retries every
interrupted connection. While JUJU is stopped, the connector endpoint is offline.
On restart the AI must fetch project_overview for the new epoch. A stdio client
must spawn a new subprocess after termination (there is no OAuth login there).

## stdio mode has no OAuth

`juju stdio` serves a *local* client over stdin/stdout. There is no bearer
token: the client process was started by the same OS user, so it is the owner's
principal. Nothing in this document applies to that mode except trust modes and
approvals, which are transport-independent. The HTTP server never grants a
local principal.

## Identity and workspace permission (0.6.0)

Valid identity tokens may authenticate with zero effective workspace scopes.
They can negotiate MCP/static metadata, but operational tools return
WORKSPACE_ACCESS_REQUIRED with no workspace context. A missing path ACL does
not itself trigger an invalid_token challenge. Invalid/expired tokens, wrong
audiences and revoked OAuth grants still fail with 401.

Once the local owner grants path access, the client calls project_overview
directly. Other tools still require its workspace ID/epoch and their own scope,
trust/action and Desktop checks. STDIO uses its local principal and the same
policy gates. There is no per-chat permission layer or chat identifier input.
Restarting changes the epoch but preserves OAuth state; legacy root-bound grants
retain their original constraints. See [upgrade notes](CONFIRMATION.md).

Schedules retain separate immutable local approval; see [SCHEDULES](SCHEDULES.md).

## Choosing a client in Local Config (0.6.1)

Client access shows only clients with permission for the active folder. To reuse
an existing OAuth registration on a new folder, expand “เพิ่ม client ให้โฟลเดอร์นี้”,
select the exact client ID, choose scopes and save. A collapsed/empty list does
not mean the OAuth registration was lost. Revoking all scopes removes the card
only from that folder; it does not revoke the installation-wide OAuth login or
other workspace access. Listing never adds permissions automatically.

## Delete an unused OAuth registration (0.6.2)

Private Local Config > Client access > “ล้าง client ที่ไม่ใช้” loads a separate
installation-wide list. Select one or more exact IDs (up to 100), review the
confirmation and delete. Nothing is selected automatically. “เลือกทั้งหมดในรายการนี้”
selects only the displayed snapshot; later registrations are never swept in.

This removes each selected registration and all of its workspace ACLs, revokes
application grants, purges associated provider grant/code/refresh/interaction
models and denies pending/approved OAuth/action approvals. New resource requests
check registration and live grant state; access and refresh credentials of the
deleted client are refused. Existing in-flight operations/jobs can finish.

The affected external connection must register/authorize again if needed; there
is no restore button for a deleted client. Other clients, signing keys, public
origin, project files and audit history remain. Revoked grant rows are retained
for audit; this is logical deletion, not secure erasure of database pages/backups.
For current-folder-only removal, keep using “ถอนสิทธิ์ทั้งหมด” on the client card.
