mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
published_by, created_by, and space_id were declared as uuid, but Mana user + space ids are Better-Auth nanoids stored as text. The insert into website.published_snapshots raised `invalid input syntax for type uuid` and Hono swallowed it as a generic 500. Changes: - schema.ts: uuid -> text on the three columns - 0003_fix_id_types.sql: ALTER COLUMN on existing installs - publish.ts: replace UUID regex on X-Mana-Space with a nanoid-shaped check (it was silently nulling valid space ids) - publish.ts: log + return the actual error message on the 500 path so the next unhandled failure is visible instead of opaque Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
/**
|
|
* Publish + unpublish endpoints.
|
|
*
|
|
* Scoped to *authenticated* users who can publish their own site. The
|
|
* public read path lives in `public-routes.ts` and is mounted outside
|
|
* the auth gate.
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import { z } from 'zod';
|
|
import { and, desc, eq } from 'drizzle-orm';
|
|
import type { AuthVariables } from '@mana/shared-hono';
|
|
import { errorResponse, validationError } from '../../lib/responses';
|
|
import { websitePublishTotal, websitePublishDuration } from '../../lib/metrics';
|
|
import { db, publishedSnapshots, submissions } from './schema';
|
|
import { isValidSlug } from './reserved-slugs';
|
|
|
|
const routes = new Hono<{ Variables: AuthVariables }>();
|
|
|
|
// Permissive schema — block props are client-trusted in M2; server-side
|
|
// Zod validation per block spec arrives in a later phase (see plan D8).
|
|
const SnapshotBlockSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
z.object({
|
|
id: z.string().uuid(),
|
|
type: z.string().min(1).max(64),
|
|
schemaVersion: z.number().int().min(1),
|
|
slotKey: z.string().max(64).nullable(),
|
|
props: z.unknown(),
|
|
children: z.array(SnapshotBlockSchema),
|
|
})
|
|
);
|
|
|
|
const SnapshotPageSchema = z.object({
|
|
id: z.string().uuid(),
|
|
path: z.string().min(1).max(256),
|
|
title: z.string().min(1).max(256),
|
|
seo: z
|
|
.object({
|
|
title: z.string().max(256).optional(),
|
|
description: z.string().max(1024).optional(),
|
|
ogImage: z.string().max(1024).optional(),
|
|
noindex: z.boolean().optional(),
|
|
})
|
|
.passthrough(),
|
|
blocks: z.array(SnapshotBlockSchema),
|
|
});
|
|
|
|
const SnapshotSiteSchema = z.object({
|
|
id: z.string().uuid(),
|
|
slug: z.string().min(2).max(40),
|
|
name: z.string().min(1).max(128),
|
|
theme: z.unknown(),
|
|
navConfig: z.unknown(),
|
|
footerConfig: z.unknown(),
|
|
settings: z.unknown(),
|
|
});
|
|
|
|
const DraftSnapshotSchema = z.object({
|
|
version: z.literal('1'),
|
|
site: SnapshotSiteSchema,
|
|
pages: z.array(SnapshotPageSchema).min(1),
|
|
});
|
|
|
|
// ─── POST /sites/:id/publish ────────────────────────────
|
|
|
|
routes.post('/sites/:id/publish', async (c) => {
|
|
const publishTimer = websitePublishDuration.startTimer();
|
|
const userId = c.get('userId');
|
|
// Space id flows in via an explicit header (mana-auth doesn't yet
|
|
// embed the active space in JWT claims). Nullable — full membership
|
|
// check lands in M6; M2 stores whatever the client declares.
|
|
const spaceIdHeader = c.req.header('X-Mana-Space');
|
|
// Better-Auth nanoid format: 32 URL-safe base64 chars. Keep this lax
|
|
// (no hard length) so it doesn't drift if nanoid config changes.
|
|
const spaceId =
|
|
spaceIdHeader && /^[A-Za-z0-9_-]{16,64}$/.test(spaceIdHeader) ? spaceIdHeader : null;
|
|
const siteId = c.req.param('id');
|
|
|
|
if (!siteId) {
|
|
websitePublishTotal.inc({ result: 'invalid' });
|
|
publishTimer();
|
|
return errorResponse(c, 'siteId required', 400);
|
|
}
|
|
|
|
const parsed = DraftSnapshotSchema.safeParse(await c.req.json().catch(() => null));
|
|
if (!parsed.success) {
|
|
websitePublishTotal.inc({ result: 'invalid' });
|
|
publishTimer();
|
|
return validationError(c, parsed.error.issues);
|
|
}
|
|
|
|
const draft = parsed.data;
|
|
if (draft.site.id !== siteId) {
|
|
websitePublishTotal.inc({ result: 'invalid' });
|
|
publishTimer();
|
|
return errorResponse(c, 'Site id mismatch between path and body', 400, {
|
|
code: 'SITE_ID_MISMATCH',
|
|
});
|
|
}
|
|
if (!isValidSlug(draft.site.slug)) {
|
|
websitePublishTotal.inc({ result: 'invalid' });
|
|
publishTimer();
|
|
return errorResponse(c, `Slug "${draft.site.slug}" is invalid or reserved`, 400, {
|
|
code: 'INVALID_SLUG',
|
|
});
|
|
}
|
|
|
|
// Check slug conflict: is another site currently published with this slug?
|
|
const conflicting = await db
|
|
.select({ id: publishedSnapshots.id, siteId: publishedSnapshots.siteId })
|
|
.from(publishedSnapshots)
|
|
.where(
|
|
and(eq(publishedSnapshots.slug, draft.site.slug), eq(publishedSnapshots.isCurrent, true))
|
|
)
|
|
.limit(1);
|
|
if (conflicting[0] && conflicting[0].siteId !== siteId) {
|
|
websitePublishTotal.inc({ result: 'slug_taken' });
|
|
publishTimer();
|
|
return errorResponse(
|
|
c,
|
|
`Slug "${draft.site.slug}" is already taken by another published site`,
|
|
409,
|
|
{ code: 'SLUG_TAKEN' }
|
|
);
|
|
}
|
|
|
|
// Atomic flip: old→false, new→true. The partial unique index on
|
|
// (slug WHERE is_current=true) catches any concurrent publishers
|
|
// racing for the same slug.
|
|
const now = new Date().toISOString();
|
|
const blob = {
|
|
...draft,
|
|
publishedAt: now,
|
|
publishedBy: userId,
|
|
};
|
|
|
|
try {
|
|
const result = await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(publishedSnapshots)
|
|
.set({ isCurrent: false })
|
|
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)));
|
|
|
|
const [row] = await tx
|
|
.insert(publishedSnapshots)
|
|
.values({
|
|
siteId,
|
|
slug: draft.site.slug,
|
|
blob,
|
|
isCurrent: true,
|
|
publishedBy: userId,
|
|
spaceId,
|
|
})
|
|
.returning({ id: publishedSnapshots.id, publishedAt: publishedSnapshots.publishedAt });
|
|
|
|
return row;
|
|
});
|
|
|
|
if (!result) throw new Error('Insert returned no row');
|
|
|
|
websitePublishTotal.inc({ result: 'success' });
|
|
publishTimer();
|
|
return c.json(
|
|
{
|
|
snapshotId: result.id,
|
|
publishedAt: result.publishedAt.toISOString(),
|
|
publicUrl: `/s/${draft.site.slug}`,
|
|
},
|
|
201
|
|
);
|
|
} catch (err) {
|
|
// Postgres unique-constraint violation → slug conflict we didn't
|
|
// catch in the pre-check (classic race).
|
|
if (err instanceof Error && /unique/i.test(err.message)) {
|
|
websitePublishTotal.inc({ result: 'slug_taken' });
|
|
publishTimer();
|
|
return errorResponse(c, `Slug "${draft.site.slug}" was taken by a concurrent publish`, 409, {
|
|
code: 'SLUG_TAKEN',
|
|
});
|
|
}
|
|
websitePublishTotal.inc({ result: 'error' });
|
|
publishTimer();
|
|
console.error('[website/publish] unhandled error', err);
|
|
return errorResponse(c, err instanceof Error ? err.message : 'Publish failed', 500, {
|
|
code: 'PUBLISH_FAILED',
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── POST /sites/:id/unpublish ──────────────────────────
|
|
|
|
routes.post('/sites/:id/unpublish', async (c) => {
|
|
const siteId = c.req.param('id');
|
|
if (!siteId) return errorResponse(c, 'siteId required', 400);
|
|
|
|
const updated = await db
|
|
.update(publishedSnapshots)
|
|
.set({ isCurrent: false })
|
|
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)))
|
|
.returning({ id: publishedSnapshots.id });
|
|
|
|
if (updated.length === 0) {
|
|
return errorResponse(c, 'No current snapshot to unpublish', 404, {
|
|
code: 'NOT_PUBLISHED',
|
|
});
|
|
}
|
|
|
|
return c.json({ unpublished: updated.length });
|
|
});
|
|
|
|
// ─── GET /sites/:id/snapshots ───────────────────────────
|
|
// Rollback-list: the last 10 snapshots of this site, newest first.
|
|
|
|
routes.get('/sites/:id/snapshots', async (c) => {
|
|
const siteId = c.req.param('id');
|
|
if (!siteId) return errorResponse(c, 'siteId required', 400);
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: publishedSnapshots.id,
|
|
publishedAt: publishedSnapshots.publishedAt,
|
|
publishedBy: publishedSnapshots.publishedBy,
|
|
isCurrent: publishedSnapshots.isCurrent,
|
|
slug: publishedSnapshots.slug,
|
|
})
|
|
.from(publishedSnapshots)
|
|
.where(eq(publishedSnapshots.siteId, siteId))
|
|
.orderBy(desc(publishedSnapshots.publishedAt))
|
|
.limit(10);
|
|
|
|
return c.json({
|
|
snapshots: rows.map((r) => ({
|
|
id: r.id,
|
|
publishedAt: r.publishedAt.toISOString(),
|
|
publishedBy: r.publishedBy,
|
|
isCurrent: r.isCurrent,
|
|
slug: r.slug,
|
|
})),
|
|
});
|
|
});
|
|
|
|
// ─── POST /sites/:id/rollback/:snapshotId ──────────────
|
|
// Flip is_current to point at a historical snapshot.
|
|
|
|
routes.post('/sites/:id/rollback/:snapshotId', async (c) => {
|
|
const siteId = c.req.param('id');
|
|
const snapshotId = c.req.param('snapshotId');
|
|
if (!siteId || !snapshotId) return errorResponse(c, 'siteId and snapshotId required', 400);
|
|
|
|
// Verify the snapshot belongs to this site.
|
|
const target = await db
|
|
.select({ id: publishedSnapshots.id, slug: publishedSnapshots.slug })
|
|
.from(publishedSnapshots)
|
|
.where(and(eq(publishedSnapshots.id, snapshotId), eq(publishedSnapshots.siteId, siteId)))
|
|
.limit(1);
|
|
if (!target[0]) {
|
|
return errorResponse(c, 'Snapshot not found for this site', 404, { code: 'NOT_FOUND' });
|
|
}
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(publishedSnapshots)
|
|
.set({ isCurrent: false })
|
|
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)));
|
|
await tx
|
|
.update(publishedSnapshots)
|
|
.set({ isCurrent: true })
|
|
.where(eq(publishedSnapshots.id, snapshotId));
|
|
});
|
|
|
|
return c.json({ rolledBack: true, slug: target[0].slug });
|
|
});
|
|
|
|
// ─── GET /sites/:id/submissions ─────────────────────────
|
|
// Owner-only list of inbox submissions for a site.
|
|
|
|
routes.get('/sites/:id/submissions', async (c) => {
|
|
const siteId = c.req.param('id');
|
|
if (!siteId) return errorResponse(c, 'siteId required', 400);
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: submissions.id,
|
|
siteId: submissions.siteId,
|
|
blockId: submissions.blockId,
|
|
payload: submissions.payload,
|
|
targetModule: submissions.targetModule,
|
|
status: submissions.status,
|
|
errorMessage: submissions.errorMessage,
|
|
createdAt: submissions.createdAt,
|
|
})
|
|
.from(submissions)
|
|
.where(eq(submissions.siteId, siteId))
|
|
.orderBy(desc(submissions.createdAt))
|
|
.limit(200);
|
|
|
|
return c.json({
|
|
submissions: rows.map((r) => ({
|
|
id: r.id,
|
|
blockId: r.blockId,
|
|
payload: r.payload,
|
|
targetModule: r.targetModule,
|
|
status: r.status,
|
|
errorMessage: r.errorMessage,
|
|
createdAt: r.createdAt.toISOString(),
|
|
})),
|
|
});
|
|
});
|
|
|
|
// ─── DELETE /sites/:id/submissions/:submissionId ───────
|
|
|
|
routes.delete('/sites/:id/submissions/:submissionId', async (c) => {
|
|
const siteId = c.req.param('id');
|
|
const submissionId = c.req.param('submissionId');
|
|
if (!siteId || !submissionId) {
|
|
return errorResponse(c, 'siteId + submissionId required', 400);
|
|
}
|
|
|
|
const deleted = await db
|
|
.delete(submissions)
|
|
.where(and(eq(submissions.id, submissionId), eq(submissions.siteId, siteId)))
|
|
.returning({ id: submissions.id });
|
|
|
|
if (deleted.length === 0) {
|
|
return errorResponse(c, 'Submission not found', 404, { code: 'NOT_FOUND' });
|
|
}
|
|
|
|
return c.json({ deleted: true });
|
|
});
|
|
|
|
export const websitePublishRoutes = routes;
|