@growth-labs/cms
v0.1.2
Published
The Fronts-proven publishing/admin engine, packaged for reuse. `@growth-labs/cms` is a versioned library each site installs and parameterizes with its own `D1Database`, R2 media bucket, authz, and theme tokens — **not** a shared runtime engine. See [`cms-
Readme
@growth-labs/cms
The Fronts-proven publishing/admin engine, packaged for reuse. @growth-labs/cms
is a versioned library each site installs and parameterizes with its own
D1Database, R2 media bucket, authz, and theme tokens — not a shared runtime
engine. See cms-reuse-decision.md
for the package-not-engine ruling.
Status: schema contract + publishing engine + API routes. This package ships the D1 schema contract and its TypeScript types (the WS7-04/06 foundation), the publishing engine — the content data-access core (WS7-05) — and the API route handlers (
@growth-labs/cms/routes, WS7-07): the/api/v1/publisher/*content/authors/media/cron surface as framework-agnostic mountable factories with auth/authz injected. The admin integration mounts multipart podcast uploads at/admin/api/media/podcast/{init,part,complete,abort}.
What's here
- The D1 contract — the 11-table content/revision/media/authors/foundry-callback
schema, verified against prod
fronts-data2026-05-29:content_items,article_content,video_content,podcast_content,content_revisions,content_tags,content_tag_links,content_relations,media_assets,authors,foundry_callback_events.content_items.typeacceptsarticle,video,podcast,newsletter, andpage; article, newsletter, and page body rows are stored inarticle_content. - Row types — a TypeScript interface per table, with the CHECK domains encoded as union types.
- Migration helpers — the SQL as a wrangler-applicable file plus an inlined constant for tests/tooling.
- The publishing engine (WS7-05) — the content data-access core, exported
from the package root and the
@growth-labs/cms/enginesubpath: content CRUD- lifecycle (
createContent,updateContentItem,scheduleContent,publishContent,unpublishContent,duplicateContentItem), revisions (createRevision,getContentSnapshot), tags/relations,ensureUniqueSlug,countWords/estimateReadTime, the publish-validation guard, the scheduled-publish sweep (runScheduledPublish), the article-body validator, and the content-integrity sanitize passes. Every function takes an injectedD1Database(the site supplies its own binding) — no Fronts coupling.
- lifecycle (
The contract excludes identity/entitlement tables (users, subscription*,
*_entitlements, gl_identity_links, CRM, mailer) and the analytics-owned
gl_content_progress. Those belong to other workstreams — pulling them in is the
monolith-creep this boundary guards against.
Intentional schema divergences (do not normalize)
media_assets.created_at/updated_atare TEXTdatetime('now'), while the content tables use INTEGERunixepoch(). This matches prod and the engine code; normalizing it breaks parity.video_contentusesvideo_id(renamed fromstream_uidin migration 0018). Do not reintroducestream_uid.content_items.typeCHECK includesnewsletter(added in prod 0024) andpage. The full domain isarticle,video,podcast,newsletter, andpage.
Apply the schema
The supported adoption path is wrangler, against the package's migrations/:
wrangler d1 migrations apply <YOUR_D1_BINDING>For tests and provisioning tooling without disk access, the same statements are available programmatically:
import { applyMigrations, CMS_TABLES } from '@growth-labs/cms/schema'
await applyMigrations(env.SITE_DB) // tests / provisioning only — never request-timeUse the types
import type { ContentItemRow, MediaAssetRow } from '@growth-labs/cms'Use the engine
The engine functions take the site's own D1Database as the first argument:
import { createContent, publishContent, runScheduledPublish } from '@growth-labs/cms'
const { id } = await createContent(env.SITE_DB, {
type: 'article',
slug: 'hello-world',
title: 'Hello, world',
content: { bodyMarkdown: '# Hello\n\nFirst post.' },
})
await publishContent(env.SITE_DB, id, Math.floor(Date.now() / 1000))
// In a scheduled handler:
await runScheduledPublish(env.SITE_DB)primaryCategory (stored in content_items.channel) is site-supplied taxonomy;
it defaults to 'analysis' (the Fronts default) when omitted.
The duplicateContentItem column-alignment gotcha
duplicateContentItem does four INSERT…SELECT copies whose explicit column
lists are hand-maintained and must stay positionally aligned to the schema.
created_at/updated_at are deliberately omitted so the copy gets fresh
DEFAULT (unixepoch()) timestamps; the duplicate is forced to status='draft',
featured=0, null publish timestamps/revision, and a video copy is forced to
processing_state='ready' (NOT re-queued to Foundry). Any future
content_items column addition requires a matching edit in
duplicateContentItem or the duplicate silently drops the new field.
Use the routes
The route handlers are framework-agnostic factories at the
@growth-labs/cms/routes subpath. Each handler is a pure
(ctx: RouteContext) => Promise<Response>; the consuming site supplies a thin
adapter that maps its request + bindings + its own authz implementation onto
a RouteContext. The package never decides who is an admin/publisher — it asks
the injected authz guard, so no site's context.locals.user / ADMIN_EMAILS
is baked in.
import { createCmsRoutes } from '@growth-labs/cms/routes'
const routes = createCmsRoutes({
authz: {
requireAdmin: (ctx) => (siteSaysAdmin(ctx) ? null : unauthorized()),
requirePublisher: (ctx) => (siteSaysPublisher(ctx) ? null : unauthorized()),
},
// theme tokens default to the Fronts values when omitted:
// mediaPublicDomain: 'media.fronts.co', mediaR2Binding: 'PUBLIC_MEDIA',
// mediaSiteId: 'fronts', publishTimezone: 'Europe/Paris', …
})
// In an Astro route (the site's adapter builds `ctx` from its APIContext):
export const POST = (c) => routes.content.create(toRouteCtx(c))The handler groups: routes.content (list/create/get/update/action — the
action endpoint covers schedule/publish/regenerate-takeaways/unpublish/archive/
unschedule/duplicate), routes.authors (list/create/get/update),
routes.media (uploadImage/listLibrary/libraryAction/uploadPodcast plus
init/part/complete/abort multipart flows for both podcast audio and video
source uploads), and routes.cron (publish — gated by requireAdmin).
The admin UI includes Social Share. Content selection fills destination URLs from the browser origin so URL inputs receive absolute links, and Author Social requires the selected author but does not require a social handle when the operator is minting a link for that author to post themselves.
The article-takeaways publish dispatch (Foundry/Hermes) is an OPTIONAL
injected dispatchTakeaways hook on the content config — the package carries no
Foundry coupling. Omit it and publish simply skips queuing takeaways.
Media transcription dispatch is also injected. When the host supplies the
Foundry media hook, dispatch-to-foundry works for video and podcast content,
and podcast create/update/publish automatically queues processing when
podcast_content.audio_r2_key is present and the row has neither a
processing_trigger_token nor a transcript. Foundry callbacks matched by
processing_trigger_token update video processing state or, for podcasts,
persist transcript, duration, description, and excerpt fields when the callback
payload includes them.
Sites with publish prerequisites can inject publishReadiness on the content
config. The hook receives { ctx, contentId, type, status } before the generic
publish lifecycle runs. Returning { ready: false, blockers, message } blocks the
manual publish with a 409 and leaves the content status unchanged; throwing from
the hook fails closed with a 503. This keeps media-specific readiness checks in
the host site while preventing a configured site from bypassing them.
Coming in later waves
- Broader admin UI polish and per-site extension points beyond the shipped Masthead screens.
