diff --git a/messages/de.json b/messages/de.json index c638bd350..12a12c74c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5177,7 +5177,8 @@ "resumeError": "Wieder verbinden fehlgeschlagen" }, "apiTokens": "API-Tokens", - "apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).", + "apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.", + "tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.", "tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)", "tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.", "tokenCopy": "Token kopieren", diff --git a/messages/en.json b/messages/en.json index 6ea7a84ed..3f4f70771 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5177,7 +5177,8 @@ "resumeError": "Reconnect failed" }, "apiTokens": "API Tokens", - "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", + "apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.", + "tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.", "tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)", "tokenCreated": "Token created — copy it now! It won't be shown again.", "tokenCopy": "Copy token", diff --git a/messages/es.json b/messages/es.json index a9b18f535..8d7e0c8fb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5177,7 +5177,8 @@ "resumeError": "Reconexión fallida" }, "apiTokens": "Tokens de API", - "apiTokensDescription": "Crea tokens de API para registrar tomas de medicación externas (p. ej., Atajos, automatizaciones).", + "apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.", + "tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.", "tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)", "tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.", "tokenCopy": "Copiar token", diff --git a/messages/fr.json b/messages/fr.json index 409c462fe..cd66a28a8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5177,7 +5177,8 @@ "resumeError": "Échec de la reconnexion" }, "apiTokens": "Jetons API", - "apiTokensDescription": "Créez des jetons API pour enregistrer des prises de médicaments externes (par exemple Raccourcis, automatisations).", + "apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.", + "tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.", "tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)", "tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.", "tokenCopy": "Copier le jeton", diff --git a/messages/it.json b/messages/it.json index f2a132f72..303022426 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5177,7 +5177,8 @@ "resumeError": "Riconnessione fallita" }, "apiTokens": "Token API", - "apiTokensDescription": "Crea token API per registrare assunzioni di farmaci esterne (ad es. Comandi rapidi, automazioni).", + "apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.", + "tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.", "tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)", "tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.", "tokenCopy": "Copia token", diff --git a/messages/pl.json b/messages/pl.json index c64233093..22cdd6c6f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5177,7 +5177,8 @@ "resumeError": "Ponowne połączenie nieudane" }, "apiTokens": "Tokeny API", - "apiTokensDescription": "Twórz tokeny API do zewnętrznego rejestrowania przyjęć leków (np. Skróty, automatyzacje).", + "apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.", + "tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.", "tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)", "tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.", "tokenCopy": "Kopiuj token", diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts new file mode 100644 index 000000000..21c938fdc --- /dev/null +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -0,0 +1,195 @@ +/** + * Structural guards on the Bearer-scope enforcement boundary. + * + * The correctness argument for the fail-closed default is structural, not + * empirical: there is exactly ONE place a raw Bearer token becomes a user, and + * exactly ONE authorisation arm inside it. Seven behavioural tests cannot cover + * 300-odd routes; what they can do is rest on that invariant. These guards are + * what keep the invariant true. + * + * They are tripwires, not proofs. They cannot show an allowlist is correct — + * only that it has not changed without someone editing this file. A reviewer + * who waves through a bad addition defeats all four, and no test substitutes + * for that review. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +const SRC = join(process.cwd(), "src"); + +/** + * Every non-test `.ts` / `.tsx` under `src/`, excluding the generated Prisma + * client (9 MB; never read it) and test files themselves. + */ +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .filter( + (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function filesMatching(re: RegExp): string[] { + return sourceFiles().filter((rel) => re.test(read(rel))); +} + +describe("T1 — the Bearer resolution set is frozen", () => { + /** + * Files allowed to turn a raw `Authorization: Bearer` value into a user. + * This is the set that can circumvent the enforcement point, so it is the + * only set that matters for the fail-closed guarantee. + * + * Two of these authenticate a request; the rest are the resolver itself and + * the two edges that wrap it. + */ + const RESOLUTION_ALLOWLIST = [ + // The resolver. Owns the one authorisation arm. + "lib/auth/bearer.ts", + // The REST edge — maps `requireAuth`'s optional scope onto the union. + "lib/api-handler.ts", + // The MCP edge — the one deliberate `any-valid-token` posture. + "lib/mcp/auth.ts", + // The `/mcp` transport, via `resolveMcpAuthContext`. + "app/mcp/route.ts", + // Hand-rolled Bearer authentication: the external medication-ingest + // surface, which gates on BOTH `medication:ingest` and the per-medication + // `medication::ingest` grant and never touches `requireAuth`. + "app/api/ingest/medication/route.ts", + ].sort(); + + it("no file outside the allowlist resolves a Bearer token to a user", () => { + // `resolveBearerToken` is the shared primitive; a `tokenHash` lookup is the + // hand-rolled equivalent. Either one turns a bearer credential into an + // identity, so both have to stay inside the allowlist. + const resolvers = filesMatching( + /resolveBearerToken|apiToken\.findUnique\([\s\S]*?where:\s*\{\s*tokenHash/, + ); + + expect(resolvers).toEqual(RESOLUTION_ALLOWLIST); + }); + + it("the resolver exposes exactly one authorisation arm", () => { + const src = read("lib/auth/bearer.ts"); + // One wildcard escape hatch, guarding one deny block. If a second + // `permissions.includes("*")` short-circuit appears, the arm has forked. + const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? []; + expect(wildcardChecks).toHaveLength(1); + }); +}); + +describe("T2 — `any-valid-token` is the single deliberate fail-open posture", () => { + it("appears at exactly one call site, and that call site is the MCP edge", () => { + // `bearer.ts` necessarily names the variant to declare the union; what + // must stay unique is a caller PASSING it. + const optedOut = filesMatching(/kind:\s*"any-valid-token"/).filter( + (rel) => rel !== "lib/auth/bearer.ts", + ); + expect(optedOut).toEqual(["lib/mcp/auth.ts"]); + }); + + it("no route file opts out of the fail-closed default", () => { + const routes = sourceFiles().filter((p) => p.endsWith("/route.ts")); + const optedOut = routes.filter((rel) => + /kind:\s*"any-valid-token"/.test(read(rel)), + ); + expect(optedOut).toEqual([]); + }); +}); + +describe("T3 — the mint sites are frozen", () => { + /** + * Every place an `ApiToken` row is created, with the scope set it may mint. + * A new mint site, or a new scope on an existing one, has to be named here — + * which is the point: a user-facing mint that hands out a broad scope is the + * one failure mode the enforcement change cannot catch by itself. + */ + const MINT_SITES: Record = { + // Cookie-equivalent. The ONLY `["*"]` mints, all behind a completed login. + "lib/auth/issue-token.ts": 'opts.permissions ?? ["*"]', + "lib/auth/login-response.ts": '["*"]', + "lib/auth/refresh-token.ts": '["*"]', + "app/api/auth/passkey/login-verify/route.ts": '["*"]', + // The working medication-ingest pair — family marker plus the per- + // medication grant that `/api/ingest/medication` actually gates on. + "app/api/medications/[id]/api-endpoint/route.ts": + '["medication:ingest", scope]', + // MCP, audience-bound to `/mcp`. `health:write` requires explicit consent. + "app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE", + "app/api/mcp/oauth/token/route.ts": + "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE", + }; + + it("only the known files create ApiToken rows", () => { + const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter( + // `issue-token.ts` defines the helper; the others call it. + (rel) => rel !== "lib/auth/issue-token.ts" || true, + ); + expect(minters).toEqual(Object.keys(MINT_SITES).sort()); + }); + + it("no user-facing mint hands out a wildcard scope", () => { + // The four `["*"]` mints are all reached only by a completed + // password / passkey / refresh exchange. Every other mint is narrow. + const userFacing = [ + "app/api/medications/[id]/api-endpoint/route.ts", + "app/api/mcp/tokens/route.ts", + "app/api/mcp/oauth/token/route.ts", + ]; + for (const rel of userFacing) { + expect(read(rel)).not.toMatch(/permissions:\s*\[\s*"\*"/); + } + }); + + it("the retired generic token mint is gone", () => { + // `POST /api/tokens` minted `["medication:ingest"]` — a token that could + // not perform its advertised job (it lacked the per-medication grant) and + // could reach every other authenticated route. List and revoke stay. + const tokensRoute = read("app/api/tokens/route.ts"); + expect(tokensRoute).not.toMatch(/export const POST/); + expect(tokensRoute).toMatch(/export const GET/); + }); +}); + +describe("T4 — declared scopes are exported constants, never string literals", () => { + it("every requireAuth argument is an identifier", () => { + const offenders: string[] = []; + for (const rel of sourceFiles()) { + const src = read(rel); + // Call sites only — skip the declaration in `api-handler.ts`, whose + // parameter list is not an argument. + for (const m of src.matchAll( + /(? { + // Scopes a route may name. Adding one means adding a route that accepts a + // narrow token, which is a widening — it belongs in a reviewed diff. + const DECLARED_SCOPES = ["FHIR_READ_SCOPE"]; + const used = new Set(); + for (const rel of sourceFiles()) { + for (const m of read(rel).matchAll( + /(? ({ - prisma: { - apiToken: { - findMany: vi.fn(), - create: vi.fn(), - }, - auditLog: { create: vi.fn() }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); -vi.mock("@/lib/app-settings", () => ({ - isApiGloballyEnabled: vi.fn(), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { isApiGloballyEnabled } from "@/lib/app-settings"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "tester", role: "USER" as const }, -}; - -function postReq(body: unknown): NextRequest { - return new NextRequest("http://localhost/api/tokens", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); - vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); -}); - -describe("POST /api/tokens — 422 multi-issue (v1.4.43 W6)", () => { - it("surfaces TWO simultaneous validation errors", async () => { - // Empty name (min 1) + `expiresInDays` above max (365). - const res = await POST(postReq({ name: "", expiresInDays: 9999 })); - expect(res.status).toBe(422); - const body = (await res.json()) as { - data: null; - error: string; - details: { - issues: Array<{ path: string; code: string; message: string }>; - }; - }; - expect(body.data).toBeNull(); - expect(body.error).toBe("Validation failed"); - expect(body.details.issues.length).toBeGreaterThanOrEqual(2); - for (const issue of body.details.issues) { - expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); - } - }); - - it("surfaces THREE simultaneous validation errors", async () => { - // Missing name + `expiresInDays` not a number + `expiresInDays` below min - // is hard since name and expiresInDays are the only knobs, so we use a - // name that is too long (max 100) + expiresInDays as a string and as - // a fraction (non-int). - const res = await POST( - postReq({ - name: "x".repeat(200), - expiresInDays: "string", - // any extra unknown key Zod will silently strip; force a 3rd issue - // by including a stray nested shape that the schema doesn't accept. - }), - ); - expect(res.status).toBe(422); - const body = (await res.json()) as { - details: { issues: Array<{ path: string; code: string }> }; - }; - // name too long + expiresInDays not a number = 2 issues at least. - expect(body.details.issues.length).toBeGreaterThanOrEqual(2); - }); - - it("writes a tokens.create.validation-failed audit row", async () => { - const res = await POST(postReq({ name: "", expiresInDays: 9999 })); - expect(res.status).toBe(422); - await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { userId: string; action: string }; - }; - expect(call.data.userId).toBe("user-1"); - expect(call.data.action).toBe("tokens.create.validation-failed"); - }); - - it("does not block the 422 when the audit-row write rejects", async () => { - vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( - new Error("db down"), - ); - const res = await POST(postReq({ name: "", expiresInDays: 9999 })); - expect(res.status).toBe(422); - }); -}); diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index cde3bc73a..541e0bf02 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -1,23 +1,22 @@ +/** + * `/api/tokens` — list and (via `[id]`) revoke a user's API tokens. + * + * The generic mint that used to live here (`POST`, minting + * `["medication:ingest"]`) was removed alongside the fail-closed scope + * default. That token could never do its advertised job — the external ingest + * surface gates on the per-medication `medication::ingest` grant, which + * this endpoint never issued — while the old fail-open default let it reach + * every other authenticated route. The working credential is minted by the + * per-medication API-endpoint toggle + * (`/api/medications/[id]/api-endpoint`), which issues both grants. + * + * Listing and revoking stay so existing tokens remain visible and revocable. + */ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiSuccess, - apiError, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; -import { hashToken } from "@/lib/auth/hmac"; +import { apiSuccess, apiError } from "@/lib/api-response"; import { isApiGloballyEnabled } from "@/lib/app-settings"; -import { randomBytes } from "node:crypto"; -import { NextRequest } from "next/server"; -import { z } from "zod/v4"; - -const createTokenSchema = z.object({ - name: z.string().min(1, "Name required").max(100), - expiresInDays: z.number().int().min(1).max(365).optional(), -}); export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -43,67 +42,3 @@ export const GET = apiHandler(async () => { return apiSuccess(tokens); }); - -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "tokens.create" } }); - - if (!(await isApiGloballyEnabled())) { - return apiError("API is globally disabled", 403); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - - if (jsonError) return jsonError; - const parsed = createTokenSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422 + audit-ledger breadcrumb so an - // operator can grep `tokens.create.validation-failed` when a - // device sends a malformed mint payload. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "tokens.create.validation-failed" }, - meta: { issue_count: issues.length }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; the - // mint payload carries a free-text `name`. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "tokens.create.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } - - const { name, expiresInDays } = parsed.data; - - // Generate a random token - const rawToken = `hlk_${randomBytes(32).toString("hex")}`; - const tokenHashValue = hashToken(rawToken); - - await prisma.apiToken.create({ - data: { - userId: user.id, - name, - tokenHash: tokenHashValue, - permissions: ["medication:ingest"], - expiresAt: expiresInDays - ? new Date(Date.now() + expiresInDays * 86400000) - : null, - }, - }); - - // Return the raw token ONCE — it can never be retrieved again - return apiSuccess({ token: rawToken, name }, 201); -}); diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 7e930bd03..b86dd1933 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, ChevronDown, Copy, Key, Loader2, Trash2 } from "lucide-react"; +import { ChevronDown, Key, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { @@ -18,7 +18,6 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -149,12 +148,7 @@ function ApiTokensCard() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); - const [newName, setNewName] = useState(""); - const [newToken, setNewToken] = useState(null); - const [creating, setCreating] = useState(false); - const [tokenMsg, setTokenMsg] = useState(null); const [showRevokedTokens, setShowRevokedTokens] = useState(false); - const [tokenCopied, setTokenCopied] = useState(false); const { data: tokens } = useQuery({ queryKey: queryKeys.tokens(), @@ -164,47 +158,6 @@ function ApiTokensCard() { enabled: isAuthenticated, }); - async function handleCreate(e: React.FormEvent) { - e.preventDefault(); - setCreating(true); - setTokenMsg(null); - setNewToken(null); - - try { - const res = await apiFetchRaw("/api/tokens", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: newName.trim() }), - }); - const json = await res.json(); - if (res.ok) { - setNewToken(json.data.token); - setNewName(""); - queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); - } else { - setTokenMsg(json.error || t("common.error")); - } - } catch { - setTokenMsg(t("common.networkError")); - } finally { - setCreating(false); - } - } - - async function handleCopyToken() { - if (!newToken) return; - try { - await navigator.clipboard.writeText(newToken); - setTokenCopied(true); - toast.success(t("settings.tokenCopied")); - // Revert the inline check affordance after a short beat so a - // second copy reads as a fresh action. - setTimeout(() => setTokenCopied(false), 2_000); - } catch { - toast.error(t("settings.tokenCopyFailed")); - } - } - async function handleRevoke(tokenId: string) { try { const res = await apiFetchRaw(`/api/tokens/${tokenId}`, { @@ -254,66 +207,15 @@ function ApiTokensCard() { />
- {/* The button mirrors the Input's responsive height (h-11 / sm:h-10) - so the row reads as one aligned control group instead of a tall - input next to a shorter `sm` button. */} -
- setNewName(e.target.value)} - placeholder={t("settings.tokenNamePlaceholder")} - maxLength={100} - className="flex-1" - /> - -
- - {newToken && ( -
-

- {t("settings.tokenCreated")} -

-
- - {newToken} - - -
-
- )} - - {tokenMsg && ( -

- {tokenMsg} -

- )} + {/* Tokens are no longer minted here. The generic mint issued a + `medication:ingest` token that the ingest endpoint refused (it + gates on the per-medication grant too) and that every other + authenticated route accepted. The per-medication API-endpoint + toggle issues the pair that actually works, scoped to one + medication. This card lists and revokes. */} +

+ {t("settings.tokenMintMovedDescription")} +