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
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /about. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/about" />;
return <PageEditor routeKey="/about" page={pageData} />;
}
@@ -1,7 +0,0 @@
import PageEditor from '@/components/page-editor';
// Editor for /account/activate/[id]/[token]. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() {
return <PageEditor routeKey="/account/activate/[id]/[token]" />;
}
@@ -1,43 +0,0 @@
{
"root": {
"props": {
"title": "Activate your account",
"description": "Finish setting up your account.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "account-activate",
"props": {
"id": "account-activate",
"flow": "activate",
"title": "Activate your account",
"description": "Choose a password to finish setting up your account.",
"submitLabel": "Activate account",
"successMessage": "",
"links": []
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
@@ -1,9 +0,0 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page);
export default function Page() {
return <PageRender page={page} />;
}
-7
View File
@@ -1,7 +0,0 @@
import PageEditor from '@/components/page-editor';
// Editor for /account. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() {
return <PageEditor routeKey="/account" />;
}
-7
View File
@@ -1,7 +0,0 @@
import PageEditor from '@/components/page-editor';
// Editor for /account/login. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() {
return <PageEditor routeKey="/account/login" />;
}
-52
View File
@@ -1,52 +0,0 @@
{
"root": {
"props": {
"title": "Sign in",
"description": "Sign in to your Shop account.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "account-login",
"props": {
"id": "account-login",
"flow": "login",
"title": "Sign in",
"description": "",
"submitLabel": "Sign in",
"successMessage": "",
"links": [
{
"label": "Create an account",
"url": "/account/register"
},
{
"label": "Forgot your password?",
"url": "/account/recover"
}
]
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
-9
View File
@@ -1,9 +0,0 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/account/login' });
export default function Page() {
return <PageRender page={page} />;
}
-41
View File
@@ -1,41 +0,0 @@
{
"root": {
"props": {
"title": "Order history",
"description": "Your recent orders.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "account-orders",
"props": {
"id": "account-orders",
"title": "Order history",
"signedOutMessage": "Sign in to see your orders.",
"emptyMessage": "You haven't placed any orders yet.",
"limit": 20
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
-16
View File
@@ -1,16 +0,0 @@
import { redirect } from 'next/navigation';
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import { getSessionToken } from '@/services/shopify/session';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/account' });
export default async function Page() {
// The order-history block re-reads the session through /api/account/orders,
// but bouncing signed-out visitors here avoids rendering the page at all.
const token = await getSessionToken();
if (!token) redirect('/account/login');
return <PageRender page={page} />;
}
-7
View File
@@ -1,7 +0,0 @@
import PageEditor from '@/components/page-editor';
// Editor for /account/recover. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() {
return <PageEditor routeKey="/account/recover" />;
}
-48
View File
@@ -1,48 +0,0 @@
{
"root": {
"props": {
"title": "Reset password",
"description": "Request a password reset link.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "account-recover",
"props": {
"id": "account-recover",
"flow": "recover",
"title": "Reset password",
"description": "Enter your email and we'll send you a link to set a new password.",
"submitLabel": "Send reset link",
"successMessage": "If that email has an account, a reset link is on its way.",
"links": [
{
"label": "Back to sign in",
"url": "/account/login"
}
]
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
-9
View File
@@ -1,9 +0,0 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/account/recover' });
export default function Page() {
return <PageRender page={page} />;
}
-7
View File
@@ -1,7 +0,0 @@
import PageEditor from '@/components/page-editor';
// Editor for /account/register. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() {
return <PageEditor routeKey="/account/register" />;
}
-48
View File
@@ -1,48 +0,0 @@
{
"root": {
"props": {
"title": "Create account",
"description": "Create a Shop account.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "account-register",
"props": {
"id": "account-register",
"flow": "register",
"title": "Create account",
"description": "",
"submitLabel": "Create account",
"successMessage": "",
"links": [
{
"label": "Already have an account? Sign in",
"url": "/account/login"
}
]
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
-9
View File
@@ -1,9 +0,0 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/account/register' });
export default function Page() {
return <PageRender page={page} />;
}
@@ -1,7 +0,0 @@
import PageEditor from '@/components/page-editor';
// Editor for /account/reset/[id]/[token]. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() {
return <PageEditor routeKey="/account/reset/[id]/[token]" />;
}
-43
View File
@@ -1,43 +0,0 @@
{
"root": {
"props": {
"title": "Set a new password",
"description": "Choose a new password.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "account-reset",
"props": {
"id": "account-reset",
"flow": "reset",
"title": "Set a new password",
"description": "",
"submitLabel": "Save password",
"successMessage": "",
"links": []
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
-9
View File
@@ -1,9 +0,0 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page);
export default function Page() {
return <PageRender page={page} />;
}
-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 }
);
}
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /collections/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/collections/[handle]" />;
return <PageEditor routeKey="/collections/[handle]" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /collections. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/collections" />;
return <PageEditor routeKey="/collections" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/" />;
return <PageEditor routeKey="/" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /policies/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/policies/[handle]" />;
return <PageEditor routeKey="/policies/[handle]" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /products/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/products/[handle]" />;
return <PageEditor routeKey="/products/[handle]" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /search. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/search" />;
return <PageEditor routeKey="/search" page={pageData} />;
}