diff --git a/prisma/migrations/20260616000002_v0_12_api_tokens/migration.sql b/prisma/migrations/20260616000002_v0_12_api_tokens/migration.sql new file mode 100644 index 0000000..3236b88 --- /dev/null +++ b/prisma/migrations/20260616000002_v0_12_api_tokens/migration.sql @@ -0,0 +1,24 @@ +-- v0.12.0 — API tokens (personal access tokens for the REST API + MCP) + +-- CreateTable +CREATE TABLE "ApiToken" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "prefix" TEXT NOT NULL, + "scopes" TEXT[], + "userId" TEXT NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash"); +CREATE INDEX "ApiToken_userId_idx" ON "ApiToken"("userId"); + +-- AddForeignKey +ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 19354a2..8b94eac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,29 @@ model User { mustChangePassword Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + apiTokens ApiToken[] +} + +// --------------------------------------------------------------------------- +// API tokens — personal access tokens for the REST API + MCP server. +// Only the SHA-256 hash of the raw token is stored; the raw value (mb_…) is +// shown once at creation. `scopes` gate what a token can do (e.g. "plants:read"). +// --------------------------------------------------------------------------- + +model ApiToken { + id String @id @default(cuid()) + name String // human label ("Home Assistant", "phone shortcut") + tokenHash String @unique // sha256(raw) + prefix String // first 8 chars of the raw token, shown in the UI + scopes String[] // e.g. ["plants:read","items:write"]; ["*"] = full + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lastUsedAt DateTime? + expiresAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId]) } // --------------------------------------------------------------------------- diff --git a/src/app/(dashboard)/settings/tokens/page.tsx b/src/app/(dashboard)/settings/tokens/page.tsx new file mode 100644 index 0000000..8675506 --- /dev/null +++ b/src/app/(dashboard)/settings/tokens/page.tsx @@ -0,0 +1,18 @@ +import { ApiTokensPanel } from "@/components/settings/api-tokens-panel"; + +export const metadata = { title: "API tokens" }; + +export default function TokensPage() { + return ( +
+
+

API tokens

+

+ Personal access tokens for the Moon Base API — use them with the{" "} + Authorization: Bearer … header. +

+
+ +
+ ); +} diff --git a/src/app/api/account/tokens/[id]/route.ts b/src/app/api/account/tokens/[id]/route.ts new file mode 100644 index 0000000..d345a52 --- /dev/null +++ b/src/app/api/account/tokens/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { requireAuth } from "@/lib/auth"; + +// DELETE /api/account/tokens/:id — revoke one of your own tokens. +export async function DELETE(_req: Request, { params }: { params: { id: string } }) { + try { + const session = await requireAuth(); + const token = await db.apiToken.findUnique({ where: { id: params.id } }); + if (!token || token.userId !== session.user.id) + return NextResponse.json({ error: "Not found" }, { status: 404 }); + + await db.apiToken.update({ where: { id: params.id }, data: { revokedAt: new Date() } }); + await db.auditEvent.create({ + data: { + action: "api_token.revoke", + entityId: params.id, + actorId: session.user.id, + payload: { name: token.name }, + }, + }); + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof Error && err.message === "Unauthorized") + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/account/tokens/route.ts b/src/app/api/account/tokens/route.ts new file mode 100644 index 0000000..f6030ff --- /dev/null +++ b/src/app/api/account/tokens/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { requireAuth } from "@/lib/auth"; +import { + generateRawToken, hashToken, tokenPrefix, KNOWN_SCOPES, +} from "@/lib/api/authenticate"; + +const scopeSchema = z.union([z.literal("*"), z.enum(KNOWN_SCOPES)]); +const createSchema = z.object({ + name: z.string().min(1), + scopes: z.array(scopeSchema).min(1), + expiresAt: z.string().optional(), +}); + +// GET /api/account/tokens — list the current user's tokens (no secrets). +export async function GET() { + try { + const session = await requireAuth(); + const tokens = await db.apiToken.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + select: { + id: true, name: true, prefix: true, scopes: true, + lastUsedAt: true, expiresAt: true, revokedAt: true, createdAt: true, + }, + }); + return NextResponse.json(tokens); + } catch (err) { + if (err instanceof Error && err.message === "Unauthorized") + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} + +// POST /api/account/tokens — create a token. The raw value is returned ONCE. +export async function POST(req: Request) { + try { + const session = await requireAuth(); + const data = createSchema.parse(await req.json()); + + const raw = generateRawToken(); + const token = await db.apiToken.create({ + data: { + name: data.name.trim(), + tokenHash: hashToken(raw), + prefix: tokenPrefix(raw), + scopes: data.scopes, + userId: session.user.id, + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + }, + select: { id: true, name: true, prefix: true, scopes: true, expiresAt: true, createdAt: true }, + }); + + await db.auditEvent.create({ + data: { + action: "api_token.create", + entityId: token.id, + actorId: session.user.id, + payload: { name: token.name, scopes: token.scopes }, + }, + }); + + // `token` (raw) is shown to the user once and never stored in plaintext. + return NextResponse.json({ ...token, token: raw }, { status: 201 }); + } catch (err) { + if (err instanceof z.ZodError) + return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 }); + if (err instanceof Error && err.message === "Unauthorized") + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts index 24b4188..0a4204a 100644 --- a/src/app/api/admin/backup/route.ts +++ b/src/app/api/admin/backup/route.ts @@ -10,11 +10,12 @@ export async function GET() { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const [locations, plantTypes, users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] = + const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] = await Promise.all([ db.location.findMany(), db.plantType.findMany(), db.user.findMany(), + db.apiToken.findMany(), db.plantGroup.findMany(), db.plant.findMany(), db.plantLog.findMany(), @@ -26,7 +27,7 @@ export async function GET() { const manifest = { version: 1, exportedAt: new Date().toISOString(), - tables: ["locations", "plantTypes", "users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"], + tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"], }; const zip = zipSync({ @@ -34,6 +35,7 @@ export async function GET() { "locations.json": strToU8(JSON.stringify(locations, null, 2)), "plantTypes.json": strToU8(JSON.stringify(plantTypes, null, 2)), "users.json": strToU8(JSON.stringify(users, null, 2)), + "apiTokens.json": strToU8(JSON.stringify(apiTokens, null, 2)), "plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)), "plants.json": strToU8(JSON.stringify(plants, null, 2)), "plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)), diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts index 1568a4a..4275e0a 100644 --- a/src/app/api/admin/restore/route.ts +++ b/src/app/api/admin/restore/route.ts @@ -31,6 +31,7 @@ export async function POST(req: Request) { const locations = files["locations.json"] ? parse("locations.json") : []; const plantTypes = files["plantTypes.json"] ? parse("plantTypes.json") : []; const users = parse("users.json"); + const apiTokens = files["apiTokens.json"] ? parse("apiTokens.json") : []; const plantGroups = parse("plantGroups.json"); const plants = parse("plants.json"); const plantLogs = parse("plantLogs.json"); @@ -47,10 +48,12 @@ export async function POST(req: Request) { await tx.plantGroup.deleteMany(); await tx.plantType.deleteMany(); await tx.location.deleteMany(); + await tx.apiToken.deleteMany(); await tx.auditEvent.deleteMany(); await tx.user.deleteMany(); if (users.length) await tx.user.createMany({ data: users }); + if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens }); // Location self-references parentId; a single createMany is one statement, // so the FK is validated only after every row exists — order-independent. if (locations.length) await tx.location.createMany({ data: locations }); diff --git a/src/app/api/v1/locations/route.ts b/src/app/api/v1/locations/route.ts new file mode 100644 index 0000000..74b32db --- /dev/null +++ b/src/app/api/v1/locations/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { listLocations } from "@/lib/services/locations"; + +// GET /api/v1/locations +export async function GET(req: Request) { + try { + const ctx = await authenticateRequest(req, "locations:read"); + return NextResponse.json(await listLocations(ctx)); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/app/api/v1/plant-types/route.ts b/src/app/api/v1/plant-types/route.ts new file mode 100644 index 0000000..8331ed7 --- /dev/null +++ b/src/app/api/v1/plant-types/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { listPlantTypes } from "@/lib/services/plant-types"; + +// GET /api/v1/plant-types?q=lily +export async function GET(req: Request) { + try { + const ctx = await authenticateRequest(req, "plant-types:read"); + const q = new URL(req.url).searchParams.get("q") ?? undefined; + return NextResponse.json(await listPlantTypes(ctx, { q })); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/app/api/v1/plants/[id]/logs/route.ts b/src/app/api/v1/plants/[id]/logs/route.ts new file mode 100644 index 0000000..0669136 --- /dev/null +++ b/src/app/api/v1/plants/[id]/logs/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { plantLogInput } from "@/lib/api/schemas"; +import { addPlantLog } from "@/lib/services/plants"; + +// POST /api/v1/plants/:id/logs +export async function POST(req: Request, { params }: { params: { id: string } }) { + try { + const ctx = await authenticateRequest(req, "plants:write"); + const input = plantLogInput.parse(await req.json()); + const log = await addPlantLog(ctx, params.id, input); + return NextResponse.json(log, { status: 201 }); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/app/api/v1/plants/[id]/route.ts b/src/app/api/v1/plants/[id]/route.ts new file mode 100644 index 0000000..b96255a --- /dev/null +++ b/src/app/api/v1/plants/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { getPlant } from "@/lib/services/plants"; + +// GET /api/v1/plants/:id +export async function GET(req: Request, { params }: { params: { id: string } }) { + try { + const ctx = await authenticateRequest(req, "plants:read"); + return NextResponse.json(await getPlant(ctx, params.id)); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/app/api/v1/plants/route.ts b/src/app/api/v1/plants/route.ts new file mode 100644 index 0000000..0addcec --- /dev/null +++ b/src/app/api/v1/plants/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { listPlants } from "@/lib/services/plants"; + +// GET /api/v1/plants?q=apple +export async function GET(req: Request) { + try { + const ctx = await authenticateRequest(req, "plants:read"); + const q = new URL(req.url).searchParams.get("q") ?? undefined; + return NextResponse.json(await listPlants(ctx, { q })); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/app/api/v1/route.ts b/src/app/api/v1/route.ts new file mode 100644 index 0000000..0af6a25 --- /dev/null +++ b/src/app/api/v1/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; + +// GET /api/v1 — whoami + endpoint discovery. Handy for testing a token. +export async function GET(req: Request) { + try { + const ctx = await authenticateRequest(req); + return NextResponse.json({ + ok: true, + via: ctx.via, + scopes: ctx.scopes, + endpoints: { + plants: "GET /api/v1/plants?q=, GET /api/v1/plants/:id, POST /api/v1/plants/:id/logs", + plantTypes: "GET /api/v1/plant-types?q=", + locations: "GET /api/v1/locations", + tasks: "GET /api/v1/tasks, POST /api/v1/tasks, POST /api/v1/tasks/:id/complete", + }, + }); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/app/api/v1/tasks/[id]/complete/route.ts b/src/app/api/v1/tasks/[id]/complete/route.ts new file mode 100644 index 0000000..9b7b3ef --- /dev/null +++ b/src/app/api/v1/tasks/[id]/complete/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { taskCompleteInput } from "@/lib/api/schemas"; +import { completeTask } from "@/lib/services/tasks"; + +// POST /api/v1/tasks/:id/complete +export async function POST(req: Request, { params }: { params: { id: string } }) { + try { + const ctx = await authenticateRequest(req, "tasks:write"); + const input = taskCompleteInput.parse(await req.json().catch(() => ({}))); + return NextResponse.json(await completeTask(ctx, params.id, input)); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/app/api/v1/tasks/route.ts b/src/app/api/v1/tasks/route.ts new file mode 100644 index 0000000..54680c1 --- /dev/null +++ b/src/app/api/v1/tasks/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { taskCreateInput } from "@/lib/api/schemas"; +import { listTasks, createTask } from "@/lib/services/tasks"; + +// GET /api/v1/tasks +export async function GET(req: Request) { + try { + const ctx = await authenticateRequest(req, "tasks:read"); + return NextResponse.json(await listTasks(ctx)); + } catch (err) { + return handleApiError(err); + } +} + +// POST /api/v1/tasks +export async function POST(req: Request) { + try { + const ctx = await authenticateRequest(req, "tasks:write"); + const input = taskCreateInput.parse(await req.json()); + return NextResponse.json(await createTask(ctx, input), { status: 201 }); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 62f9a1b..325b8c4 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen } from "lucide-react"; +import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound } from "lucide-react"; import { cn } from "@/lib/utils"; const navItems = [ @@ -17,6 +17,7 @@ const navItems = [ const settingsItems = [ { label: "Suggestions", href: "/suggestions", icon: Lightbulb }, + { label: "API tokens", href: "/settings/tokens", icon: KeyRound }, { label: "Backup & Restore", href: "/settings/backup", icon: HardDrive }, { label: "Updates", href: "/settings/updates", icon: RefreshCw }, ]; diff --git a/src/components/settings/api-tokens-panel.tsx b/src/components/settings/api-tokens-panel.tsx new file mode 100644 index 0000000..f7117b2 --- /dev/null +++ b/src/components/settings/api-tokens-panel.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { Plus, Trash2, KeyRound, Copy, Check, AlertTriangle } from "lucide-react"; +import { SCOPE_RESOURCES } from "@/lib/api/scopes"; + +type Token = { + id: string; name: string; prefix: string; scopes: string[]; + lastUsedAt: string | null; expiresAt: string | null; revokedAt: string | null; createdAt: string; +}; + +function fmt(d: string | null) { + return d ? new Date(d).toLocaleDateString() : "—"; +} + +export function ApiTokensPanel() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [fullAccess, setFullAccess] = useState(false); + const [scopes, setScopes] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); + const { toast } = useToast(); + + async function load() { + setLoading(true); + const res = await fetch("/api/account/tokens"); + if (res.ok) setTokens(await res.json()); + setLoading(false); + } + useEffect(() => { load(); }, []); + + function toggleScope(s: string) { + setScopes((prev) => { + const next = new Set(prev); + next.has(s) ? next.delete(s) : next.add(s); + return next; + }); + } + + function resetForm() { + setName(""); setFullAccess(false); setScopes(new Set()); setNewToken(null); setCopied(false); + } + + async function create(e: React.FormEvent) { + e.preventDefault(); + const chosen = fullAccess ? ["*"] : Array.from(scopes); + if (!name.trim() || chosen.length === 0) { + toast({ title: "Add a name and at least one permission.", variant: "destructive" }); + return; + } + setBusy(true); + const res = await fetch("/api/account/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim(), scopes: chosen }), + }); + setBusy(false); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: "Error", description: err.error ?? "Could not create token.", variant: "destructive" }); + return; + } + const data = await res.json(); + setNewToken(data.token); + load(); + } + + async function revoke(id: string, label: string) { + if (!confirm(`Revoke "${label}"? Anything using it will stop working.`)) return; + const res = await fetch(`/api/account/tokens/${id}`, { method: "DELETE" }); + if (res.ok) { toast({ title: "Token revoked" }); load(); } + else toast({ title: "Error", description: "Could not revoke.", variant: "destructive" }); + } + + async function copy() { + if (!newToken) return; + await navigator.clipboard.writeText(newToken); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + + return ( +
+
+

+ Tokens let other apps (Home Assistant, phone shortcuts, scripts, Claude) read and update + your homestead over the API. Each token only gets the permissions you grant it. +

+ +
+ + {loading ? ( +

Loading…

+ ) : tokens.length === 0 ? ( +
+ +

No tokens yet.

+
+ ) : ( +
+ {tokens.map((t) => { + const expired = t.expiresAt && new Date(t.expiresAt) < new Date(); + const dead = !!t.revokedAt || expired; + return ( +
+ +
+
+ {t.name} + {t.prefix}… + {t.revokedAt && Revoked} + {expired && !t.revokedAt && Expired} +
+
+ {t.scopes.map((s) => {s})} +
+

+ Created {fmt(t.createdAt)} · Last used {fmt(t.lastUsedAt)} + {t.expiresAt && ` · Expires ${fmt(t.expiresAt)}`} +

+
+ {!dead && ( + + )} +
+ ); + })} +
+ )} + + { setOpen(v); if (!v) resetForm(); }}> + + {newToken ? ( + <> + + Copy your new token + + + This is the only time you'll see it. Copy it somewhere safe now. + + +
+ {newToken} + +
+ + + + + ) : ( +
+ + New API token + +
+
+ + setName(e.target.value)} + placeholder="Home Assistant, phone shortcut…" /> +
+ + + + {!fullAccess && ( +
+ +
+
+ ReadWrite +
+ {SCOPE_RESOURCES.map((r) => ( +
+ {r.label}{r.soon && · soon} + toggleScope(`${r.key}:read`)} /> + toggleScope(`${r.key}:write`)} /> +
+ ))} +
+
+ )} +
+ + + + +
+ )} +
+
+
+ ); +} diff --git a/src/lib/api/authenticate.test.ts b/src/lib/api/authenticate.test.ts new file mode 100644 index 0000000..1e6a637 --- /dev/null +++ b/src/lib/api/authenticate.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { hasScope, generateRawToken, hashToken, tokenPrefix, TOKEN_PREFIX } from "./authenticate"; + +describe("hasScope", () => { + it("grants everything with the global wildcard", () => { + expect(hasScope(["*"], "plants:read")).toBe(true); + expect(hasScope(["*"], "items:write")).toBe(true); + }); + + it("matches an exact scope", () => { + expect(hasScope(["plants:read"], "plants:read")).toBe(true); + expect(hasScope(["plants:read"], "plants:write")).toBe(false); + expect(hasScope(["plants:read"], "items:read")).toBe(false); + }); + + it("honors a resource wildcard", () => { + expect(hasScope(["plants:*"], "plants:read")).toBe(true); + expect(hasScope(["plants:*"], "plants:write")).toBe(true); + expect(hasScope(["plants:*"], "items:read")).toBe(false); + }); + + it("lets write imply read, but not the reverse", () => { + expect(hasScope(["plants:write"], "plants:read")).toBe(true); + expect(hasScope(["plants:read"], "plants:write")).toBe(false); + }); + + it("returns false for an empty scope set", () => { + expect(hasScope([], "plants:read")).toBe(false); + }); +}); + +describe("token helpers", () => { + it("generates a prefixed, unique-ish token and hashes deterministically", () => { + const a = generateRawToken(); + const b = generateRawToken(); + expect(a.startsWith(TOKEN_PREFIX)).toBe(true); + expect(a).not.toBe(b); + expect(hashToken(a)).toBe(hashToken(a)); // deterministic + expect(hashToken(a)).not.toBe(hashToken(b)); + expect(hashToken(a)).toMatch(/^[0-9a-f]{64}$/); // sha256 hex + }); + + it("derives an 8-char prefix for display", () => { + const raw = generateRawToken(); + expect(tokenPrefix(raw)).toBe(raw.slice(0, 8)); + expect(tokenPrefix(raw)).toHaveLength(8); + }); + + it("ignores surrounding whitespace when hashing", () => { + const raw = generateRawToken(); + expect(hashToken(` ${raw} `)).toBe(hashToken(raw)); + }); +}); diff --git a/src/lib/api/authenticate.ts b/src/lib/api/authenticate.ts new file mode 100644 index 0000000..03b1e1e --- /dev/null +++ b/src/lib/api/authenticate.ts @@ -0,0 +1,97 @@ +import { createHash, randomBytes } from "crypto"; +import { NextResponse } from "next/server"; +import { ZodError } from "zod"; +import { UserRole } from "@prisma/client"; +import { db } from "@/lib/db"; +import { getSession } from "@/lib/auth"; + +// --------------------------------------------------------------------------- +// Token format + hashing (pure) +// --------------------------------------------------------------------------- + +export const TOKEN_PREFIX = "mb_"; + +/** A fresh raw token, e.g. "mb_<43 base64url chars>". Shown to the user once. */ +export function generateRawToken(): string { + return TOKEN_PREFIX + randomBytes(32).toString("base64url"); +} + +/** Only the hash is stored; lookups hash the incoming bearer and match. */ +export function hashToken(raw: string): string { + return createHash("sha256").update(raw.trim()).digest("hex"); +} + +/** Short identifier shown in the UI so a token is recognizable post-creation. */ +export function tokenPrefix(raw: string): string { + return raw.slice(0, 8); +} + +// --------------------------------------------------------------------------- +// Scopes (pure) — ":", plus "*" and ":*" wildcards. +// "write" implies "read". Cookie sessions hold ["*"] (full UI access). +// Scope constants live in ./scopes (client-safe); re-exported for convenience. +// --------------------------------------------------------------------------- + +export { KNOWN_SCOPES } from "./scopes"; + +export function hasScope(scopes: string[], required: string): boolean { + const [reqRes, reqAct] = required.split(":"); + for (const s of scopes) { + if (s === "*") return true; + const [res, act] = s.split(":"); + if (res !== reqRes) continue; + if (act === "*" || act === reqAct) return true; + if (act === "write" && reqAct === "read") return true; // write implies read + } + return false; +} + +// --------------------------------------------------------------------------- +// Request authentication — accepts a NextAuth cookie session OR a Bearer PAT +// --------------------------------------------------------------------------- + +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = "ApiError"; + } +} + +export type AuthContext = { + userId: string; + role: UserRole; + scopes: string[]; // ["*"] for cookie sessions + via: "session" | "token"; +}; + +export async function authenticateRequest(req: Request, required?: string): Promise { + const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1]; + + if (bearer) { + const tok = await db.apiToken.findUnique({ + where: { tokenHash: hashToken(bearer) }, + include: { user: { select: { role: true, active: true } } }, + }); + if (!tok || tok.revokedAt || !tok.user.active || (tok.expiresAt && tok.expiresAt < new Date())) + throw new ApiError(401, "Invalid or expired token"); + if (required && !hasScope(tok.scopes, required)) + throw new ApiError(403, `Token is missing the "${required}" scope`); + // Fire-and-forget — never block the request on a usage timestamp. + db.apiToken.update({ where: { id: tok.id }, data: { lastUsedAt: new Date() } }).catch(() => {}); + return { userId: tok.userId, role: tok.user.role, scopes: tok.scopes, via: "token" }; + } + + const session = await getSession(); + if (!session) throw new ApiError(401, "Unauthorized"); + return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" }; +} + +/** Standardize the error ladder shared by every /api/v1 route. */ +export function handleApiError(err: unknown): NextResponse { + if (err instanceof ApiError) + return NextResponse.json({ error: err.message }, { status: err.status }); + if (err instanceof ZodError) + return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); +} diff --git a/src/lib/api/schemas.ts b/src/lib/api/schemas.ts new file mode 100644 index 0000000..fe5a883 --- /dev/null +++ b/src/lib/api/schemas.ts @@ -0,0 +1,27 @@ +// Zod schemas shared by the REST API and (later) the MCP tool definitions, so +// validation lives in exactly one place. +import { z } from "zod"; + +export const plantLogInput = z.object({ + type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]), + notes: z.string().optional(), + quantity: z.string().optional(), + date: z.string().optional(), // ISO date; defaults to now +}); + +export const taskCreateInput = z.object({ + title: z.string().min(1), + notes: z.string().optional(), + category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"), + plantId: z.string().optional(), + recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"), + intervalDays: z.number().int().positive().optional(), + month: z.number().int().min(1).max(12).optional(), + nextDue: z.string(), // ISO date +}); + +export const taskCompleteInput = z.object({ notes: z.string().optional() }); + +export type PlantLogInput = z.infer; +export type TaskCreateInput = z.infer; +export type TaskCompleteInput = z.infer; diff --git a/src/lib/api/scopes.ts b/src/lib/api/scopes.ts new file mode 100644 index 0000000..6a37e67 --- /dev/null +++ b/src/lib/api/scopes.ts @@ -0,0 +1,22 @@ +// Client-safe scope constants (no server deps) — shared by the auth helper, +// the token routes, and the token-management UI. + +export const KNOWN_SCOPES = [ + "plants:read", "plants:write", + "plant-types:read", "plant-types:write", + "locations:read", "locations:write", + "tasks:read", "tasks:write", + "items:read", "items:write", +] as const; + +export type KnownScope = (typeof KNOWN_SCOPES)[number]; + +// Resources for the token-creation UI (read/write columns). `soon` marks +// domains whose endpoints are still being built. +export const SCOPE_RESOURCES: { key: string; label: string; soon?: boolean }[] = [ + { key: "plants", label: "Plants" }, + { key: "plant-types", label: "Plant types" }, + { key: "locations", label: "Locations" }, + { key: "tasks", label: "Tasks & reminders" }, + { key: "items", label: "Inventory", soon: true }, +]; diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 5305534..56b5283 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -8,6 +8,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.12.0", + date: "2026-06-15", + changes: [ + "Moon Base now has an API — other apps can read and update your homestead. Create access tokens under Settings → API tokens, choosing exactly what each one is allowed to do (e.g. read plants, log harvests). This is the groundwork for phone shortcuts, Home Assistant, and letting Claude help directly.", + ], + }, { version: "0.11.0", date: "2026-06-15", diff --git a/src/lib/services/locations.ts b/src/lib/services/locations.ts new file mode 100644 index 0000000..2bf0009 --- /dev/null +++ b/src/lib/services/locations.ts @@ -0,0 +1,10 @@ +import { db } from "@/lib/db"; +import type { AuthContext } from "@/lib/api/authenticate"; + +export function listLocations(_ctx: AuthContext) { + return db.location.findMany({ + where: { active: true }, + orderBy: { name: "asc" }, + select: { id: true, name: true, kind: true, parentId: true, path: true }, + }); +} diff --git a/src/lib/services/plant-types.ts b/src/lib/services/plant-types.ts new file mode 100644 index 0000000..e040f4d --- /dev/null +++ b/src/lib/services/plant-types.ts @@ -0,0 +1,12 @@ +import { db } from "@/lib/db"; +import type { AuthContext } from "@/lib/api/authenticate"; + +export function listPlantTypes(_ctx: AuthContext, opts?: { q?: string }) { + return db.plantType.findMany({ + where: { + active: true, + ...(opts?.q ? { commonName: { contains: opts.q, mode: "insensitive" } } : {}), + }, + orderBy: { commonName: "asc" }, + }); +} diff --git a/src/lib/services/plants.ts b/src/lib/services/plants.ts new file mode 100644 index 0000000..bb328a9 --- /dev/null +++ b/src/lib/services/plants.ts @@ -0,0 +1,57 @@ +// Plant service — DB work + AuditEvent for plantings. Called by /api/v1 routes +// and (later) the MCP server, so the logic lives once. +import { db } from "@/lib/db"; +import { ApiError, type AuthContext } from "@/lib/api/authenticate"; +import type { PlantLogInput } from "@/lib/api/schemas"; + +export function listPlants(_ctx: AuthContext, opts?: { q?: string }) { + return db.plant.findMany({ + where: { + active: true, + ...(opts?.q ? { commonName: { contains: opts.q, mode: "insensitive" } } : {}), + }, + orderBy: { commonName: "asc" }, + include: { + location: { select: { id: true, name: true } }, + plantType: { select: { id: true, commonName: true } }, + }, + }); +} + +export async function getPlant(_ctx: AuthContext, id: string) { + const plant = await db.plant.findUnique({ + where: { id }, + include: { + location: { select: { id: true, name: true } }, + plantType: { select: { id: true, commonName: true } }, + logs: { orderBy: { date: "desc" }, take: 50 }, + }, + }); + if (!plant || !plant.active) throw new ApiError(404, "Plant not found"); + return plant; +} + +export async function addPlantLog(ctx: AuthContext, plantId: string, input: PlantLogInput) { + const plant = await db.plant.findUnique({ where: { id: plantId }, select: { active: true } }); + if (!plant || !plant.active) throw new ApiError(404, "Plant not found"); + + const log = await db.plantLog.create({ + data: { + plantId, + type: input.type, + date: input.date ? new Date(input.date) : new Date(), + notes: input.notes?.trim() || null, + quantity: input.quantity?.trim() || null, + actorId: ctx.userId, + }, + }); + await db.auditEvent.create({ + data: { + action: "plant.log.add", + entityId: plantId, + actorId: ctx.userId, + payload: { type: input.type, via: ctx.via }, + }, + }); + return log; +} diff --git a/src/lib/services/tasks.ts b/src/lib/services/tasks.ts new file mode 100644 index 0000000..35a044a --- /dev/null +++ b/src/lib/services/tasks.ts @@ -0,0 +1,67 @@ +import { db } from "@/lib/db"; +import { nextDueDate } from "@/lib/tasks/schedule"; +import { ApiError, type AuthContext } from "@/lib/api/authenticate"; +import type { TaskCreateInput, TaskCompleteInput } from "@/lib/api/schemas"; + +export function listTasks(_ctx: AuthContext) { + return db.task.findMany({ + where: { active: true }, + orderBy: { nextDue: "asc" }, + include: { plant: { select: { id: true, commonName: true } } }, + }); +} + +export async function createTask(ctx: AuthContext, input: TaskCreateInput) { + const task = await db.task.create({ + data: { + title: input.title.trim(), + notes: input.notes?.trim() || null, + category: input.category, + plantId: input.plantId || null, + recurrence: input.recurrence, + intervalDays: input.intervalDays ?? null, + month: input.month ?? null, + nextDue: new Date(input.nextDue), + }, + }); + await db.auditEvent.create({ + data: { + action: "task.create", + entityId: task.id, + actorId: ctx.userId, + payload: { title: task.title, recurrence: task.recurrence, via: ctx.via }, + }, + }); + return task; +} + +export async function completeTask(ctx: AuthContext, id: string, input: TaskCompleteInput) { + const task = await db.task.findUnique({ where: { id } }); + if (!task || !task.active) throw new ApiError(404, "Task not found"); + + const now = new Date(); + const next = nextDueDate(task.recurrence, now, task.intervalDays, task.month); + + const [completion] = await db.$transaction([ + db.taskCompletion.create({ + data: { taskId: task.id, doneAt: now, notes: input.notes?.trim() || null, actorId: ctx.userId }, + }), + db.task.update({ + where: { id: task.id }, + data: { + lastDone: now, + nextDue: next ?? task.nextDue, + active: task.recurrence === "ONCE" ? false : true, + }, + }), + ]); + await db.auditEvent.create({ + data: { + action: "task.complete", + entityId: task.id, + actorId: ctx.userId, + payload: { title: task.title, nextDue: next, via: ctx.via }, + }, + }); + return { completion, nextDue: next }; +} diff --git a/src/lib/version.ts b/src/lib/version.ts index e989337..8ce5426 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. -export const APP_VERSION = "0.11.0"; +export const APP_VERSION = "0.12.0";