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.
+
+
{ resetForm(); setOpen(true); }}>
+ New token
+
+
+
+ {loading ? (
+
Loading…
+ ) : tokens.length === 0 ? (
+
+ ) : (
+
+ {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 && (
+
revoke(t.id, t.name)}>
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
{ 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}
+
+ {copied ? : }
+
+
+
+ { setOpen(false); resetForm(); }}>Done
+
+ >
+ ) : (
+
+ )}
+
+
+
+ );
+}
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";