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:
18
src/app/(dashboard)/settings/tokens/page.tsx
Normal file
18
src/app/(dashboard)/settings/tokens/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/api/account/tokens/[id]/route.ts
Normal file
29
src/app/api/account/tokens/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
75
src/app/api/account/tokens/route.ts
Normal file
75
src/app/api/account/tokens/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
13
src/app/api/v1/locations/route.ts
Normal file
13
src/app/api/v1/locations/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/app/api/v1/plant-types/route.ts
Normal file
14
src/app/api/v1/plant-types/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/app/api/v1/plants/[id]/logs/route.ts
Normal file
16
src/app/api/v1/plants/[id]/logs/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/app/api/v1/plants/[id]/route.ts
Normal file
13
src/app/api/v1/plants/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/app/api/v1/plants/route.ts
Normal file
14
src/app/api/v1/plants/route.ts
Normal 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
22
src/app/api/v1/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/app/api/v1/tasks/[id]/complete/route.ts
Normal file
15
src/app/api/v1/tasks/[id]/complete/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/app/api/v1/tasks/route.ts
Normal file
25
src/app/api/v1/tasks/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
217
src/components/settings/api-tokens-panel.tsx
Normal file
217
src/components/settings/api-tokens-panel.tsx
Normal 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'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 & 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>
|
||||
);
|
||||
}
|
||||
53
src/lib/api/authenticate.test.ts
Normal file
53
src/lib/api/authenticate.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
97
src/lib/api/authenticate.ts
Normal file
97
src/lib/api/authenticate.ts
Normal 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
27
src/lib/api/schemas.ts
Normal 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
22
src/lib/api/scopes.ts
Normal 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 },
|
||||
];
|
||||
@@ -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",
|
||||
|
||||
10
src/lib/services/locations.ts
Normal file
10
src/lib/services/locations.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
12
src/lib/services/plant-types.ts
Normal file
12
src/lib/services/plant-types.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
57
src/lib/services/plants.ts
Normal file
57
src/lib/services/plants.ts
Normal 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
67
src/lib/services/tasks.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user