v0.12.0 — REST API + personal access tokens

A bidirectional API so other apps (Home Assistant, phone shortcuts, scripts,
and the coming MCP server) can read and update the homestead.

- ApiToken model (sha256-hashed, scoped, with prefix/lastUsed/expiry/revoke)
- authenticateRequest() accepts EITHER a NextAuth cookie session OR a Bearer
  PAT; cookie sessions get full access, tokens are limited to their scopes
  (write implies read; resource and global wildcards). ApiError + handleApiError
- Shared service layer (plants/tasks/locations/plant-types) reused by every
  endpoint and, later, the MCP server — logic lives once
- /api/v1: plants (read + add log), plant-types, locations, tasks (read/create/
  complete), and a whoami/discovery root
- Settings → API tokens: create with per-resource read/write checkboxes, raw
  token shown once, revoke; tokens added to backup/restore
- Pure tests for scope matching + token hashing; verified end-to-end (401/403/
  201, lastUsedAt) against a running server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:55:58 -05:00
parent cdb21ed58a
commit c5bf108cb9
27 changed files with 880 additions and 4 deletions

View File

@@ -0,0 +1,18 @@
import { ApiTokensPanel } from "@/components/settings/api-tokens-panel";
export const metadata = { title: "API tokens" };
export default function TokensPage() {
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="font-display text-2xl font-semibold">API tokens</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Personal access tokens for the Moon Base API use them with the{" "}
<code className="text-xs">Authorization: Bearer </code> header.
</p>
</div>
<ApiTokensPanel />
</div>
);
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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)),

View File

@@ -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 });

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

22
src/app/api/v1/route.ts Normal file
View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 },
];

View File

@@ -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<Token[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [fullAccess, setFullAccess] = useState(false);
const [scopes, setScopes] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [newToken, setNewToken] = useState<string | null>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
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.
</p>
<Button size="sm" onClick={() => { resetForm(); setOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> New token
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : tokens.length === 0 ? (
<div className="text-center py-10 text-muted-foreground border rounded-lg">
<KeyRound className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p className="text-sm">No tokens yet.</p>
</div>
) : (
<div className="divide-y border rounded-lg overflow-hidden">
{tokens.map((t) => {
const expired = t.expiresAt && new Date(t.expiresAt) < new Date();
const dead = !!t.revokedAt || expired;
return (
<div key={t.id} className="flex items-center gap-3 px-4 py-3">
<KeyRound className={`h-4 w-4 shrink-0 ${dead ? "text-muted-foreground/40" : "text-[hsl(var(--leaf))]"}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-medium ${dead ? "line-through text-muted-foreground" : ""}`}>{t.name}</span>
<code className="text-xs text-muted-foreground">{t.prefix}</code>
{t.revokedAt && <Badge variant="outline" className="text-xs">Revoked</Badge>}
{expired && !t.revokedAt && <Badge variant="outline" className="text-xs">Expired</Badge>}
</div>
<div className="flex items-center gap-1.5 flex-wrap mt-1">
{t.scopes.map((s) => <Badge key={s} variant="secondary" className="text-[10px]">{s}</Badge>)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Created {fmt(t.createdAt)} · Last used {fmt(t.lastUsedAt)}
{t.expiresAt && ` · Expires ${fmt(t.expiresAt)}`}
</p>
</div>
{!dead && (
<Button variant="ghost" size="sm" className="text-[hsl(var(--ember))] shrink-0"
onClick={() => revoke(t.id, t.name)}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
);
})}
</div>
)}
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) resetForm(); }}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
{newToken ? (
<>
<DialogHeader>
<DialogTitle>Copy your new token</DialogTitle>
<DialogDescription className="flex items-start gap-1.5 text-[hsl(var(--ember))]">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
This is the only time you&apos;ll see it. Copy it somewhere safe now.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted rounded p-2.5 break-all">{newToken}</code>
<Button size="sm" variant="outline" onClick={copy}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<DialogFooter>
<Button onClick={() => { setOpen(false); resetForm(); }}>Done</Button>
</DialogFooter>
</>
) : (
<form onSubmit={create}>
<DialogHeader>
<DialogTitle>New API token</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)}
placeholder="Home Assistant, phone shortcut…" />
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={fullAccess} onChange={(e) => setFullAccess(e.target.checked)}
className="accent-[hsl(var(--leaf))]" />
Full access (read &amp; write everything)
</label>
{!fullAccess && (
<div className="space-y-1.5">
<Label>Permissions</Label>
<div className="border rounded-md divide-y">
<div className="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-1.5 text-xs text-muted-foreground">
<span></span><span className="w-12 text-center">Read</span><span className="w-12 text-center">Write</span>
</div>
{SCOPE_RESOURCES.map((r) => (
<div key={r.key} className="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-2 items-center text-sm">
<span>{r.label}{r.soon && <span className="text-xs text-muted-foreground"> · soon</span>}</span>
<input type="checkbox" className="w-12 accent-[hsl(var(--leaf))]"
checked={scopes.has(`${r.key}:read`)} onChange={() => toggleScope(`${r.key}:read`)} />
<input type="checkbox" className="w-12 accent-[hsl(var(--leaf))]"
checked={scopes.has(`${r.key}:write`)} onChange={() => toggleScope(`${r.key}:write`)} />
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Creating…" : "Create token"}</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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));
});
});

View File

@@ -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) — "<resource>:<action>", plus "*" and "<resource>:*" 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<AuthContext> {
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 });
}

27
src/lib/api/schemas.ts Normal file
View File

@@ -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<typeof plantLogInput>;
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;

22
src/lib/api/scopes.ts Normal file
View File

@@ -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 },
];

View File

@@ -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",

View File

@@ -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 },
});
}

View File

@@ -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" },
});
}

View File

@@ -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;
}

67
src/lib/services/tasks.ts Normal file
View File

@@ -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 };
}

View File

@@ -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";