Load page.json by import, publish via fs, drop cookie auth

The editor no longer round-trips through /api/pages. Each route's
editor/page.tsx imports its own `../page.json` and hands it to
PageEditor as a prop, so the editor opens with the page already in
hand — no fetch, no loading state, no undo history seeded from a
placeholder. Globals still come from app.globals.json.

Publishing moves from `PUT /api/pages` to a `publishPage` server
action that writes the route's page.json with node:fs directly.
The target path is still built from the lib/pages.ts registry rather
than from the caller, so an unknown route key is rejected instead of
escaping app/.

Removes the customer account auth entirely: the httpOnly cookie
session, the /api/account/* handlers, the customer service and
GraphQL documents, the account-* blocks, and the /account/* routes.
The template has no auth, so nothing reads a cookie now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STGDvL4X7FhHHnxdRE2ayo
This commit is contained in:
Rami Bitar
2026-08-09 16:36:18 -04:00
co-authored by Claude Opus 5
parent 63ecc5e284
commit f11a764426
51 changed files with 129 additions and 2080 deletions
-30
View File
@@ -1,30 +0,0 @@
import {
activateAccount,
toCustomerGid,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { id, activationToken, password } = await req.json();
if (!id || !activationToken || !password) {
return Response.json(
{ error: 'This activation link is incomplete.' },
{ status: 400 }
);
}
const { token, errors } = await activateAccount(
toCustomerGid(id),
activationToken,
password
);
if (!token) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
-31
View File
@@ -1,31 +0,0 @@
import { login, customerErrorMessage } from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { email, password } = await req.json();
if (!email || !password) {
return Response.json(
{ error: 'Enter your email and password.' },
{ status: 400 }
);
}
const { token, errors } = await login(email, password);
if (!token) {
// Shopify distinguishes wrong-password from unknown-email; collapse both so
// the form can't be used to enumerate accounts.
return Response.json(
{
error: errors.length
? 'Incorrect email or password.'
: customerErrorMessage(errors),
},
{ status: 401 }
);
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
-10
View File
@@ -1,10 +0,0 @@
import { logout } from '@/services/shopify/customer';
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
export async function POST() {
const token = await getSessionToken();
if (token) await logout(token);
await clearSessionToken();
return Response.json({ ok: true });
}
-19
View File
@@ -1,19 +0,0 @@
import { getSessionToken } from '@/services/shopify/session';
import { getCustomer } from '@/services/shopify/customer';
// Minimal session probe for the header menu — never returns the access token.
export async function GET() {
const token = await getSessionToken();
if (!token) return Response.json({ customer: null });
const customer = await getCustomer(token, 0);
if (!customer) return Response.json({ customer: null });
return Response.json({
customer: {
displayName: customer.displayName,
email: customer.email,
firstName: customer.firstName,
},
});
}
-26
View File
@@ -1,26 +0,0 @@
import { getSessionToken } from '@/services/shopify/session';
import { getCustomer } from '@/services/shopify/customer';
/**
* Full customer record including orders, for the client-rendered order-history
* block. `/api/account/me` stays the lightweight session probe the header uses
* — it asks for zero orders — so the two don't fight over payload size.
*
* The access token never leaves the server: it is read from the session cookie
* here and only the resolved customer is returned.
*/
export async function GET(request: Request) {
const token = await getSessionToken();
if (!token) return Response.json({ customer: null }, { status: 401 });
const { searchParams } = new URL(request.url);
const parsed = Number(searchParams.get('orders'));
const orderCount = Number.isFinite(parsed)
? Math.min(Math.max(Math.trunc(parsed), 1), 50)
: 20;
const customer = await getCustomer(token, orderCount);
if (!customer) return Response.json({ customer: null }, { status: 401 });
return Response.json({ customer });
}
-10
View File
@@ -1,10 +0,0 @@
import { recoverPassword } from '@/services/shopify/customer';
export async function POST(req: Request) {
const { email } = await req.json();
if (email) await recoverPassword(email);
// Always the same response, so the form can't reveal who has an account.
return Response.json({ ok: true });
}
-39
View File
@@ -1,39 +0,0 @@
import {
createCustomer,
login,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { email, password, firstName, lastName } = await req.json();
if (!email || !password) {
return Response.json(
{ error: 'Enter your email and password.' },
{ status: 400 }
);
}
const { errors } = await createCustomer({
email,
password,
firstName,
lastName,
});
if (errors.length) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
// Sign the new customer straight in. Accounts needing email confirmation
// won't return a token yet, which is not an error.
const { token } = await login(email, password);
if (token) {
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true, signedIn: true });
}
return Response.json({ ok: true, signedIn: false });
}
-27
View File
@@ -1,27 +0,0 @@
import {
resetPassword,
toCustomerGid,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { id, resetToken, password } = await req.json();
if (!id || !resetToken || !password) {
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
}
const { token, errors } = await resetPassword(
toCustomerGid(id),
resetToken,
password
);
if (!token) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
-103
View File
@@ -1,103 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { findPageRoute } from '@/lib/pages';
// Touches the filesystem, so it must never be statically optimised.
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
/**
* Resolves a route key to its `page.json` on disk.
*
* The path is built from the registry in `lib/pages.ts`, never from the request
* body, so an unknown or crafted route key is rejected outright rather than
* escaping the `app/` directory. The realpath check is belt-and-braces for the
* same thing.
*/
function resolvePageFile(routeKey: string): string | null {
const route = findPageRoute(routeKey);
if (!route) return null;
const appDir = path.join(process.cwd(), 'app');
const file = path.join(appDir, route.dir, 'page.json');
return file.startsWith(appDir + path.sep) ? file : null;
}
// Props of blocks marked `global: true` (header, footer) live in one file that
// every page.json references, so editing them once updates every route.
const GLOBALS_FILE = path.join(process.cwd(), 'app.globals.json');
async function readJson(file: string): Promise<Record<string, any> | null> {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch {
return null;
}
}
export async function GET(request: Request) {
const routeKey = new URL(request.url).searchParams.get('route') ?? '/';
const file = resolvePageFile(routeKey);
if (!file) {
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
}
const page = await readJson(file);
// A route with no page.json yet is a new page, not an error.
if (!page) return Response.json({ page: null });
const globals = await readJson(GLOBALS_FILE);
return Response.json({ page: { ...page, globals: globals ?? {} } });
}
export async function PUT(request: Request) {
let body: { route?: string; page?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: 'Expected a JSON body.' }, { status: 400 });
}
const routeKey = body.route ?? '';
const file = resolvePageFile(routeKey);
if (!file) {
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
}
if (!body.page || typeof body.page !== 'object') {
return Response.json({ error: 'Expected a page object.' }, { status: 400 });
}
// Globals belong to the whole site, not this route, so they go to their own
// file and are stripped from the page before it is written.
const { globals, ...page } = body.page as Record<string, unknown>;
try {
await writeFile(file, `${JSON.stringify(page, null, 2)}\n`, 'utf8');
if (globals && typeof globals === 'object') {
await writeFile(
GLOBALS_FILE,
`${JSON.stringify(globals, null, 2)}\n`,
'utf8'
);
}
return Response.json({ file: path.relative(process.cwd(), file) });
} catch (err) {
// Read-only filesystems (most serverless hosts) land here. Say so plainly
// rather than reporting a save that did not happen.
console.error(`Failed to write ${file}:`, err);
return Response.json(
{
error:
'Could not write page.json. The filesystem is read-only — run the editor locally to save.',
},
{ status: 500 }
);
}
}