;
/** Typed repository bound to the request's database handle. */
@@ -61,6 +72,10 @@ export const createContentModel = <
return {
columns,
definition,
+ publicService: definition.publicApi.enabled
+ ? (c: Context) =>
+ createContentPublicService({ c, columns, definition, table })
+ : undefined,
schemas,
service: (c: Context) =>
createContentService({
diff --git a/packages/vitnode/src/content/server/public-module.ts b/packages/vitnode/src/content/server/public-module.ts
new file mode 100644
index 000000000..8fb440573
--- /dev/null
+++ b/packages/vitnode/src/content/server/public-module.ts
@@ -0,0 +1,60 @@
+import type { BuildModuleReturn } from "../../api/lib/module";
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentModel } from "./model";
+
+import { buildModule } from "../../api/lib/module";
+import { buildContentPublicRoutes } from "./public-routes";
+
+/**
+ * Builds the generated public module for a plugin's content types.
+ *
+ * A **top-level** module, unlike `buildContentAdminModule`, so the paths land
+ * outside `/admin/` and the global admin gate never sees them:
+ *
+ * ```ts
+ * buildApiPlugin({
+ * pluginId: CONFIG_PLUGIN.pluginId,
+ * modules: [
+ * adminModule,
+ * buildContentPublicModule({ pluginId, contentTypes: [articleContent] }),
+ * ],
+ * });
+ * ```
+ *
+ * That yields `GET /api/{pluginId}/content/{publicApi.path}/` and `/{slug}`.
+ *
+ * Pass every model you like: a content type without `publicApi` is skipped, so
+ * the two module builders can take the same array.
+ *
+ *
+ * This module deliberately does **not** set `contentTypes`. `buildApiPlugin`
+ * collects them recursively, and registering a content type twice makes
+ * `validateContentTypes` throw "Duplicate content type id". Only
+ * `buildContentAdminModule` registers.
+ *
+ */
+export const buildContentPublicModule = ({
+ contentTypes,
+ pluginId,
+}: {
+ contentTypes: ContentModel[];
+ pluginId: P;
+}): BuildModuleReturn => {
+ const modules = contentTypes
+ .filter(model => model.definition.publicApi.enabled)
+ .map(model =>
+ buildModule({
+ pluginId,
+ name: model.definition.publicApi.path,
+ routes: buildContentPublicRoutes(model, { pluginId }),
+ }),
+ );
+
+ return buildModule({
+ pluginId,
+ name: "content",
+ routes: [],
+ modules,
+ // No `contentTypes` - see the warning above.
+ });
+};
diff --git a/packages/vitnode/src/content/server/public-routes.test.ts b/packages/vitnode/src/content/server/public-routes.test.ts
new file mode 100644
index 000000000..9372f20de
--- /dev/null
+++ b/packages/vitnode/src/content/server/public-routes.test.ts
@@ -0,0 +1,325 @@
+// @vitest-environment node
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ testCategoryContentType,
+ testPostContentType,
+} from "@/tests/content-fixtures";
+
+import { createContentModel } from "./model";
+import { buildContentPublicModule } from "./public-module";
+import { buildContentPublicRoutes } from "./public-routes";
+
+// Deliberately NOT mocked: `assertStaffPermission` is never reached, because a
+// public route installs no permission middleware. If one ever did, these tests
+// would fail on a missing admin session rather than quietly passing.
+const categories = createContentModel(testCategoryContentType);
+const posts = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
+
+const PLUGIN_ID = "@vitnode/example";
+
+const publicRow = {
+ category: { id: 3 },
+ excerpt: "Prose",
+ publishedAt: new Date("2026-08-01T09:00:00.000Z"),
+ slug: "hello-world",
+ title: "Hello world",
+};
+
+const emptyPage = {
+ edges: [],
+ pageInfo: {
+ count: 0,
+ endCursor: null,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: null,
+ totalCount: 0,
+ },
+};
+
+/**
+ * Mounts the generated public routes with the public service stubbed.
+ *
+ * No session middleware and no admin context: the request arrives exactly as an
+ * anonymous one would.
+ */
+const harness = () => {
+ const service = {
+ findById: vi.fn(),
+ findBySlug: vi.fn(),
+ findMany: vi.fn(),
+ };
+
+ vi.spyOn(posts, "publicService", "get").mockReturnValue(() => service);
+
+ const app = new OpenAPIHono();
+ for (const { handler, route } of buildContentPublicRoutes(posts, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ return { app, service };
+};
+
+describe("public list route", () => {
+ it("answers without any session at all", async () => {
+ const { app, service } = harness();
+ service.findMany.mockResolvedValue(emptyPage);
+
+ const response = await app.request("/");
+
+ // The whole point: no staff permission, no admin middleware, no 401.
+ expect(response.status).toBe(200);
+ });
+
+ it("returns edges and pageInfo", async () => {
+ const { app, service } = harness();
+ service.findMany.mockResolvedValue({
+ ...emptyPage,
+ edges: [publicRow],
+ pageInfo: { ...emptyPage.pageInfo, count: 1, totalCount: 1 },
+ });
+
+ const body = (await (await app.request("/")).json()) as {
+ edges: Record[];
+ pageInfo: { totalCount: number };
+ };
+
+ expect(body.pageInfo.totalCount).toBe(1);
+ expect(Object.keys(body.edges[0]).sort()).toEqual([
+ "category",
+ "excerpt",
+ "publishedAt",
+ "slug",
+ "title",
+ ]);
+ });
+
+ it("passes pagination, search, order and filters to the service", async () => {
+ const { app, service } = harness();
+ service.findMany.mockResolvedValue(emptyPage);
+
+ await app.request(
+ "/?first=5&cursor=12&search=hello&order=asc&orderBy=title&category=3",
+ );
+
+ expect(service.findMany).toHaveBeenCalledWith({
+ filters: { category: 3 },
+ orderBy: { column: "title", order: "asc" },
+ query: { cursor: "12", first: "5", last: undefined, search: "hello" },
+ });
+ });
+
+ it("ignores a query parameter it does not recognise", async () => {
+ const { app, service } = harness();
+ service.findMany.mockResolvedValue(emptyPage);
+
+ // A stale bookmark or a tracking parameter is not a client error.
+ const response = await app.request("/?utm_source=newsletter&nope=1");
+
+ expect(response.status).toBe(200);
+ expect(service.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ filters: {} }),
+ );
+ });
+
+ it("rejects an order column outside the public allowlist", async () => {
+ const { app, service } = harness();
+ service.findMany.mockResolvedValue(emptyPage);
+
+ // `views` is private; `createdAt` is orderable in the AdminCP but not here.
+ expect((await app.request("/?orderBy=views")).status).toBe(400);
+ expect((await app.request("/?orderBy=createdAt")).status).toBe(400);
+ expect(service.findMany).not.toHaveBeenCalled();
+ });
+
+ it("rejects a malformed filter value", async () => {
+ const { app, service } = harness();
+ service.findMany.mockResolvedValue(emptyPage);
+
+ expect((await app.request("/?category=banana")).status).toBe(400);
+ expect(service.findMany).not.toHaveBeenCalled();
+ });
+
+ it("never accepts a private field as a filter", async () => {
+ const { app, service } = harness();
+ service.findMany.mockResolvedValue(emptyPage);
+
+ await app.request("/?views=10&author=1&status=draft");
+
+ // Not a 400 - the filter schema simply has no such key, so it cannot reach
+ // the query builder. Draft-hunting by query string does not work.
+ expect(service.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ filters: {} }),
+ );
+ });
+});
+
+describe("public detail route", () => {
+ it("returns a published row", async () => {
+ const { app, service } = harness();
+ service.findBySlug.mockResolvedValue(publicRow);
+
+ const response = await app.request("/hello-world");
+
+ expect(response.status).toBe(200);
+ expect(service.findBySlug).toHaveBeenCalledWith("hello-world");
+ });
+
+ it("returns exactly the allowlisted keys", async () => {
+ const { app, service } = harness();
+ service.findBySlug.mockResolvedValue(publicRow);
+
+ const body = (await (await app.request("/hello-world")).json()) as Record<
+ string,
+ unknown
+ >;
+
+ expect(Object.keys(body).sort()).toEqual([
+ "category",
+ "excerpt",
+ "publishedAt",
+ "slug",
+ "title",
+ ]);
+ expect(body).not.toHaveProperty("views");
+ expect(body).not.toHaveProperty("author");
+ expect(body).not.toHaveProperty("status");
+ expect(body).not.toHaveProperty("id");
+ });
+
+ it("projects the relation as an identifier and nothing else", async () => {
+ const { app, service } = harness();
+ service.findBySlug.mockResolvedValue(publicRow);
+
+ const body = (await (await app.request("/hello-world")).json()) as {
+ category: unknown;
+ };
+
+ expect(body.category).toEqual({ id: 3 });
+ });
+
+ it("documents the relation without a label", () => {
+ // The response schema is the contract a generated client is built from, so
+ // it has to say the same thing the handler does.
+ const relation = (
+ testPostContentType.schemas.publicSelectObject.shape
+ .category as unknown as { shape: Record }
+ ).shape;
+
+ expect(Object.keys(relation)).toEqual(["id"]);
+ });
+
+ it("is a 404 for a draft, an unpublished row and a typo alike", async () => {
+ const { app, service } = harness();
+ // The service returns `null` for all three, so the route cannot tell them
+ // apart - and neither can anyone probing for unpublished URLs.
+ service.findBySlug.mockResolvedValue(null);
+
+ const response = await app.request("/some-draft");
+
+ expect(response.status).toBe(404);
+ });
+
+ it("never answers 403, which would confirm the row exists", async () => {
+ const { app, service } = harness();
+ service.findBySlug.mockResolvedValue(null);
+
+ expect((await app.request("/some-draft")).status).not.toBe(403);
+ expect((await app.request("/some-draft")).status).not.toBe(401);
+ });
+});
+
+describe("generated surface", () => {
+ it("builds only GET routes", () => {
+ const routes = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID });
+
+ expect(routes.map(item => item.route.method)).toEqual(["get", "get"]);
+ });
+
+ it("documents only the two read operations", () => {
+ const app = new OpenAPIHono();
+ for (const { handler, route } of buildContentPublicRoutes(posts, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ const document = app.getOpenAPI31Document({
+ info: { title: "test", version: "1" },
+ openapi: "3.1.0",
+ });
+ const paths = document.paths ?? {};
+
+ expect(Object.keys(paths).sort()).toEqual(["/", "/{slug}"]);
+ for (const operations of Object.values(paths)) {
+ // No post, put, patch or delete anywhere under the public prefix.
+ expect(Object.keys(operations ?? {})).toEqual(["get"]);
+ }
+ });
+
+ it("installs no staff-permission guard", () => {
+ // `buildRoute` always adds `pluginMiddleware`, and adds a second handler
+ // only when `adminStaffPermission` is set. Exactly one means none.
+ const routes = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID });
+
+ for (const { route } of routes) {
+ // `createRoute` types `middleware` per route config, so read it through
+ // the shape `buildRoute` actually assembles.
+ const { middleware } = route as unknown as { middleware: unknown[] };
+
+ expect(middleware).toHaveLength(1);
+ }
+ });
+});
+
+describe("buildContentPublicModule", () => {
+ it("skips a content type with no public API", () => {
+ const module = buildContentPublicModule({
+ contentTypes: [posts, categories],
+ pluginId: PLUGIN_ID,
+ });
+
+ expect(module.modules?.map(item => item.name)).toEqual(["posts"]);
+ });
+
+ it("names each sub-module after its public path", () => {
+ const module = buildContentPublicModule({
+ contentTypes: [posts],
+ pluginId: PLUGIN_ID,
+ });
+
+ expect(module.name).toBe("content");
+ expect(module.modules?.[0].name).toBe(testPostContentType.publicApi.path);
+ });
+
+ it("registers no content types", () => {
+ // `buildApiPlugin` collects `contentTypes` recursively, so registering them
+ // here as well would make `validateContentTypes` throw "Duplicate content
+ // type id". Only `buildContentAdminModule` registers.
+ const module = buildContentPublicModule({
+ contentTypes: [posts, categories],
+ pluginId: PLUGIN_ID,
+ });
+
+ expect(module.contentTypes).toBeUndefined();
+ for (const child of module.modules ?? []) {
+ expect(child.contentTypes).toBeUndefined();
+ }
+ });
+
+ it("has no `/admin/` anywhere in its paths", () => {
+ // The global admin gate is a `path.includes("/admin/")` substring test, so
+ // a public route that landed under one would demand a staff session.
+ const routes = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID });
+
+ for (const { route } of routes) {
+ expect(route.path).not.toContain("admin");
+ }
+ });
+});
diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts
new file mode 100644
index 000000000..2e12f751c
--- /dev/null
+++ b/packages/vitnode/src/content/server/public-routes.ts
@@ -0,0 +1,157 @@
+import type { Context } from "hono";
+
+import { z } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+
+import type {
+ AnyContentTypeDefinition,
+ ContentPublicFilterInput,
+ ContentPublicOrderableFieldName,
+} from "../types";
+import type { ContentModel } from "./model";
+import type { ContentPublicService } from "./public-service";
+
+import { buildRoute } from "../../api/lib/route";
+import {
+ zodPaginationPageInfo,
+ zodPaginationQuery,
+} from "../../api/lib/with-pagination";
+import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const";
+import { ContentEngineError } from "../errors";
+import { publicOrderableColumns } from "../registry";
+
+/**
+ * The two read-only routes one public content type gets.
+ *
+ * ```http
+ * GET /api/{pluginId}/content/{publicApi.path}/
+ * GET /api/{pluginId}/content/{publicApi.path}/{slug}
+ * ```
+ *
+ * No `adminStaffPermission` and no `/admin/` anywhere in the path, which is
+ * exactly how every other public route in VitNode is public: by omission. The
+ * global middleware still runs, so `c.get("user")` is populated (possibly
+ * `null`) and the IP rate limiter still applies.
+ *
+ * Only `get` is ever built here. There is no public create, update, delete,
+ * publish or unpublish, and no flag that would add one.
+ */
+export const buildContentPublicRoutes = <
+ TDefinition extends AnyContentTypeDefinition,
+ P extends string,
+>(
+ model: ContentModel,
+ { pluginId }: { pluginId: P },
+) => {
+ const { definition, schemas } = model;
+ const label = definition.admin.label;
+
+ const service = (c: Context): ContentPublicService => {
+ const build = model.publicService;
+ if (!build) {
+ throw new ContentEngineError(
+ "This content type has no public API, so it should not have a public route either.",
+ { contentTypeId: definition.id },
+ );
+ }
+
+ return build(c);
+ };
+
+ // `orderBy` is a literal enum, so a column outside the public allowlist is a
+ // 400 at validation time and shows up in the OpenAPI document. The service
+ // keeps its own allowlist check for callers that did not come through here.
+ const orderable = publicOrderableColumns(definition) as [string, ...string[]];
+ const paginationQuery = zodPaginationQuery.extend({
+ order: z.enum(["asc", "desc"]).optional(),
+ orderBy: z.enum(orderable).optional(),
+ search: z.string().optional(),
+ });
+ const listQuery = paginationQuery.extend(schemas.publicFilters.shape);
+
+ const notFound = () =>
+ new HTTPException(404, {
+ message: `${label.singular} not found.`,
+ });
+
+ const list = buildRoute({
+ pluginId,
+ route: {
+ method: "get",
+ path: "/",
+ description: `List published ${label.plural}`,
+ request: { query: listQuery },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ edges: z.array(schemas.publicSelectObject),
+ pageInfo: zodPaginationPageInfo,
+ }),
+ },
+ },
+ description: `Up to ${CONTENT_PUBLIC_MAX_PAGE_SIZE} published ${label.plural}`,
+ },
+ 400: { description: "Invalid query parameters" },
+ },
+ },
+ handler: async c => {
+ // The whole query string goes through both schemas, each reading only the
+ // keys it owns, and neither is strict - so a stale bookmark or a tracking
+ // parameter is ignored rather than turned into a 400. `orderBy` is the
+ // exception: a *present* but unknown column fails validation.
+ const raw = c.req.query();
+ const { cursor, first, last, order, orderBy, search } =
+ paginationQuery.parse(raw);
+ const filters = schemas.publicFilters.parse(
+ raw,
+ ) as ContentPublicFilterInput;
+
+ const data = await service(c).findMany({
+ filters,
+ // Both narrowings restate what the schemas just proved: `orderBy` came
+ // out of a literal enum built from `publicApi.orderableFields`, and
+ // `filters` out of a shape built from `publicApi.filterableFields`. The
+ // service re-checks both, since the type is not what protects the query.
+ orderBy: {
+ column: orderBy as ContentPublicOrderableFieldName,
+ order,
+ },
+ query: { cursor, first, last, search },
+ });
+
+ return c.json(data, 200);
+ },
+ });
+
+ const detail = buildRoute({
+ pluginId,
+ route: {
+ method: "get",
+ path: "/{slug}",
+ description: `Get one published ${label.singular} by slug`,
+ request: { params: schemas.publicParams },
+ responses: {
+ 200: {
+ content: {
+ "application/json": { schema: schemas.publicSelectObject },
+ },
+ description: `${label.singular} found`,
+ },
+ 404: { description: `${label.singular} not found` },
+ },
+ },
+ handler: async c => {
+ // A draft, an unpublished row, a cleared publication date and a typo are
+ // all the same 404. A 403 would confirm the record exists, which is the
+ // one thing a draft URL must not do.
+ const row = await service(c).findBySlug(c.req.param("slug"));
+ if (!row) throw notFound();
+
+ return c.json(row, 200);
+ },
+ });
+
+ return [list, detail];
+};
diff --git a/packages/vitnode/src/content/server/public-service.test.ts b/packages/vitnode/src/content/server/public-service.test.ts
new file mode 100644
index 000000000..0eec81d8c
--- /dev/null
+++ b/packages/vitnode/src/content/server/public-service.test.ts
@@ -0,0 +1,403 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { PgDialect } from "drizzle-orm/pg-core";
+import { describe, expect, it } from "vitest";
+
+import {
+ testCategoryContentType,
+ testPostContentType,
+} from "@/tests/content-fixtures";
+
+import { ContentEngineError } from "../errors";
+import { createContentModel } from "./model";
+
+const categories = createContentModel(testCategoryContentType);
+const posts = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
+
+const dialect = new PgDialect();
+
+interface RecordedCall {
+ arg: unknown;
+ op: string;
+}
+
+/** The same chainable Drizzle stand-in `service.test.ts` uses. */
+const createDbMock = (results: unknown[][]) => {
+ const calls: RecordedCall[] = [];
+ const queue = [...results];
+
+ const chain = (rows: unknown[]) => {
+ const record = (op: string, arg: unknown) => {
+ calls.push({ arg, op });
+
+ return builder;
+ };
+
+ const builder = {
+ $dynamic: () => builder,
+ from: (value: unknown) => record("from", value),
+ leftJoin: (value: unknown) => record("leftJoin", value),
+ limit: (value: unknown) => record("limit", value),
+ orderBy: (value: unknown) => record("orderBy", value),
+ then: async (resolve: (rows: unknown[]) => TResult) =>
+ Promise.resolve(rows).then(resolve),
+ where: (value: unknown) => record("where", value),
+ };
+
+ return builder;
+ };
+
+ const c = {
+ get: (key: string) =>
+ key === "db"
+ ? {
+ select: (arg: unknown) => {
+ calls.push({ arg, op: "select" });
+
+ return chain(queue.shift() ?? []);
+ },
+ }
+ : undefined,
+ } as Context;
+
+ return { c, calls };
+};
+
+const opsOf = (calls: RecordedCall[], op: string) =>
+ calls.filter(call => call.op === op).map(call => call.arg);
+
+/** The compiled SQL of the last `where` the service handed Drizzle. */
+const lastWhere = (calls: RecordedCall[]) => {
+ const wheres = opsOf(calls, "where");
+ const condition = wheres.at(-1);
+ if (!condition) throw new Error("Expected a where clause.");
+
+ return dialect.sqlToQuery(condition as never);
+};
+
+const publicService = (results: unknown[][]) => {
+ const { c, calls } = createDbMock(results);
+ const service = posts.publicService?.(c);
+ if (!service) throw new Error("Expected a public service.");
+
+ return { calls, service };
+};
+
+const storedRow = {
+ category: 3,
+ excerpt: "Prose",
+ id: 12,
+ publishedAt: new Date("2026-08-01T09:00:00.000Z"),
+ slug: "hello-world",
+ title: "Hello world",
+};
+
+describe("model.publicService", () => {
+ it("exists only for a content type with a public API", () => {
+ expect(posts.publicService).toBeDefined();
+ expect(categories.publicService).toBeUndefined();
+ });
+
+ it("has no write methods at all", () => {
+ const { service } = publicService([]);
+
+ // Not "omitted from a filtered view" - there is nothing to omit, because
+ // this is a different object from `model.service`.
+ for (const method of [
+ "create",
+ "update",
+ "delete",
+ "publish",
+ "unpublish",
+ "options",
+ ]) {
+ expect(service).not.toHaveProperty(method);
+ }
+
+ expect(Object.keys(service).sort()).toEqual([
+ "findById",
+ "findBySlug",
+ "findMany",
+ ]);
+ });
+});
+
+describe("the published invariant", () => {
+ const PREDICATE =
+ '"test_posts"."status" = $1 and "test_posts"."publishedAt" is not null and "test_posts"."publishedAt" <= now()';
+
+ it("is applied by findBySlug", async () => {
+ const { calls, service } = publicService([[storedRow]]);
+
+ await service.findBySlug("hello-world");
+
+ const { params, sql } = lastWhere(calls);
+ expect(sql).toContain(PREDICATE);
+ expect(sql).toContain('"test_posts"."slug" = ');
+ expect(params).toEqual(["published", "hello-world"]);
+ });
+
+ it("is applied by findById", async () => {
+ const { calls, service } = publicService([[storedRow]]);
+
+ await service.findById(12);
+
+ expect(lastWhere(calls).sql).toContain(PREDICATE);
+ expect(lastWhere(calls).sql).toContain('"test_posts"."id" = ');
+ });
+
+ it("is applied by findMany", async () => {
+ // A count query, then the page itself.
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ await service.findMany();
+
+ expect(opsOf(calls, "where").length).toBeGreaterThan(0);
+ expect(
+ opsOf(calls, "where").some(condition =>
+ dialect.sqlToQuery(condition as never).sql.includes(PREDICATE),
+ ),
+ ).toBe(true);
+ });
+
+ it("cannot be turned off by a caller", async () => {
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ // There is no `where` argument and no `includeDrafts` flag on
+ // `ContentPublicFindManyArgs` - the predicate is not a parameter.
+ await service.findMany({ filters: { category: 3 } });
+
+ const combined = opsOf(calls, "where")
+ .map(condition => dialect.sqlToQuery(condition as never).sql)
+ .join(" ");
+ expect(combined).toContain(PREDICATE);
+ });
+});
+
+describe("projection", () => {
+ it("selects only the allowlisted columns, plus id for the cursor", async () => {
+ const { calls, service } = publicService([[storedRow]]);
+
+ await service.findBySlug("hello-world");
+
+ expect(Object.keys(opsOf(calls, "select")[0] as object).sort()).toEqual([
+ "category",
+ "excerpt",
+ "id",
+ "publishedAt",
+ "slug",
+ "title",
+ ]);
+ });
+
+ it("never selects a private column", async () => {
+ const { calls, service } = publicService([[storedRow]]);
+
+ await service.findBySlug("hello-world");
+
+ // `views`, `author` and `status` are not in `publicApi.fields`, so they do
+ // not leave Postgres in the first place.
+ const selected = Object.keys(opsOf(calls, "select")[0] as object);
+ expect(selected).not.toContain("views");
+ expect(selected).not.toContain("author");
+ expect(selected).not.toContain("status");
+ });
+
+ it("returns exactly the allowlisted keys", async () => {
+ const { service } = publicService([[storedRow]]);
+
+ const row = await service.findBySlug("hello-world");
+
+ expect(Object.keys(row ?? {}).sort()).toEqual([
+ "category",
+ "excerpt",
+ "publishedAt",
+ "slug",
+ "title",
+ ]);
+ });
+
+ it("drops the cursor id, which the allowlist does not name", async () => {
+ // The one column fetched beyond the allowlist: `withPagination` reads the
+ // cursor off the row. It is removed again here, and that is the whole
+ // projection boundary.
+ const { service } = publicService([[storedRow]]);
+
+ expect(await service.findBySlug("hello-world")).not.toHaveProperty("id");
+ });
+
+ it("projects a relation down to an identifier", async () => {
+ const { service } = publicService([[storedRow]]);
+
+ expect(await service.findBySlug("hello-world")).toMatchObject({
+ category: { id: 3 },
+ });
+ });
+
+ it("puts no label on a relation", async () => {
+ // The only label available is the target's `admin.titleField` - admin
+ // metadata, from a row that may itself be a draft and may never have opted
+ // into a public API at all.
+ const row = await publicService([[storedRow]]).service.findBySlug("x");
+
+ expect(row?.category).toEqual({ id: 3 });
+ expect(row?.category).not.toHaveProperty("label");
+ });
+
+ it("joins nothing at all", async () => {
+ const { calls, service } = publicService([[storedRow]]);
+
+ await service.findBySlug("hello-world");
+
+ // No target table is read, so no target column can be selected by mistake.
+ expect(opsOf(calls, "leftJoin")).toHaveLength(0);
+ });
+
+ it("never selects the target's title column", async () => {
+ const { calls, service } = publicService([[storedRow]]);
+
+ await service.findBySlug("hello-world");
+
+ // `test.category`'s `admin.titleField` is `title`, reached through the
+ // `label__category` alias in the admin service. It is absent here.
+ const selected = Object.keys(opsOf(calls, "select")[0] as object);
+ expect(selected.some(name => name.startsWith("label__"))).toBe(false);
+ });
+
+ it("keeps a nullable relation null", async () => {
+ const { service } = publicService([[{ ...storedRow, category: null }]]);
+
+ expect((await service.findBySlug("hello-world"))?.category).toBeNull();
+ });
+
+ it("returns null for a missing row", async () => {
+ const { service } = publicService([[]]);
+
+ await expect(service.findBySlug("nope")).resolves.toBeNull();
+ });
+});
+
+describe("filters", () => {
+ it("accepts a configured filterable field", async () => {
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ await service.findMany({ filters: { category: 3 } });
+
+ const combined = opsOf(calls, "where")
+ .map(condition => dialect.sqlToQuery(condition as never))
+ .find(query => query.sql.includes('"test_posts"."category" = '));
+ expect(combined?.params).toContain(3);
+ });
+
+ it("rejects a field that is exposed but not filterable", async () => {
+ const { service } = publicService([[{ count: 0 }], []]);
+
+ // `title` is public, but `filterableFields` is `["category"]`. Being
+ // readable does not make a column a query parameter.
+ await expect(
+ service.findMany({ filters: { title: "Hello" } }),
+ ).rejects.toThrow(/Filter "title" is not in the allowlist/);
+ });
+
+ it("rejects a private field", async () => {
+ const { service } = publicService([[{ count: 0 }], []]);
+
+ await expect(
+ service.findMany({
+ filters: { views: 10 } as never,
+ }),
+ ).rejects.toBeInstanceOf(ContentEngineError);
+ });
+
+ it("rejects the publication status, so drafts cannot be asked for", async () => {
+ const { service } = publicService([[{ count: 0 }], []]);
+
+ await expect(
+ service.findMany({ filters: { status: "draft" } as never }),
+ ).rejects.toThrow(/not in the allowlist/);
+ });
+});
+
+describe("search", () => {
+ it("scans only the configured searchable columns", async () => {
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ await service.findMany({ query: { search: "hello" } });
+
+ const combined = opsOf(calls, "where")
+ .map(condition => dialect.sqlToQuery(condition as never).sql)
+ .join(" ");
+ expect(combined).toContain('"test_posts"."title" ilike');
+ expect(combined).toContain('"test_posts"."excerpt" ilike');
+ // A private column cannot be probed by searching for it either.
+ expect(combined).not.toContain('"test_posts"."views"');
+ });
+
+ it("escapes the wildcards", async () => {
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ await service.findMany({ query: { search: "100%" } });
+
+ const params = opsOf(calls, "where").flatMap(
+ condition => dialect.sqlToQuery(condition as never).params,
+ );
+ expect(params).toContain("%100\\%%");
+ });
+});
+
+describe("ordering", () => {
+ it("accepts a column from the public allowlist", async () => {
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ await service.findMany({ orderBy: { column: "title", order: "asc" } });
+
+ expect(opsOf(calls, "orderBy")).toHaveLength(1);
+ });
+
+ it("rejects a column the public allowlist does not name", async () => {
+ const { service } = publicService([[{ count: 0 }], []]);
+
+ // Orderable in the AdminCP, but the public list has its own, smaller list.
+ await expect(
+ service.findMany({ orderBy: { column: "createdAt" as never } }),
+ ).rejects.toThrow(/Cannot order by "createdAt"/);
+ });
+
+ it("rejects a private column", async () => {
+ const { service } = publicService([[{ count: 0 }], []]);
+
+ await expect(
+ service.findMany({ orderBy: { column: "views" as never } }),
+ ).rejects.toThrow(/Cannot order by "views"/);
+ });
+
+ it("falls back to the configured default", async () => {
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ await service.findMany();
+
+ expect(opsOf(calls, "orderBy")).toHaveLength(1);
+ });
+});
+
+describe("pagination", () => {
+ it("caps the page size below the admin limit", async () => {
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ await service.findMany({ query: { first: "500" } });
+
+ // `withPagination` would otherwise clamp to 100 and ask for 101 rows.
+ expect(opsOf(calls, "limit")[0]).toBe(51);
+ });
+
+ it("leaves a reasonable page size alone", async () => {
+ const { calls, service } = publicService([[{ count: 0 }], []]);
+
+ await service.findMany({ query: { first: "10" } });
+
+ expect(opsOf(calls, "limit")[0]).toBe(11);
+ });
+});
diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts
new file mode 100644
index 000000000..ca7f602e9
--- /dev/null
+++ b/packages/vitnode/src/content/server/public-service.ts
@@ -0,0 +1,242 @@
+import type { ColumnBaseConfig, SQL } from "drizzle-orm";
+import type {
+ PgColumn,
+ PgTableWithColumns,
+ TableConfig,
+} from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+
+import { and, eq } from "drizzle-orm";
+
+import type {
+ AnyContentTypeDefinition,
+ ContentPublicFilterInput,
+ ContentPublicListRow,
+ ContentPublicOrderableFieldName,
+ ContentPublicSelect,
+} from "../types";
+import type { ContentPageInfo } from "./service";
+
+import { withPagination } from "../../api/lib/with-pagination";
+import {
+ CONTENT_PUBLIC_DEFAULT_PAGE_SIZE,
+ CONTENT_PUBLIC_MAX_PAGE_SIZE,
+} from "../const";
+import { ContentEngineError } from "../errors";
+import { publicOrderableColumns } from "../registry";
+import { publicationColumns, publishedCondition } from "./publication";
+import {
+ buildFilterCondition,
+ buildOrderColumn,
+ buildSearchCondition,
+} from "./query";
+
+export interface ContentPublicFindManyArgs {
+ /** Equality filters, restricted to `publicApi.filterableFields`. */
+ filters?: ContentPublicFilterInput;
+ orderBy?: {
+ column?: ContentPublicOrderableFieldName;
+ order?: "asc" | "desc";
+ };
+ /** Raw pagination query (`cursor`, `first`, `last`, `search`). */
+ query?: { cursor?: string; first?: string; last?: string; search?: string };
+}
+
+/**
+ * The read-only half of a content type, for anonymous callers.
+ *
+ * There is no `create`, `update`, `delete`, `publish` or `unpublish` to omit -
+ * this is a different object from `model.service`, not a filtered view of it,
+ * so a public write is not something you can reach by accident.
+ */
+export interface ContentPublicService {
+ /** `null` unless the row exists *and* is published. */
+ findById: (id: number) => Promise | null>;
+ /** The public detail lookup. `null` for a draft, an unpublished row or a typo. */
+ findBySlug: (
+ slug: string,
+ ) => Promise | null>;
+ findMany: (args?: ContentPublicFindManyArgs) => Promise<{
+ edges: ContentPublicListRow[];
+ pageInfo: ContentPageInfo;
+ }>;
+}
+
+/** Public pages are smaller than admin ones, and the cap is lower too. */
+const clampPageSize = (value: string | undefined): string | undefined => {
+ if (value === undefined) return undefined;
+
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) return value;
+
+ return String(Math.min(parsed, CONTENT_PUBLIC_MAX_PAGE_SIZE));
+};
+
+/**
+ * Builds the read-only service a public route serves from.
+ *
+ * Two things make this safe rather than "the admin service with fewer methods":
+ *
+ * 1. **The published predicate is not a parameter.** Every method `and`s it in
+ * itself, so there is no argument a caller could forget and no code path
+ * that reaches an unpublished row.
+ * 2. **The `SELECT` is built from `publicApi.fields`.** A private column is
+ * never fetched, so it cannot be leaked by a mistake further downstream.
+ * The one exception is `id`, which the cursor needs; it is dropped from the
+ * projected row unless the allowlist names it, and that boundary is tested.
+ *
+ * It also joins nothing. An exposed relation is projected from the foreign key
+ * the row already carries, so a target table is never read - which is what
+ * makes it impossible for one content type's allowlist to publish another's
+ * administrative metadata.
+ */
+export const createContentPublicService = <
+ TDefinition extends AnyContentTypeDefinition,
+>({
+ c,
+ columns,
+ definition,
+ table,
+}: {
+ c: Context;
+ columns: Record;
+ definition: TDefinition;
+ table: PgTableWithColumns;
+}): ContentPublicService => {
+ const contentTypeId = definition.id;
+ const publicApi = definition.publicApi;
+
+ if (!publicApi.enabled) {
+ throw new ContentEngineError(
+ "This content type has no public API. Add `publicApi: { enabled: true, path, fields }` to generate one.",
+ { contentTypeId },
+ );
+ }
+
+ const fields = definition.fields;
+ // `publicApi` cannot be enabled without publication, so this never throws
+ // here - it is what turns the erased column map into the two columns the
+ // predicate needs.
+ const published = publicationColumns(definition, columns);
+ const primaryCursor = columns.id as PgColumn<
+ ColumnBaseConfig<"number", string>
+ >;
+ const exposed = publicApi.fields;
+ const exposesId = exposed.includes("id");
+ // A `user` field is never exposable, so this is only ever relations.
+ const exposedRelations = new Set(
+ exposed.filter(name => fields[name]?.kind === "relation"),
+ );
+ const searchColumns = publicApi.searchableFields.map(name => columns[name]);
+ const orderable = publicOrderableColumns(definition);
+
+ /** Own columns, plus `id` for the cursor whether or not it is exposed. */
+ const selection = (): Record => ({
+ id: primaryCursor,
+ ...Object.fromEntries(exposed.map(name => [name, columns[name]])),
+ });
+
+ /**
+ * Turns one raw row into the public projection: relations collapse to
+ * `{ id }`, and the cursor `id` disappears unless the allowlist asked for it.
+ *
+ * The relation identifier is the foreign key already on this row, so no
+ * target table is read and no label is invented.
+ */
+ const project = (
+ row: Record,
+ ): ContentPublicSelect => {
+ const projected: Record = {};
+
+ for (const name of exposed) {
+ if (!exposedRelations.has(name)) {
+ projected[name] = row[name];
+ continue;
+ }
+
+ const id = row[name];
+ projected[name] = typeof id === "number" ? { id } : null;
+ }
+
+ if (exposesId) projected.id = row.id;
+
+ return projected as ContentPublicSelect;
+ };
+
+ const readOne = async (
+ condition: SQL,
+ ): Promise | null> => {
+ const [row] = await c
+ .get("db")
+ .select(selection())
+ .from(table)
+ .where(and(publishedCondition(published), condition))
+ .limit(1);
+
+ return row ? project(row) : null;
+ };
+
+ return {
+ findById: async id => await readOne(eq(primaryCursor, id)),
+
+ findBySlug: async slug =>
+ await readOne(eq(columns[publicApi.slugField], slug)),
+
+ findMany: async ({ filters = {}, orderBy, query = {} } = {}) => {
+ const conditions = [
+ // Not optional, not a parameter, and first: whatever else a caller
+ // passes, an unpublished row cannot come back.
+ publishedCondition(published),
+ buildFilterCondition({
+ allowed: publicApi.filterableFields,
+ columns,
+ contentTypeId,
+ fields,
+ filters,
+ }),
+ buildSearchCondition(searchColumns, query.search),
+ ].filter((item): item is SQL => item !== undefined);
+
+ const data = await withPagination({
+ c,
+ params: {
+ query: {
+ ...query,
+ first: clampPageSize(query.first),
+ last: clampPageSize(query.last),
+ // Folded into `where` above so the term is escaped; handing it to
+ // `withPagination` would build an unescaped `ilike`.
+ search: undefined,
+ },
+ },
+ primaryCursor,
+ orderBy: {
+ column: buildOrderColumn({
+ columns,
+ contentTypeId,
+ fallback: publicApi.defaultOrderBy,
+ orderBy: orderBy?.column,
+ orderable,
+ }),
+ order: orderBy?.order ?? publicApi.defaultOrder,
+ },
+ table,
+ where: conditions.length > 1 ? and(...conditions) : conditions[0],
+ query: async ({ limit, orderBy: order, where }) =>
+ await c
+ .get("db")
+ .select(selection())
+ .from(table)
+ .where(where)
+ .orderBy(order)
+ .limit(
+ typeof limit === "number"
+ ? Math.min(limit, CONTENT_PUBLIC_MAX_PAGE_SIZE + 1)
+ : CONTENT_PUBLIC_DEFAULT_PAGE_SIZE,
+ ),
+ });
+
+ return { edges: data.edges.map(project), pageInfo: data.pageInfo };
+ },
+ };
+};
diff --git a/packages/vitnode/src/content/server/publication.test-d.ts b/packages/vitnode/src/content/server/publication.test-d.ts
new file mode 100644
index 000000000..b3a488278
--- /dev/null
+++ b/packages/vitnode/src/content/server/publication.test-d.ts
@@ -0,0 +1,51 @@
+import type { PgColumn } from "drizzle-orm/pg-core";
+
+import { describe, it } from "vitest";
+
+import {
+ testArticleContentType,
+ testCategoryContentType,
+ testPostContentType,
+} from "@/tests/content-fixtures";
+
+import { createContentModel } from "./model";
+import { publishedCondition } from "./publication";
+
+const categories = createContentModel(testCategoryContentType);
+const articles = createContentModel(testArticleContentType, {
+ references: { category: () => categories.table.id },
+});
+const posts = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
+
+describe("publishedCondition", () => {
+ it("accepts the columns of a publication content type", () => {
+ void publishedCondition(posts.columns);
+ });
+
+ it("rejects the columns of a content type without publication", () => {
+ // @ts-expect-error - publication is off, so there is no `status` or
+ // `publishedAt` column to compare against
+ void publishedCondition(categories.columns);
+ });
+
+ it("accepts a hand-assembled pair of columns", () => {
+ // Structural, deliberately: the helper is a predicate over two columns, not
+ // over a `ContentModel`, so a custom table with the same two names works.
+ const manual: { publishedAt: PgColumn; status: PgColumn } = {
+ publishedAt: posts.columns.publishedAt,
+ status: posts.columns.status,
+ };
+
+ void publishedCondition(manual);
+ });
+
+ it("accepts a Stage 1 content type that declares both names itself", () => {
+ // The flip side of being structural. This fixture declares its own `status`
+ // enum and `publishedAt` date field, so the predicate compiles and compares
+ // real columns - it just is not the generated lifecycle. Enabling
+ // `publication` is what makes the two columns mean what this helper assumes.
+ void publishedCondition(articles.columns);
+ });
+});
diff --git a/packages/vitnode/src/content/server/publication.test.ts b/packages/vitnode/src/content/server/publication.test.ts
new file mode 100644
index 000000000..16346e30b
--- /dev/null
+++ b/packages/vitnode/src/content/server/publication.test.ts
@@ -0,0 +1,98 @@
+// @vitest-environment node
+import type { SQL } from "drizzle-orm";
+
+import { PgDialect } from "drizzle-orm/pg-core";
+import { describe, expect, it } from "vitest";
+
+import {
+ testCategoryContentType,
+ testPostContentType,
+} from "@/tests/content-fixtures";
+
+import { ContentEngineError } from "../errors";
+import { createContentModel } from "./model";
+import {
+ publicationColumns,
+ publicationMethods,
+ publishedCondition,
+} from "./publication";
+
+const categories = createContentModel(testCategoryContentType);
+const posts = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
+
+const dialect = new PgDialect();
+
+/** The SQL text and bound parameters Drizzle would actually send. */
+const compile = (condition: SQL | undefined) => {
+ if (!condition) throw new Error("Expected a condition.");
+
+ return dialect.sqlToQuery(condition);
+};
+
+describe("publishedCondition", () => {
+ it("compiles the full published invariant", () => {
+ const { params, sql } = compile(publishedCondition(posts.columns));
+
+ // All three clauses, in one predicate. Dropping `IS NOT NULL` would leak a
+ // row whose timestamp was cleared by hand, which is the whole reason this
+ // is exported rather than written out at each call site.
+ expect(sql).toBe(
+ '("test_posts"."status" = $1 and "test_posts"."publishedAt" is not null and "test_posts"."publishedAt" <= now())',
+ );
+ expect(params).toEqual(["published"]);
+ });
+
+ it("binds the status rather than inlining it", () => {
+ expect(compile(publishedCondition(posts.columns)).sql).not.toContain(
+ "'published'",
+ );
+ });
+});
+
+describe("publicationColumns", () => {
+ it("picks the two columns out of an erased map", () => {
+ // Generic code holds `Record`, which does not satisfy the
+ // narrowed parameter type. This is the runtime step that makes it true.
+ const narrowed = publicationColumns(testPostContentType, posts.columns);
+
+ expect(Object.keys(narrowed).sort()).toEqual(["publishedAt", "status"]);
+ expect(compile(publishedCondition(narrowed)).params).toEqual(["published"]);
+ });
+
+ it("throws for a content type without publication", () => {
+ expect(() =>
+ publicationColumns(testCategoryContentType, categories.columns),
+ ).toThrow(ContentEngineError);
+ });
+
+ it("throws when the columns are missing, whatever the flag says", () => {
+ // Belt and braces: the presence check is what stops `undefined` reaching
+ // Drizzle if a caller hands over the wrong column map.
+ expect(() => publicationColumns(testPostContentType, {})).toThrow(
+ ContentEngineError,
+ );
+ });
+});
+
+describe("publicationMethods", () => {
+ it("returns the publish methods for a publication content type", () => {
+ const service = posts.service({ get: () => undefined } as never);
+ const methods = publicationMethods(testPostContentType, service);
+
+ expect(typeof methods.publish).toBe("function");
+ expect(typeof methods.unpublish).toBe("function");
+ });
+
+ it("throws for a content type without publication", () => {
+ const service = categories.service({ get: () => undefined } as never);
+
+ expect(() => publicationMethods(testCategoryContentType, service)).toThrow(
+ ContentEngineError,
+ );
+ expect(() => publicationMethods(testCategoryContentType, service)).toThrow(
+ /publication: \{ enabled: true \}/,
+ );
+ });
+});
diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts
new file mode 100644
index 000000000..1c8471d45
--- /dev/null
+++ b/packages/vitnode/src/content/server/publication.ts
@@ -0,0 +1,121 @@
+import type { SQL } from "drizzle-orm";
+import type { PgColumn } from "drizzle-orm/pg-core";
+
+import { and, eq, isNotNull, lte, sql } from "drizzle-orm";
+
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentPublicationMethods, ContentService } from "./service";
+
+import { ContentEngineError } from "../errors";
+
+/**
+ * The two columns `publication: { enabled: true }` generates.
+ *
+ * Structural on purpose: a `ContentModel`'s `columns` map satisfies it only when
+ * publication is enabled, because `ContentColumnName` adds those two names under
+ * the same conditional. Passing the columns of a content type without
+ * publication is therefore a compile error rather than a query against columns
+ * that do not exist.
+ */
+export interface PublicationColumns {
+ publishedAt: PgColumn;
+ status: PgColumn;
+}
+
+/**
+ * Picks the two publication columns out of a model's column map.
+ *
+ * Generic code is written against `AnyContentTypeDefinition`, whose
+ * `publication.enabled` is `boolean`, so its `columns` map is a plain
+ * `Record` and does not satisfy {@link PublicationColumns}.
+ * This is the runtime step that makes it true - a real presence check rather
+ * than a cast, since the whole point of narrowing the parameter was to stop
+ * `undefined` reaching Drizzle.
+ */
+export const publicationColumns = (
+ definition: AnyContentTypeDefinition,
+ columns: Record,
+): PublicationColumns => {
+ const { publishedAt, status } = columns;
+
+ if (!definition.publication.enabled || !publishedAt || !status) {
+ throw new ContentEngineError(
+ "The published predicate needs `publication: { enabled: true }` on the content type.",
+ { contentTypeId: definition.id },
+ );
+ }
+
+ return { publishedAt, status };
+};
+
+/**
+ * The one definition of "published".
+ *
+ * ```sql
+ * status = 'published' AND published_at IS NOT NULL AND published_at <= NOW()
+ * ```
+ *
+ * The generated public read layer applies this centrally: every method on
+ * `model.publicService` `and`s it in itself, so there is no argument a caller
+ * could forget, and the two generated public routes go through that service.
+ *
+ * It is also exported for **hand-written plugin queries**, which is where the
+ * predicate would otherwise be retyped by hand - exactly the thing worth
+ * getting wrong once, because forgetting the `IS NOT NULL` leaks a row whose
+ * timestamp was cleared:
+ *
+ * ```ts
+ * const rows = await c
+ * .get("db")
+ * .select({ id: articles.table.id, title: articles.table.title })
+ * .from(articles.table)
+ * .where(
+ * publishedCondition(publicationColumns(articleContentType, articles.columns)),
+ * );
+ * ```
+ *
+ * One definition either way, so a custom route and a generated one can never
+ * disagree about what "published" means.
+ *
+ * `published_at <= now()` is always true today - `publish` only ever stamps
+ * `now()` - but stating the invariant costs nothing and makes scheduled
+ * publishing a purely additive change later.
+ *
+ * Enabling `publication` still exposes nothing on its own: it adds the
+ * lifecycle, and `publicApi.enabled` is what generates the public routes. On a
+ * content type without that block, this predicate is only ever reached by a
+ * route you wrote.
+ */
+export const publishedCondition = (
+ columns: PublicationColumns,
+): SQL | undefined =>
+ and(
+ eq(columns.status, "published"),
+ isNotNull(columns.publishedAt),
+ lte(columns.publishedAt, sql`now()`),
+ );
+
+/**
+ * Narrows a service to its publication methods.
+ *
+ * Route and module code is generic over `AnyContentTypeDefinition`, whose
+ * `publication.enabled` is `boolean` rather than `true`, so the conditional
+ * members resolve to `never` there. Every call site checks
+ * `definition.publication.enabled` first - this is the accompanying type-level
+ * step, in the same spirit as the `isReferenceField` predicate in `routes.ts`.
+ */
+export const publicationMethods = <
+ TDefinition extends AnyContentTypeDefinition,
+>(
+ definition: TDefinition,
+ service: ContentService,
+): ContentPublicationMethods => {
+ if (!definition.publication.enabled) {
+ throw new ContentEngineError(
+ "publish/unpublish need `publication: { enabled: true }` on the content type.",
+ { contentTypeId: definition.id },
+ );
+ }
+
+ return service as unknown as ContentPublicationMethods;
+};
diff --git a/packages/vitnode/src/content/server/query.test.ts b/packages/vitnode/src/content/server/query.test.ts
index 3e71a6baa..c21b2357a 100644
--- a/packages/vitnode/src/content/server/query.test.ts
+++ b/packages/vitnode/src/content/server/query.test.ts
@@ -9,6 +9,7 @@ import { field } from "@/content/fields";
import {
testArticleContentType,
testCategoryContentType,
+ testPostContentType,
} from "@/tests/content-fixtures";
import { ContentEngineError } from "../errors";
@@ -62,6 +63,12 @@ const referenceTable = createContentTable(referenceType, {
});
const referenceColumns = contentTableColumns(referenceType, referenceTable);
+/** Publication enabled, and deliberately declaring neither generated name. */
+const postTable = createContentTable(testPostContentType, {
+ references: { category: () => categories.id },
+});
+const postColumns = contentTableColumns(testPostContentType, postTable);
+
const dialect = new PgDialect();
/** The SQL text and bound parameters Drizzle would actually send. */
@@ -228,6 +235,94 @@ describe("buildFilterCondition", () => {
);
});
});
+
+ /**
+ * `status` on a publication content type is a *generated* column: there is no
+ * field descriptor behind it, so none of the checks above apply and it needs
+ * its own guard. The generated Zod schema narrows the value on the HTTP path;
+ * these cover the direct-service path, where a cast or a runtime-built object
+ * can carry anything.
+ */
+ describe("publication status", () => {
+ const publicationFilter = (filters: Record) =>
+ buildFilterCondition({
+ columns: postColumns,
+ contentTypeId: testPostContentType.id,
+ fields: testPostContentType.fields,
+ filters,
+ publication: true,
+ });
+
+ it.each(["draft", "published"])(
+ "filters by the generated %s status",
+ status => {
+ const { params, sql } = compile(publicationFilter({ status }));
+
+ expect(sql).toBe('"test_posts"."status" = $1');
+ expect(params).toEqual([status]);
+ },
+ );
+
+ it("combines with an ordinary field filter", () => {
+ const { params, sql } = compile(
+ publicationFilter({ category: 3, status: "published" }),
+ );
+
+ expect(sql).toBe(
+ '("test_posts"."category" = $1 and "test_posts"."status" = $2)',
+ );
+ expect(params).toEqual([3, "published"]);
+ });
+
+ it.each([
+ ["a value from a Stage 1 enum", "archived"],
+ ["an unrelated string", "sideways"],
+ ["an empty string", ""],
+ ["a number", 1],
+ ["null", null],
+ ["a boolean", true],
+ ["an object", {}],
+ ])("rejects %s before it reaches SQL", (_case, status) => {
+ expect(() => publicationFilter({ status })).toThrow(ContentEngineError);
+ });
+
+ it("names the value and the allowed set", () => {
+ expect(() => publicationFilter({ status: "archived" })).toThrow(
+ /Invalid publication status "archived"\. Allowed values: draft, published\./,
+ );
+ });
+
+ it("names the content type", () => {
+ expect(() => publicationFilter({ status: "archived" })).toThrow(
+ /test\.post/,
+ );
+ });
+
+ it("still rejects an unknown filter name with the unknown-filter error", () => {
+ expect(() => publicationFilter({ nope: 1 })).toThrow(
+ /Unknown filter "nope"/,
+ );
+ });
+
+ // The guard is keyed on `publication`, not on the column name, so a Stage 1
+ // content type that declares its own `status` enum is untouched by it.
+ it("leaves a declared status enum on its own values", () => {
+ expect(compile(filter({ status: "archived" })).params).toEqual([
+ "archived",
+ ]);
+ });
+
+ it("does not accept a status filter when publication is off", () => {
+ expect(() =>
+ buildFilterCondition({
+ columns: postColumns,
+ contentTypeId: testPostContentType.id,
+ fields: testPostContentType.fields,
+ filters: { status: "published" },
+ }),
+ ).toThrow(/Unknown filter "status"/);
+ });
+ });
});
describe("buildOrderColumn", () => {
diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts
index 0f30bd419..2659e96bf 100644
--- a/packages/vitnode/src/content/server/query.ts
+++ b/packages/vitnode/src/content/server/query.ts
@@ -7,6 +7,8 @@ import type { ContentFieldDescriptor, ContentFieldMap } from "../types";
import {
CONTENT_FILTERABLE_FIELD_KINDS,
+ CONTENT_PUBLICATION_STATUSES,
+ isContentPublicationStatus,
isFilterableFieldKind,
} from "../const";
import { ContentEngineError } from "../errors";
@@ -54,21 +56,55 @@ const filterValue = (
* and the type is not what protects the query.
*/
export const buildFilterCondition = ({
+ allowed,
columns,
contentTypeId,
fields,
filters,
+ publication = false,
}: {
+ /**
+ * Narrows the filterable set further, for a caller with its own allowlist -
+ * the public service, whose `filterableFields` is a deliberate subset of
+ * what the admin list accepts.
+ */
+ allowed?: readonly string[];
columns: Record;
contentTypeId: string;
fields: ContentFieldMap;
filters: Record;
+ /** Whether `status` is a generated column and therefore filterable. */
+ publication?: boolean;
}): SQL | undefined => {
const conditions: SQL[] = [];
for (const [name, raw] of Object.entries(filters)) {
if (raw === undefined) continue;
+ if (allowed && !allowed.includes(name)) {
+ throw new ContentEngineError(
+ `Filter "${name}" is not in the allowlist. Allowed: ${allowed.length > 0 ? allowed.join(", ") : "(none)"}.`,
+ { contentTypeId },
+ );
+ }
+
+ // `status` is a generated column, not a declared field, so there is no
+ // descriptor to drive the checks below. The generated schema's `z.enum`
+ // already narrowed it on the HTTP path; this re-checks the value for the
+ // direct-service path, where a cast or a runtime-built object could put
+ // anything here.
+ if (publication && name === "status" && columns.status) {
+ if (!isContentPublicationStatus(raw)) {
+ throw new ContentEngineError(
+ `Invalid publication status ${JSON.stringify(raw)}. Allowed values: ${CONTENT_PUBLICATION_STATUSES.join(", ")}.`,
+ { contentTypeId },
+ );
+ }
+
+ conditions.push(eq(columns.status, raw));
+ continue;
+ }
+
const fieldValue = fields[name];
const column = columns[name];
if (!fieldValue || !column) {
diff --git a/packages/vitnode/src/content/server/references.ts b/packages/vitnode/src/content/server/references.ts
new file mode 100644
index 000000000..dd00ce03c
--- /dev/null
+++ b/packages/vitnode/src/content/server/references.ts
@@ -0,0 +1,98 @@
+import type {
+ PgColumn,
+ PgTable,
+ PgTableWithColumns,
+ TableConfig,
+} from "drizzle-orm/pg-core";
+
+import { alias, getTableConfig } from "drizzle-orm/pg-core";
+
+import type { AnyContentTypeDefinition } from "../types";
+
+import { ContentEngineError } from "../errors";
+
+export interface ReferenceTarget {
+ /** Aliased, so two relations pointing at the same table can both be joined. */
+ aliased: PgTable;
+ idColumn: PgColumn;
+ labelColumn: PgColumn;
+ owner: PgColumn;
+}
+
+export const LABEL_PREFIX = "label__";
+
+/**
+ * Turns a joined label column value into display text. Only the shapes a title
+ * column can actually hold are handled - anything else becomes `null` rather
+ * than "[object Object]".
+ */
+export const toLabel = (value: unknown): null | string => {
+ if (value === null || value === undefined) return null;
+ if (typeof value === "string") return value;
+ if (typeof value === "number" || typeof value === "bigint") {
+ return value.toString();
+ }
+ if (value instanceof Date) return value.toISOString();
+
+ return null;
+};
+
+/**
+ * Works out which table and column supply the display label for each
+ * `user`/`relation` field.
+ *
+ * The target comes from the foreign keys Drizzle already resolved on the table,
+ * so the engine needs no separate table registry - and because the FK thunk is
+ * evaluated here, circular content type references stay safe.
+ *
+ * **Administrative only.** A label is read from the target's
+ * `admin.titleField`, which is metadata for the AdminCP: it may name a field
+ * the target never publishes, and the row it comes from may itself be a draft.
+ * The public projection therefore does not use this at all - an exposed
+ * relation there is `{ id }`, taken straight off the foreign key.
+ */
+export const resolveReferenceTargets = (
+ definition: AnyContentTypeDefinition,
+ table: PgTableWithColumns,
+ columns: Record,
+): Record => {
+ const fields = definition.fields;
+ const byOwnerColumn = new Map(
+ getTableConfig(table)
+ .foreignKeys.map(foreignKey => foreignKey.reference())
+ .map(reference => [reference.columns[0]?.name, reference]),
+ );
+
+ const targets: Record = {};
+
+ for (const [name, fieldValue] of Object.entries(fields)) {
+ if (fieldValue.kind !== "relation" && fieldValue.kind !== "user") continue;
+
+ const reference = byOwnerColumn.get(name);
+ if (!reference) {
+ throw new ContentEngineError(
+ `Field "${name}" has no foreign key on "${definition.tableName}".`,
+ { contentTypeId: definition.id },
+ );
+ }
+
+ // `user` labels come from the core users table; a relation uses the target
+ // content type's own `admin.titleField`.
+ const labelName =
+ fieldValue.kind === "user"
+ ? "name"
+ : (fieldValue.target().admin.titleField ?? "id");
+
+ const aliased = alias(reference.foreignTable, `${LABEL_PREFIX}${name}`);
+ const aliasedColumns = aliased as unknown as Record;
+
+ targets[name] = {
+ aliased,
+ idColumn: aliasedColumns.id,
+ labelColumn: aliasedColumns[labelName] ?? aliasedColumns.id,
+ owner: columns[name],
+ };
+ }
+
+ return targets;
+};
diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts
index 8b2969837..f7c1f7c51 100644
--- a/packages/vitnode/src/content/server/routes.test.ts
+++ b/packages/vitnode/src/content/server/routes.test.ts
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
testArticleContentType,
testCategoryContentType,
+ testPostContentType,
} from "@/tests/content-fixtures";
import { createContentModel } from "./model";
@@ -30,6 +31,9 @@ const categories = createContentModel(testCategoryContentType);
const articles = createContentModel(testArticleContentType, {
references: { category: () => categories.table.id },
});
+const posts = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
const PLUGIN_ID = "@vitnode/example";
@@ -96,6 +100,47 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => {
return { app, emitted, service };
};
+/**
+ * The same harness for a content type with publication enabled, which adds two
+ * routes and two service methods.
+ */
+const publicationHarness = ({ allow = true }: { allow?: boolean } = {}) => {
+ const emitted: Harness["emitted"] = [];
+ const service = {
+ create: vi.fn(),
+ delete: vi.fn(),
+ findById: vi.fn(),
+ findMany: vi.fn(),
+ options: vi.fn(),
+ publish: vi.fn(),
+ unpublish: vi.fn(),
+ update: vi.fn(),
+ };
+
+ permissionGranted = allow;
+ vi.spyOn(posts, "service").mockReturnValue(service);
+
+ const app = new OpenAPIHono();
+ app.use("*", async (c, next) => {
+ c.set("events", {
+ emit: async (name: string, payload: unknown) => {
+ await Promise.resolve();
+ emitted.push({ name, payload });
+ },
+ } as unknown as Context["var"]["events"]);
+ c.set("admin", allow ? { user: adminUser } : null);
+ await next();
+ });
+
+ for (const { handler, route } of buildContentRoutes(posts, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ return { app, emitted, service };
+};
+
const json = (body: unknown) => ({
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
@@ -412,6 +457,125 @@ describe("generated content routes", () => {
});
});
+ describe("publication", () => {
+ const publishedRow = {
+ ...row,
+ publishedAt: new Date("2026-08-01T09:00:00.000Z"),
+ status: "published" as const,
+ };
+
+ it("generates no publish routes without publication", () => {
+ const paths = buildContentRoutes(articles, { pluginId: PLUGIN_ID }).map(
+ entry => `${entry.route.method} ${entry.route.path}`,
+ );
+
+ expect(paths).not.toContain("post /{id}/publish");
+ expect(paths).not.toContain("post /{id}/unpublish");
+ });
+
+ it.each([
+ ["publish", publishedRow, "published"],
+ ["unpublish", row, "unpublished"],
+ ] as const)(
+ "%ss and emits the matching event",
+ async (action, resultRow, event) => {
+ const { app, emitted, service } = publicationHarness();
+ service[action].mockResolvedValue({
+ changed: true,
+ publishedAt: publishedRow.publishedAt,
+ row: resultRow,
+ });
+
+ const res = await app.request(`/7/${action}`, { method: "POST" });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({ changed: true });
+ expect(service[action]).toHaveBeenCalledWith(7);
+ expect(emitted).toHaveLength(1);
+ expect(emitted[0].name).toBe(`content.test.post.${event}`);
+ },
+ );
+
+ it("carries the publication date on the published event only", async () => {
+ const { app, emitted, service } = publicationHarness();
+ service.publish.mockResolvedValue({
+ changed: true,
+ publishedAt: publishedRow.publishedAt,
+ row: publishedRow,
+ });
+
+ await app.request("/7/publish", { method: "POST" });
+
+ expect(emitted[0].payload).toEqual({
+ contentId: 7,
+ publishedAt: publishedRow.publishedAt,
+ });
+ });
+
+ it("answers 200 but emits nothing when nothing changed", async () => {
+ const { app, emitted, service } = publicationHarness();
+ service.publish.mockResolvedValue({
+ changed: false,
+ publishedAt: publishedRow.publishedAt,
+ row: publishedRow,
+ });
+
+ const res = await app.request("/7/publish", { method: "POST" });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({ changed: false });
+ // No outbox, so a listener firing on every button press would be doing
+ // duplicate work for free.
+ expect(emitted).toEqual([]);
+ });
+
+ it("answers 404 for a missing record", async () => {
+ const { app, service } = publicationHarness();
+ service.publish.mockResolvedValue(null);
+
+ const res = await app.request("/7/publish", { method: "POST" });
+
+ expect(res.status).toBe(404);
+ });
+
+ it("answers 400 for a non-numeric identifier", async () => {
+ const { app } = publicationHarness();
+
+ const res = await app.request("/abc/publish", { method: "POST" });
+
+ expect(res.status).toBe(400);
+ });
+
+ it.each(["publish", "unpublish"])(
+ "requires can_publish for %s",
+ async action => {
+ const { app } = publicationHarness({ allow: false });
+
+ const res = await app.request(`/7/${action}`, { method: "POST" });
+
+ expect(res.status).toBe(403);
+ },
+ );
+
+ it("documents both operations", () => {
+ const doc = publicationHarness().app.getOpenAPIDocument({
+ info: { title: "t", version: "1" },
+ openapi: "3.0.0",
+ });
+
+ expect(Object.keys(doc.paths).sort()).toEqual([
+ "/",
+ "/options/{field}",
+ "/{id}",
+ "/{id}/publish",
+ "/{id}/unpublish",
+ ]);
+ expect(
+ Object.keys(doc.paths["/{id}/publish"].post?.responses ?? {}).sort(),
+ ).toEqual(["200", "400", "404"]);
+ });
+ });
+
describe("OpenAPI", () => {
const document = () =>
harness().app.getOpenAPIDocument({
diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts
index bcf4a345b..00304fd09 100644
--- a/packages/vitnode/src/content/server/routes.ts
+++ b/packages/vitnode/src/content/server/routes.ts
@@ -19,6 +19,7 @@ import { CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS } from "../const";
import { orderableColumns } from "../registry";
import { emitContentEvent } from "./emit";
import { withHttpErrors } from "./http-errors";
+import { publicationMethods } from "./publication";
const zodLabels = z.record(z.string(), z.string().nullable());
@@ -59,6 +60,11 @@ export const buildContentRoutes = <
const label = definition.admin.label;
const listRow = schemas.selectObject.extend({ labels: zodLabels });
+ const publicationResponse = z.object({
+ /** `false` when the record was already in the requested state. */
+ changed: z.boolean(),
+ row: schemas.selectObject,
+ });
const referenceFieldNames = Object.entries(definition.fields)
.filter(
@@ -278,6 +284,55 @@ export const buildContentRoutes = <
},
});
+ // Publishing is a domain operation, not a field update: `status` and
+ // `publishedAt` are absent from the strict create/update schemas, so these
+ // two routes are the only way to move them over HTTP. Both are idempotent -
+ // publishing an already-published record is a 200 that changed nothing.
+ const publicationRoute = (action: "publish" | "unpublish") =>
+ buildRoute({
+ pluginId,
+ adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.publish },
+ route: {
+ method: "post",
+ path: `/{id}/${action}` as const,
+ description: `${action === "publish" ? "Publish" : "Unpublish"} a ${label.singular}`,
+ request: { params: schemas.params },
+ responses: {
+ 200: jsonResponse(
+ publicationResponse,
+ `${label.singular} ${action}ed, or already in that state`,
+ ),
+ 400: invalidIdentifier,
+ 404: { description: `${label.singular} not found` },
+ },
+ },
+ handler: async c => {
+ const id = identifier(c);
+ const service = publicationMethods(definition, model.service(c));
+
+ const result = await withHttpErrors(
+ "update",
+ async () => await service[action](id),
+ );
+ if (!result) throw notFound(definition);
+
+ // A no-op emits nothing: there is no outbox, so a listener that fires
+ // on every button press would be doing duplicate work for free.
+ if (result.changed) {
+ await emitContentEvent(
+ c,
+ definition,
+ action === "publish" ? "published" : "unpublished",
+ action === "publish" && result.publishedAt
+ ? { contentId: id, publishedAt: result.publishedAt }
+ : { contentId: id },
+ );
+ }
+
+ return c.json({ changed: result.changed, row: result.row }, 200);
+ },
+ });
+
const remove = buildRoute({
pluginId,
adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete },
@@ -308,5 +363,15 @@ export const buildContentRoutes = <
},
});
- return [list, options, detail, create, update, remove];
+ return [
+ list,
+ options,
+ detail,
+ create,
+ update,
+ remove,
+ ...(definition.publication.enabled
+ ? [publicationRoute("publish"), publicationRoute("unpublish")]
+ : []),
+ ];
};
diff --git a/packages/vitnode/src/content/server/service.test-d.ts b/packages/vitnode/src/content/server/service.test-d.ts
index 5607e6fd2..1e42f0f91 100644
--- a/packages/vitnode/src/content/server/service.test-d.ts
+++ b/packages/vitnode/src/content/server/service.test-d.ts
@@ -5,6 +5,7 @@ import { describe, expectTypeOf, it } from "vitest";
import {
testArticleContentType,
testCategoryContentType,
+ testPostContentType,
} from "@/tests/content-fixtures";
import type {
@@ -20,11 +21,16 @@ const categories = createContentModel(testCategoryContentType);
const articles = createContentModel(testArticleContentType, {
references: { category: () => categories.table.id },
});
+const posts = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
type ArticleType = typeof testArticleContentType;
// Never executed - the type checker is the whole point.
const service = articles.service({} as Context);
+const postService = posts.service({} as Context);
+const categoryService = categories.service({} as Context);
describe("findMany filters", () => {
it("accepts every filterable field", () => {
@@ -104,6 +110,71 @@ describe("findMany ordering", () => {
});
});
+describe("publication ordering", () => {
+ it("accepts the generated columns on a publication content type", () => {
+ void postService.findMany({ orderBy: { column: "status" } });
+ void postService.findMany({
+ orderBy: { column: "publishedAt", order: "desc" },
+ });
+ });
+
+ it("still accepts declared fields and system columns", () => {
+ void postService.findMany({ orderBy: { column: "title" } });
+ void postService.findMany({ orderBy: { column: "updatedAt" } });
+ });
+
+ // The category fixture has publication disabled *and* declares neither name,
+ // which is what makes this a real negative. The article fixture would pass
+ // for the wrong reason: it declares its own `status` and `publishedAt`.
+ it("does not invent them for a content type without publication", () => {
+ // @ts-expect-error - `status` is not a column of this content type
+ void categoryService.findMany({ orderBy: { column: "status" } });
+ // @ts-expect-error - `publishedAt` is not a column of this content type
+ void categoryService.findMany({ orderBy: { column: "publishedAt" } });
+ });
+
+ it("leaves a Stage 1 content type ordering by its own fields", () => {
+ // Accepted because they are declared fields, not generated columns.
+ void service.findMany({ orderBy: { column: "status" } });
+ void service.findMany({ orderBy: { column: "publishedAt" } });
+ });
+});
+
+describe("publication filters", () => {
+ it("accepts the two generated statuses", () => {
+ void postService.findMany({ filters: { status: "draft" } });
+ void postService.findMany({ filters: { status: "published" } });
+ });
+
+ it("rejects anything else", () => {
+ void postService.findMany({
+ // @ts-expect-error - "archived" is not a generated publication status
+ filters: { status: "archived" },
+ });
+ });
+
+ it("is absent from a content type without publication", () => {
+ void categoryService.findMany({
+ // @ts-expect-error - no `status` column to filter on
+ filters: { status: "draft" },
+ });
+ });
+});
+
+describe("publication service methods", () => {
+ it("exist on a publication content type", () => {
+ void postService.publish(1);
+ void postService.unpublish(1, {});
+ });
+
+ it("are absent everywhere else", () => {
+ // @ts-expect-error - publication is not enabled on this content type
+ void service.publish(1);
+ // @ts-expect-error - publication is not enabled on this content type
+ void categoryService.unpublish(1);
+ });
+});
+
describe("options", () => {
it("accepts relation and user fields", () => {
void service.options("category");
diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts
index 5c74280b2..8123e566a 100644
--- a/packages/vitnode/src/content/server/service.test.ts
+++ b/packages/vitnode/src/content/server/service.test.ts
@@ -7,6 +7,7 @@ import { ZodError } from "zod";
import {
testArticleContentType,
testCategoryContentType,
+ testPostContentType,
} from "@/tests/content-fixtures";
import type {
@@ -15,7 +16,9 @@ import type {
ContentUpdateInput,
} from "../types";
-import { ContentEngineError } from "../errors";
+import { defineContentType } from "../define";
+import { ContentEngineError, ContentInputError } from "../errors";
+import { field } from "../fields";
import { createContentModel } from "./model";
type ArticleType = typeof testArticleContentType;
@@ -24,6 +27,9 @@ const categories = createContentModel(testCategoryContentType);
const articles = createContentModel(testArticleContentType, {
references: { category: () => categories.table.id },
});
+const posts = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
interface RecordedCall {
arg: unknown;
@@ -437,4 +443,249 @@ describe("content service", () => {
expect(opsOf(outer.calls, "insert")).toHaveLength(1);
});
});
+
+ describe("publication", () => {
+ const published = {
+ id: 1,
+ publishedAt: new Date("2026-08-01T09:00:00.000Z"),
+ status: "published",
+ title: "Hello",
+ };
+ const draft = { ...published, status: "draft" };
+
+ it("is absent from a content type without publication", () => {
+ const service = articles.service(createDbMock([]).c);
+
+ expect(service.publish).toBeUndefined();
+ expect(service.unpublish).toBeUndefined();
+ });
+
+ describe("publish", () => {
+ it("writes the status and coalesces the publication date", async () => {
+ const { c, calls } = createDbMock([[published]]);
+
+ const result = await posts.service(c).publish?.(1);
+
+ expect(result).toEqual({
+ changed: true,
+ publishedAt: published.publishedAt,
+ row: published,
+ });
+ // COALESCE, so a republish keeps the original date - it is passed as
+ // SQL rather than a JS value on purpose.
+ expect(opsOf(calls, "set")).toHaveLength(1);
+ expect(opsOf(calls, "set")[0]).toMatchObject({ status: "published" });
+ // One statement in the happy path: no read-then-write race.
+ expect(opsOf(calls, "select")).toHaveLength(0);
+ });
+
+ it("is a no-op when the row is already published", async () => {
+ // The conditional UPDATE matches nothing, so the follow-up read is what
+ // tells "already published" apart from "no such row".
+ const { c, calls } = createDbMock([[], [published]]);
+
+ const result = await posts.service(c).publish?.(1);
+
+ expect(result).toEqual({
+ changed: false,
+ publishedAt: published.publishedAt,
+ row: published,
+ });
+ expect(opsOf(calls, "select")).toHaveLength(1);
+ });
+
+ it("returns null when the row does not exist", async () => {
+ const { c } = createDbMock([[], []]);
+
+ await expect(posts.service(c).publish?.(1)).resolves.toBeNull();
+ });
+
+ it("joins a caller's transaction", async () => {
+ const { c } = createDbMock([]);
+ const outer = createDbMock([[published]]);
+
+ await posts.service(c).publish?.(1, { tx: outer.c.get("db") });
+
+ expect(opsOf(outer.calls, "update")).toHaveLength(1);
+ });
+ });
+
+ describe("unpublish", () => {
+ it("flips the status and leaves the publication date alone", async () => {
+ const { c, calls } = createDbMock([[draft]]);
+
+ const result = await posts.service(c).unpublish?.(1);
+
+ expect(result).toEqual({
+ changed: true,
+ publishedAt: draft.publishedAt,
+ row: draft,
+ });
+ // `publishedAt` means "first published at", so unpublishing must not
+ // clear it - a republish would otherwise reorder the public feed.
+ expect(opsOf(calls, "set")).toEqual([{ status: "draft" }]);
+ });
+
+ it("is a no-op when the row is already a draft", async () => {
+ const { c } = createDbMock([[], [draft]]);
+
+ await expect(posts.service(c).unpublish?.(1)).resolves.toMatchObject({
+ changed: false,
+ });
+ });
+
+ it("returns null when the row does not exist", async () => {
+ const { c } = createDbMock([[], []]);
+
+ await expect(posts.service(c).unpublish?.(1)).resolves.toBeNull();
+ });
+ });
+
+ it("selects the generated columns on every read", async () => {
+ const { c, calls } = createDbMock([[published]]);
+
+ await posts.service(c).findById(1);
+
+ expect(Object.keys(opsOf(calls, "select")[0] as object)).toEqual(
+ expect.arrayContaining(["status", "publishedAt"]),
+ );
+ });
+ });
+
+ describe("slug", () => {
+ const createPost = async (values: Record) => {
+ const { c, calls } = createDbMock([[{ id: 1 }]]);
+
+ await posts
+ .service(c)
+ .create(values as ContentCreateInput);
+
+ return opsOf(calls, "values")[0] as Record;
+ };
+
+ const updatePost = async (
+ current: Record,
+ values: Record,
+ ) => {
+ const { c, calls } = createDbMock([[current], [{ ...current }]]);
+
+ await posts.service(c).update(1, values);
+
+ return opsOf(calls, "set")[0] as Record | undefined;
+ };
+
+ describe("create", () => {
+ it("derives the slug from the source field", async () => {
+ const values = await createPost({ category: 2, title: "Hello World" });
+
+ expect(values.slug).toBe("hello-world");
+ });
+
+ it("normalises a slug the caller supplied", async () => {
+ const values = await createPost({
+ category: 2,
+ slug: " Hello World! ",
+ title: "Something else",
+ });
+
+ // Supplied, so the source is ignored - but it is still normalised,
+ // because the same rules have to hold whoever wrote the value.
+ expect(values.slug).toBe("hello-world");
+ });
+
+ it("transliterates the source", async () => {
+ const values = await createPost({ category: 2, title: "Zażółć gęślą" });
+
+ expect(values.slug).toBe("zazolc-gesla");
+ });
+
+ it("rejects a source that folds to nothing", async () => {
+ // No random suffix and no numeric fallback: an unaddressable row is
+ // refused, and the message says how to fix it.
+ await expect(
+ createPost({ category: 2, title: "日本語のタイトル" }),
+ ).rejects.toThrow(/Could not derive "slug" from "title"/);
+ });
+
+ it("rejects a supplied slug that folds to nothing", async () => {
+ await expect(
+ createPost({ category: 2, slug: "!!!", title: "Fine title" }),
+ ).rejects.toThrow(/normalises to an empty slug/);
+ });
+
+ it("reports the failure as a client error", async () => {
+ // `ContentInputError` is what the generated routes turn into a 400;
+ // every other engine error is a configuration bug and a 500.
+ await expect(
+ createPost({ category: 2, title: "🎉🎉🎉" }),
+ ).rejects.toBeInstanceOf(ContentInputError);
+ });
+
+ it("truncates to the descriptor's maxLength", async () => {
+ const short = createContentModel(
+ defineContentType({
+ id: "test.short-slug",
+ tableName: "test_short_slugs",
+ fields: {
+ title: field.text({ required: true }),
+ slug: field.slug({ maxLength: 8, source: "title" }),
+ },
+ admin: { label: { plural: "Shorts", singular: "Short" } },
+ }),
+ );
+ const { c, calls } = createDbMock([[{ id: 1 }]]);
+
+ await short.service(c).create({ title: "Hello World" });
+
+ expect((opsOf(calls, "values")[0] as { slug: string }).slug).toBe(
+ "hello-wo",
+ );
+ });
+ });
+
+ describe("update", () => {
+ const stored = { id: 1, slug: "hello-world", title: "Hello World" };
+
+ it("leaves the slug alone when the source field changes", async () => {
+ // The whole point of a slug: a published URL does not move because
+ // somebody fixed a typo in the title.
+ const set = await updatePost(stored, { title: "Goodbye World" });
+
+ expect(set).toEqual({ title: "Goodbye World" });
+ expect(set).not.toHaveProperty("slug");
+ });
+
+ it("changes the slug when it is sent explicitly", async () => {
+ const set = await updatePost(stored, { slug: "Brand New Slug" });
+
+ expect(set).toEqual({ slug: "brand-new-slug" });
+ });
+
+ it("treats a re-sent slug as no change", async () => {
+ // Normalised before the diff, so "Hello World" and "hello-world" are
+ // the same stored value and the write is skipped.
+ const set = await updatePost(stored, { slug: "Hello World" });
+
+ expect(set).toBeUndefined();
+ });
+
+ it("rejects a slug that folds to nothing", async () => {
+ await expect(updatePost(stored, { slug: "???" })).rejects.toThrow(
+ ContentInputError,
+ );
+ });
+
+ it("never re-derives from the source", async () => {
+ const set = await updatePost(stored, {
+ slug: "explicit-one",
+ title: "A Totally New Title",
+ });
+
+ expect(set).toEqual({
+ slug: "explicit-one",
+ title: "A Totally New Title",
+ });
+ });
+ });
+ });
});
diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts
index 3b368ec84..dd491d5a7 100644
--- a/packages/vitnode/src/content/server/service.ts
+++ b/packages/vitnode/src/content/server/service.ts
@@ -1,19 +1,18 @@
import type { ColumnBaseConfig, SQL } from "drizzle-orm";
import type {
PgColumn,
- PgTable,
PgTableWithColumns,
TableConfig,
} from "drizzle-orm/pg-core";
import type { Context } from "hono";
-import { and, eq } from "drizzle-orm";
-import { alias, getTableConfig } from "drizzle-orm/pg-core";
+import { and, eq, ne, sql } from "drizzle-orm";
import type { ContentSchemas } from "../schemas";
import type {
AnyContentTypeDefinition,
ContentCreateInput,
+ ContentFieldMap,
ContentFieldName,
ContentFilterInput,
ContentOrderableFieldName,
@@ -23,9 +22,15 @@ import type {
} from "../types";
import { withPagination } from "../../api/lib/with-pagination";
-import { CONTENT_DEFAULT_PAGE_SIZE, CONTENT_OPTIONS_LIMIT } from "../const";
-import { ContentEngineError } from "../errors";
+import {
+ CONTENT_DEFAULT_PAGE_SIZE,
+ CONTENT_OPTIONS_LIMIT,
+ CONTENT_PUBLICATION_FIELDS,
+ CONTENT_SLUG_DEFAULT_LENGTH,
+} from "../const";
+import { ContentEngineError, ContentInputError } from "../errors";
import { orderableColumns } from "../registry";
+import { slugify } from "../slug";
import {
buildFilterCondition,
buildOrderColumn,
@@ -33,6 +38,7 @@ import {
diffChangedFields,
toColumnValues,
} from "./query";
+import { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references";
/** Display labels for `user` and `relation` values, keyed by field name. */
export type ContentLabels = Record;
@@ -75,7 +81,50 @@ export interface ContentUpdateResult {
row: ContentSelect;
}
-export interface ContentService {
+export interface ContentPublicationResult {
+ /**
+ * `false` when the row was already in that state: no write happened, no event
+ * was emitted, and nothing needs invalidating.
+ */
+ changed: boolean;
+ /**
+ * When the row was first published, or `null` if it never has been. Lifted
+ * out of `row` because the generated columns are conditional on a type
+ * parameter that is still open in generic route code.
+ */
+ publishedAt: Date | null;
+ row: ContentSelect;
+}
+
+export interface ContentPublicationMethods {
+ /**
+ * Idempotent. Stamps `publishedAt` on the first `draft -> published`
+ * transition and never rewrites it. `null` when the row does not exist.
+ */
+ publish: (
+ id: number,
+ options?: ContentServiceOptions,
+ ) => Promise | null>;
+ /** Idempotent. Flips `status` only - `publishedAt` is left alone. */
+ unpublish: (
+ id: number,
+ options?: ContentServiceOptions,
+ ) => Promise | null>;
+}
+
+/**
+ * `publish`/`unpublish` exist only on a content type with publication enabled.
+ *
+ * The `never` branch is the same trick `ContentFieldsConstraint` uses for
+ * reserved system columns: calling `service.publish(...)` on a content type
+ * without publication is a compile error rather than a runtime surprise.
+ */
+export type ContentService = ContentServiceBase &
+ (TDefinition extends { publication: { enabled: true } }
+ ? ContentPublicationMethods
+ : Partial, never>>);
+
+export interface ContentServiceBase {
/** Throws a `ZodError` if `values` does not satisfy `schemas.create`. */
create: (
values: ContentCreateInput,
@@ -106,84 +155,27 @@ export interface ContentService {
) => Promise | null>;
}
-interface ReferenceTarget {
- /** Aliased, so two relations pointing at the same table can both be joined. */
- aliased: PgTable;
- idColumn: PgColumn;
- labelColumn: PgColumn;
- owner: PgColumn;
+interface SlugFieldConfig {
+ maxLength: number;
+ name: string;
+ /** Field the value is derived from when a create payload omits the slug. */
+ source: string | undefined;
}
-const LABEL_PREFIX = "label__";
-
-/**
- * Turns a joined label column value into display text. Only the shapes a title
- * column can actually hold are handled - anything else becomes `null` rather
- * than "[object Object]".
- */
-const toLabel = (value: unknown): null | string => {
- if (value === null || value === undefined) return null;
- if (typeof value === "string") return value;
- if (typeof value === "number" || typeof value === "bigint") {
- return value.toString();
- }
- if (value instanceof Date) return value.toISOString();
-
- return null;
-};
-
-/**
- * Works out which table and column supply the display label for each
- * `user`/`relation` field.
- *
- * The target comes from the foreign keys Drizzle already resolved on the table,
- * so the engine needs no separate table registry - and because the FK thunk is
- * evaluated here, circular content type references stay safe.
- */
-const resolveReferenceTargets = (
- definition: AnyContentTypeDefinition,
- table: PgTableWithColumns,
- columns: Record,
-): Record => {
- const fields = definition.fields;
- const byOwnerColumn = new Map(
- getTableConfig(table)
- .foreignKeys.map(foreignKey => foreignKey.reference())
- .map(reference => [reference.columns[0]?.name, reference]),
- );
-
- const targets: Record = {};
+const slugFieldsOf = (fields: ContentFieldMap): SlugFieldConfig[] => {
+ const slugFields: SlugFieldConfig[] = [];
for (const [name, fieldValue] of Object.entries(fields)) {
- if (fieldValue.kind !== "relation" && fieldValue.kind !== "user") continue;
-
- const reference = byOwnerColumn.get(name);
- if (!reference) {
- throw new ContentEngineError(
- `Field "${name}" has no foreign key on "${definition.tableName}".`,
- { contentTypeId: definition.id },
- );
- }
+ if (fieldValue.kind !== "slug") continue;
- // `user` labels come from the core users table; a relation uses the target
- // content type's own `admin.titleField`.
- const labelName =
- fieldValue.kind === "user"
- ? "name"
- : (fieldValue.target().admin.titleField ?? "id");
-
- const aliased = alias(reference.foreignTable, `${LABEL_PREFIX}${name}`);
- const aliasedColumns = aliased as unknown as Record;
-
- targets[name] = {
- aliased,
- idColumn: aliasedColumns.id,
- labelColumn: aliasedColumns[labelName] ?? aliasedColumns.id,
- owner: columns[name],
- };
+ slugFields.push({
+ maxLength: fieldValue.maxLength ?? CONTENT_SLUG_DEFAULT_LENGTH,
+ name,
+ source: fieldValue.source,
+ });
}
- return targets;
+ return slugFields;
};
/**
@@ -221,11 +213,101 @@ export const createContentService = <
// object is the very field map that type is derived from, so this restates
// what TypeScript already knows rather than asserting anything new.
const fieldNames = Object.keys(fields) as ContentFieldName[];
- const ownColumnNames = ["id", "createdAt", "updatedAt", ...fieldNames];
+ const publication = definition.publication.enabled;
+ const ownColumnNames = [
+ "id",
+ "createdAt",
+ "updatedAt",
+ ...(publication ? CONTENT_PUBLICATION_FIELDS : []),
+ ...fieldNames,
+ ];
const references = resolveReferenceTargets(definition, table, columns);
const searchColumns = definition.admin.list.searchableFields.map(
name => columns[name],
);
+ const slugFields = slugFieldsOf(fields);
+
+ /**
+ * Normalises a slug and refuses one that folds to nothing.
+ *
+ * Nothing random or numeric is appended - `slugify` is deterministic, and
+ * uniqueness belongs to the unique index, which surfaces a clash as a 409.
+ */
+ const toSlug = (
+ slugField: SlugFieldConfig,
+ value: string,
+ derived: boolean,
+ ): string => {
+ const slug = slugify(value, slugField.maxLength);
+ if (slug !== "") return slug;
+
+ throw new ContentInputError(
+ derived
+ ? `Could not derive "${slugField.name}" from "${slugField.source}". Send "${slugField.name}" explicitly.`
+ : `Field "${slugField.name}" normalises to an empty slug. Use at least one letter or digit.`,
+ { contentTypeId },
+ );
+ };
+
+ /**
+ * Fills in and normalises every slug on the way into a create.
+ *
+ * A supplied value is normalised rather than trusted, so the same rules apply
+ * whether the slug came from the caller or from the source field.
+ */
+ const withCreateSlugs = (
+ values: Record,
+ ): Record => {
+ if (slugFields.length === 0) return values;
+
+ const next = { ...values };
+
+ for (const slugField of slugFields) {
+ const supplied = next[slugField.name];
+
+ if (typeof supplied === "string") {
+ next[slugField.name] = toSlug(slugField, supplied, false);
+ continue;
+ }
+
+ // `assertSlugSources` guarantees a source exists whenever the create
+ // schema lets the value be omitted, so this is the derived branch.
+ const source = slugField.source ?? "";
+ const from = next[source];
+
+ next[slugField.name] = toSlug(
+ slugField,
+ typeof from === "string" ? from : "",
+ true,
+ );
+ }
+
+ return next;
+ };
+
+ /**
+ * Normalises the slugs an update actually names, and only those.
+ *
+ * A slug is never re-derived here: editing the title of a published article
+ * must not silently move its URL and 404 every link to it. Sending the slug
+ * is the only way to change it.
+ */
+ const withUpdateSlugs = (
+ patch: Record,
+ ): Record => {
+ if (slugFields.length === 0) return patch;
+
+ const next = { ...patch };
+
+ for (const slugField of slugFields) {
+ const supplied = next[slugField.name];
+ if (typeof supplied !== "string") continue;
+
+ next[slugField.name] = toSlug(slugField, supplied, false);
+ }
+
+ return next;
+ };
const db = (options?: ContentServiceOptions): ContentDatabase =>
options?.tx ?? c.get("db");
@@ -266,7 +348,76 @@ export const createContentService = <
return row ?? null;
};
- return {
+ /** Reads the generated column off a raw row, before it is cast to a select. */
+ const publishedAtOf = (row: Record): Date | null => {
+ const value = row.publishedAt;
+
+ return value instanceof Date ? value : null;
+ };
+
+ /**
+ * One conditional UPDATE does the whole job: the `WHERE` clause is what makes
+ * the transition atomic, so two concurrent publishes cannot both stamp
+ * `publishedAt`, and no read-then-write race exists. The extra SELECT only
+ * runs when nothing matched, to tell "already in that state" from "no such
+ * row" - a distinction the route turns into 200 vs 404.
+ */
+ const transition = async (
+ id: number,
+ options: ContentServiceOptions | undefined,
+ values: Record,
+ guard: SQL,
+ ): Promise