mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(tokens): retire the generic API-token mint
POST /api/tokens minted ["medication:ingest"], and that token could never do the job the settings copy advertised: /api/ingest/medication gates on the per-medication medication:<id>:ingest grant as well, which this endpoint never issued. It was refused at the ingest surface and admitted everywhere else. With Bearer scope now fail-closed the token is inert rather than dangerous, which is a worse thing to hand a user than the bug it replaces. Remove the mint. The working credential comes from the per-medication API-endpoint toggle, which issues both grants scoped to one medication. List and revoke stay so existing tokens remain visible and revocable, and the settings card now points at the per-medication toggle instead of offering a name field. Six locales updated.
This commit is contained in:
1 parent
df611227f9
commit
8012aa095b
10 files changed
+232
-314
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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:<id>: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<string, string> = {
|
||||
// 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(
|
||||
/(?<!function\s)requireAuth\(\s*([^)\s][^)]*)\)/g,
|
||||
)) {
|
||||
const arg = m[1].trim();
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(arg)) {
|
||||
offenders.push(`${rel}: requireAuth(${arg})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the declared scope vocabulary is closed", () => {
|
||||
// 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<string>();
|
||||
for (const rel of sourceFiles()) {
|
||||
for (const m of read(rel).matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
}
|
||||
}
|
||||
expect([...used].sort()).toEqual(DECLARED_SCOPES);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/tokens.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
+15
-80
@@ -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:<id>: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);
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tokenMsg, setTokenMsg] = useState<string | null>(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() {
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{/* 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. */}
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("settings.tokenNamePlaceholder")}
|
||||
maxLength={100}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="h-11 sm:h-10"
|
||||
disabled={creating || !newName.trim()}
|
||||
>
|
||||
{creating && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
<div
|
||||
className="bg-success/10 rounded-lg p-3 text-sm"
|
||||
data-slot="settings-api-token-created"
|
||||
>
|
||||
<p className="text-success mb-1 font-medium">
|
||||
{t("settings.tokenCreated")}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{newToken}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => void handleCopyToken()}
|
||||
aria-label={t("settings.tokenCopy")}
|
||||
data-slot="settings-api-token-copy"
|
||||
>
|
||||
{tokenCopied ? (
|
||||
<Check className="text-success h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenMsg && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{tokenMsg}
|
||||
</p>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.tokenMintMovedDescription")}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
|
||||
Reference in new issue
Block a user