@blueforge-studio/service-content

Content service factory: createContentService with memory, committed-snapshot and marketing-os (CMS read) adapters

Package Info

Latest Version0.2.0
LicenseMIT
Keywords-
Repositorygit+https://github.com/blueforge-studio/blueforge-org.git
Author-
Homepagehttps://github.com/blueforge-studio/blueforge-org#readme
Bugs[object Object]

Versions

VersionPublished
0.2.02026-10-16
0.1.02026-10-16

Per-version supply chain

VersionSigSBOMAtt
0.2.0
0.1.0

Click a version to view its details.

Supply chain

Provenance

Published by
web:5
When
2026-10-16T19:42:37Z
sha256 (registry-computed)
3b0d27e15015236ee1444f550cb55c1fe1c3055f2b72ad506b18a518ce2ddc04

Observed by the registry at publish.

Claimed by the build

The publisher claimed nothing about its build.

Attachments

Signature
none
SBOM
none

Dependencies

Loading dependency graph...

README

@blueforge-studio/service-content

Provider-neutral, read-only content access for apps: createContentService() reads CONTENT_PROVIDER and returns a stable ContentService (getItem, listItems). Same pattern as service-support / service-analytics.

CONTENT_PROVIDERWhat it isConfig
memory (default)In-memory, empty. Dev and tests; new MemoryContentAdapter([...]) to seed.none
snapshotA JSON file committed to the app repo. The safe default for a deploy: a CMS outage cannot block it.CONTENT_SNAPSHOT_PATH (default content-snapshot.json)
marketing-osThe marketing-os CMS read API, with ETag revalidation, timeout and stale-while-error.MARKETING_OS_API_URL, MARKETING_OS_CMS_KEY (a tenant-bound cms:read key)
import { createContentService } from "@blueforge-studio/service-content";

const content = createContentService();
const terms = await content.getItem("legal", "terms-of-service", { appId: "contracts-os" });
if (terms) render(terms.body, terms.fields);          // keep terms.id + terms.version if you must record what was shown

The item

{ id, type, slug, title, body, fields, version, scope?, locked?, publishedAt?, updatedAt?, source, stale? }. body is opaque (the editor document, or Markdown, depending on the type); fields is {} for plain types. Store id + version (not a label) when you must record exactly what a user saw, e.g. with the essentials-kit consent store's content reference.

Behaviour you can rely on

  • Published items only. The package cannot write, approve or publish.
  • Scope resolution (marketing-os): pass appId and you get the app's own item, else its family's, else the portfolio's; a locked ancestor wins over any descendant. Without appId you get the item as stored at the default scope.
  • Resilience (marketing-os): responses are cached with their ETag and revalidated with If-None-Match (304 reuses the cached item). If the provider times out, is unreachable or returns 5xx, the last good copy comes back with stale: true; with nothing cached the error is thrown. 404 on getItem is null. Other 4xx throw MarketingOSContentError with status.
  • No locale. The marketing-os read API has no locale parameter yet, so the option does not exist here rather than being ignored.

Snapshot workflow (recommended for deploys)

A snapshot is a JSON file committed to the app repo: { "version": 1, "generatedAt": "...", "items": [ ... ] }. In a hand-written snapshot each item needs id, type, slug, title, body, fields, version; source is optional (the adapter stamps "snapshot"), and locked, scope, publishedAt, updatedAt are optional too.

Generate one from any source (normally marketing-os) in a build step or by hand, and commit it:

import { createContentService, writeContentSnapshot } from "@blueforge-studio/service-content";
// run with CONTENT_PROVIDER=marketing-os; the file is provider-neutral (source/stale are not written)
await writeContentSnapshot(createContentService(), { types: ["legal", "page"], appId: "contracts-os" });

Bundled apps (Next output: "standalone", serverless, edge): pass the snapshot OBJECT

CONTENT_PROVIDER=snapshot with a path reads the file from disk at runtime, relative to the process cwd. A bundler only includes files it sees imported, so in a standalone/serverless image that file can be missing. Import the JSON so it is bundled, and hand it over:

import snapshot from "./content-snapshot.json";
import { createContentService } from "@blueforge-studio/service-content";

const content = createContentService({ snapshot });   // used when CONTENT_PROVIDER=snapshot; no file access

or new SnapshotContentAdapter(snapshot). The object is validated immediately (a bad snapshot fails at startup, not on the first request); parseContentSnapshot(x) does the same check on its own. With CONTENT_PROVIDER=memory the snapshot option is ignored, so the flag still switches between "snapshot" and "nothing" (your in-repo fallback).

MemoryContentAdapter

Exported, takes ContentItem[] (here source IS required, or use .set() which defaults it to "memory"):

import { MemoryContentAdapter } from "@blueforge-studio/service-content";
const content = new MemoryContentAdapter([{ id: "1", type: "page", slug: "home", title: "Home", body: "...", fields: {}, version: 1, source: "memory" }]);
content.set({ id: "2", type: "page", slug: "about", title: "About", body: "...", fields: {}, version: 1 });

Use it in tests, or to seed content you already have in code. It returns null for unknown items and never throws.

What is NOT verified

The marketing-os adapter is tested against a fake fetch only. When it was written the CMS was merged in marketing-os but not deployed. These come from the marketing-os session's description (2026-10-16), not from a live response, and must be checked against a real server before anyone relies on them: the routes (/v1/dashboard/cms/types/{type}/items/{slug}[/resolve?app=], /items?type=), the Authorization: Bearer header, the item JSON shape (the adapter accepts a bare item or { item }), and that the gateway passes ETag / 304 through. Pin operation ids against marketing-os packages/sdk-core/src/generated/operations.ts (not read here). Whether marketing-os may be the source of an app's content is the operator's decision, and legal text additionally needs counsel.