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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user