技能是指引而非安全邊界:Next.js Server Action 將資料操作留在伺服器端
Skills Are Guidance, Not a Security Boundary: A Next.js Server Action That Stays Server-Side
作者將 vercel-labs/skills 的技能定位為須審核的開發指引而非安全邊界,並示範以 Next.js Server Action 將資料操作留在伺服器端。
Skills Are Guidance, Not a Security Boundary: A Next.js Server Action That Stays Server-Side
It's 3:00 AM. Your on-call pager fires: leaked API keys in a production bundle. The incident trace shows a shared utility file—imported by both a Server Component and a client form—pulled environment variables into the browser. No one added NEXT_PUBLIC_ prefixes. No one intended exposure. Someone just moved "use client" up one level to simplify an import.
Adopting vercel-labs/skills into a Next.js project raises a practical question: how do you make reusable development guidance survive contact with production security boundaries? The repository distributes agent skills through npx skills. As an external architect evaluating its integration, I treat installed instructions as reviewed conventions, not enforcement. I have not inspected a pinned repository revision here—this is an integration design, not a claim about its current internals.
The pain point is boundary erosion: code that looks reusable crosses from trusted server execution into an untrusted client entry point. Fixing that boundary also gives us a clean place to prevent sequential data fetching.
The failure behind the convenient export
Consider a production incident pattern: a shared barrel exports both interactive components and a privileged API helper. Someone adds "use client" to simplify an import. The helper now enters the client dependency graph.
A private, unprefixed environment variable does not automatically become public in Next.js. Actual exposure requires something more: an embedded credential, public environment configuration, or another build-time substitution. That distinction matters when investigating a leaked bundle. Moving a file is not sufficient remediation—revoke exposed credentials and inspect emitted assets.
The same architectural ambiguity encourages fetching the session, notes, and summary consecutively. When a slow query blocks the main content, LCP can deteriorate. Suspense helps only if independent work actually starts independently.
Our boundary is explicit: browser submissions enter a Server Action; the action authenticates, validates, and executes an ownership-scoped query; Server Components read through the same server-only data layer.
Establish the runtime contract
Assume an existing Next.js App Router application, Node.js, PostgreSQL 14+, and an authentication service that issues an HttpOnly session cookie. Its HS256 JWT must contain sub, exp, issuer notes-app, and audience notes-web.
This is the single setup configuration. Replace the local database credentials before deployment:
npm install zod jose postgres server-only
cat > .env.local <<EOF
DATABASE_URL=postgres://app:app@localhost:5432/app
AUTH_SECRET=$(openssl rand -hex 32)
EOF
psql postgres://app:app@localhost:5432/app <<'SQL'
CREATE TABLE IF NOT EXISTS notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id text NOT NULL,
body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS notes_owner_created
ON notes (owner_id, created_at DESC);
SQL
Share AUTH_SECRET securely with the issuer; generating it here does not implement login. Never commit .env.local.
Keep credentials behind an executable boundary
// lib/data.ts
import "server-only";
import postgres from "postgres";
import { jwtVerify } from "jose";
import { cookies } from "next/headers";
export const sql = postgres(process.env.DATABASE_URL!, { max: 5 });
export async function userId(): Promise<string | null> {
const token = (await cookies()).get("session")?.value;
if (!token) return null;
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error("AUTH_SECRET is required");
try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(secret),
{
algorithms: ["HS256"],
issuer: "notes-app",
audience: "notes-web",
requiredClaims: ["sub", "exp"],
},
);
return payload.sub || null;
} catch {
return null;
}
}
server-only turns accidental client imports into build failures. Authentication establishes identity; the SQL predicate below supplies authorization.
// app/notes/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { sql, userId } from "../../lib/data";
type Result =
| { ok: true }
| { ok: false; code: "UNAUTHORIZED" | "INVALID_INPUT" | "INTERNAL" };
const input = z.object({
body: z.string().trim().min(1).max(2000),
});
export async function createNote(form: FormData): Promise<Result> {
try {
const owner = await userId();
if (!owner) return { ok: false, code: "UNAUTHORIZED" };
const parsed = input.safeParse({ body: form.get("body") });
if (!parsed.success) return { ok: false, code: "INVALID_INPUT" };
await sql`
INSERT INTO notes (owner_id, body)
VALUES (${owner}, ${parsed.data.body})
`;
revalidatePath("/notes");
return { ok: true };
} catch {
console.error("createNote failed");
return { ok: false, code: "INTERNAL" };
}
}
Neither an owner identifier nor authorization status comes from the form. Database errors remain private. Production callers should render the discriminated result, rather than treating every resolved action as success.
Stream independent regions, not an entire waterfall
// app/notes/page.tsx
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { sql, userId } from "../../lib/data";
export const runtime = "nodejs";
type Note = { id: string; body: string };
type Count = { total: string };
function Skeleton() {
return (
<div role="status" aria-label="Loading">
<div style={{ height: 80, background: "#eee", borderRadius: 8 }} />
</div>
);
}
async function Notes({ data }: { data: PromiseLike<Note[]> }) {
const rows = await data;
return <ul>{rows.map(n => <li key={n.id}>{n.body}</li>)}</ul>;
}
async function Summary({ data }: { data: PromiseLike<Count[]> }) {
const [row] = await data;
return <p>{row.total} notes</p>;
}
export default async function Page() {
const owner = await userId();
if (!owner) redirect("/login");
const notes = sql<Note[]>`
SELECT id, body FROM notes
WHERE owner_id = ${owner}
ORDER BY created_at DESC LIMIT 50
`.execute();
const count = sql<Count[]>`
SELECT count(*)::text AS total FROM notes WHERE owner_id = ${owner}
`.execute();
return (
<main>
<h1>Your notes</h1>
<Suspense fallback={<Skeleton />}><Summary data={count} /></Suspense>
<Suspense fallback={<Skeleton />}><Notes data={notes} /></Suspense>
</main>
);
}
Explicit .execute() starts both queries before either component awaits them. Authentication remains deliberately sequential: private work must not begin before identity verification.
What skills should standardize
I would adopt reviewed skills that preserve these boundaries, then enforce them with builds and integration tests. Test forged sessions, oversized submissions, and cross-user reads. Treat skill updates as executable development inputs requiring review, not automatic trust.
Parallel queries consume more connections, and exact counts become expensive as datasets grow. Skeleton streaming can improve perceived responsiveness without improving LCP if the largest element still depends on slow data. Measure that element directly. Reusable guidance helps consistency; server-only imports, verified sessions, scoped SQL, and observed rendering behavior provide the actual guarantees.
Here's the hardest operational dilemma: when do you favor consistency over latency? Parallel queries speed up the happy path but complicate failure modes. A count query that times out while the main list succeeds creates a partial UI state. Do you render incomplete data with an error banner, block the entire page, or fall back to an approximate count? How have you handled partial query failures in production Server Components, and what observability do you need to catch them before users do?
來源:DEV Community · dev.to