feat: REST API v1 + API key management + Swagger docs

**API system**
- ApiKey model: SHA-256-hashed tokens (gw_<hex>), per-family, scoped
- Migration: 20260615210000_api_keys
- src/lib/api-auth.ts: verifyApiKey(), hasScope(), generateApiKey(), prepareKey()

**V1 endpoints** (all require Bearer gw_ token):
- GET  /api/v1/babies              — list family babies (any read scope)
- GET  /api/v1/events              — query events (events:read), babyId/type/from/to/limit/offset
- POST /api/v1/events              — log event (events:write), full metadata support
- GET  /api/v1/growth              — growth logs (growth:read)
- POST /api/v1/growth              — add measurement (growth:write), weight in grams
- GET  /api/v1/summary             — today's counts + sleep + last feed (summary:read)
- GET  /api/v1/milk                — stock lots + totalMl (milk:read)

**API key management**
- GET  /api/api-keys               — list keys (session auth)
- POST /api/api-keys               — create key, returns raw token once (session auth)
- DELETE /api/api-keys/[id]        — revoke key (session auth)

**Documentation**
- GET /api/v1/openapi.json         — OpenAPI 3.0 spec (CORS open)
- GET /api-docs                    — Swagger UI (CDN, dark themed)

**Settings UI** — "Clés API" section: create key with scope checkboxes, copy token banner (shown once), revoke, link to /api-docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:33:25 +02:00
parent 009090b381
commit f59a6c5d8a
13 changed files with 886 additions and 1 deletions
+68
View File
@@ -0,0 +1,68 @@
import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
export const API_SCOPES = [
"events:read",
"events:write",
"growth:read",
"growth:write",
"milk:read",
"summary:read",
] as const;
export type ApiScope = typeof API_SCOPES[number];
export interface ApiContext {
familyId: string;
scopes: string[];
keyId: string;
}
function hashKey(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function generateApiKey(): string {
const arr = new Uint8Array(32);
// Use crypto.getRandomValues if available (edge), else randomBytes
if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.getRandomValues) {
globalThis.crypto.getRandomValues(arr);
return "gw_" + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
}
// Node.js fallback
const { randomBytes } = require("crypto") as typeof import("crypto");
return "gw_" + randomBytes(32).toString("hex");
}
export function keyPrefix(token: string): string {
return token.slice(0, 10);
}
export async function verifyApiKey(req: Request): Promise<ApiContext | null> {
const auth = req.headers.get("authorization");
if (!auth?.startsWith("Bearer gw_")) return null;
const token = auth.slice(7);
const hash = hashKey(token);
const key = await prisma.apiKey.findUnique({ where: { keyHash: hash } });
if (!key) return null;
// Fire-and-forget lastUsedAt update
prisma.apiKey.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
return { familyId: key.familyId, scopes: key.scopes, keyId: key.id };
}
export function hasScope(ctx: ApiContext, scope: ApiScope): boolean {
return ctx.scopes.includes(scope);
}
export function prepareKey(token: string, name: string, familyId: string, scopes: string[]) {
return {
familyId,
name,
keyHash: hashKey(token),
keyPrefix: keyPrefix(token),
scopes,
};
}