Build on it
Headless integration
Generated from INTEGRATION.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.
How to use AstroBaaS as a backend for a separate frontend (Astro/React/Vue/an AI-vibe-coded app) or from an AI agent. For running it as a classic CMS, the README is enough; this guide is the headless/BaaS path.
- 1. Mint an API key
- 2. Allow your origin (CORS)
- 3. Call the API
- 4. The typed SDK (
astrobaas/client) - 5. Webhooks
- 6. AI agents (MCP + llms.txt)
- 7. The CLI
1. Mint an API key
Keys authenticate headless/cross-origin callers and act with a role
(admin | editor | author | viewer). Create one as an admin:
curl -X POST https://cms.example.com/api/keys \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ADMIN_KEY" \
-d '{"name":"my-frontend","role":"editor"}'
# → { "success": true, "data": { "id": "...", "prefix": "abk_…", "key": "abk_…" } }
The full key is shown once — store it as a secret (it’s SHA-256 hashed at
rest). Revoke with DELETE /api/keys/{id}. You can also mint the first key from
the admin UI while signed in with the cookie session.
Least-privilege + lifecycle (optional):
scopes: restrict a key toresource:actioncapabilities, e.g.{"scopes":["posts:write","content:read"]}(resources:posts/content/media; actions:read/write/*; or the global*). A scoped key is denied (403 INSUFFICIENT_SCOPE) on anything outside its scopes; omitscopesfor full role-based access.expires_in_days: set an expiry — expired keys are rejected (401).- Rotate a key’s secret without changing its id/role/scopes:
POST /api/keys/{id}/rotate(the old secret stops working immediately). With the SDK:baas.keys.rotate(id).
Send it on every request:
Authorization: Bearer abk_xxx
Bearer requests are CSRF-exempt (no cookie, no ambient authority). Reads
(GET) work anonymously too, but only return published content.
2. Allow your origin (CORS)
If your frontend runs on a different origin, start the backend with the origins allow-listed:
CORS_ORIGINS="https://app.example.com http://localhost:3000" npm run start
Credentials are never allowed (auth is a header token, not a cookie), so the API
stays CSRF-safe even with CORS_ORIGINS=*. Same-origin frontends need nothing.
3. Call the API
All responses share one envelope:
{ "success": true, "data": <T>, "message"?: "...", "meta"?: { ... } }
{ "success": false, "error": { "message": "...", "code"?: "..." } }
Core endpoints (full list in /openapi.json):
| Method & path | Purpose | Min role |
|---|---|---|
GET /api/posts?status=&category=&kind=&limit= | List posts (published when anonymous) | – |
GET /api/posts/{slug} | One post (article or page) | – |
GET /api/posts/{slug}/related | Related articles for the strip under a post | – |
GET /api/products | The catalogue (search, category, brand, featured, on_sale) | – |
GET /api/products/{ref} | One product, by id or slug | – |
POST /api/products/{ref}/notify-me | “Tell me when it’s back” | – |
POST /api/orders/quote | Price a basket, server-side | – |
POST /api/orders | Place an order — no account needed | – |
POST /api/payments/start | Open a payment session for an order | – |
POST /api/orders/{id}/ship | Record tracking; emails the customer once | staff |
GET /api/orders | List orders | staff |
GET /api/customers | List customers | staff |
GET/POST /api/coupons | Coupons and automatic cart rules | staff |
The typed client covers all of these:
import { createClient } from 'astrobaas/client';
const baas = createClient('https://cms.example.com');
const frames = await baas.products.list({ category: 'frames', on_sale: true });
const quote = await baas.orders.quote({ items: [{ product_id: frames[0].id, qty: 1 }] });
const order = await baas.orders.place({ email: 'buyer@example.com', items: [...] });
A shopper never needs an account: orders.place is public, and prices are
computed server-side from the ids and quantities, so a basket total sent by a
client is ignored.
| POST /api/posts | Create a post or page | author |
| GET /api/content/{type} | List custom-type entities | – |
| POST /api/content/{type} | Create one (schema-validated) | editor |
| PUT /api/content/{type}/{id} | Update one | editor |
| DELETE /api/content/{type}/{id} | Delete one | editor |
| GET /api/auth/me | Who the credential resolves to | – |
Articles vs. pages (kind)
A page is a Post with kind: "page". Same record, same endpoints — it is
routed at /{slug} instead of /blog/{slug} and carries no date or author
byline. Records created before this field exists have no kind at all, and an
absent value means “article”.
GET /api/posts returns articles only by default. That is deliberate: an
existing storefront calling this endpoint to render its blog must not start
receiving About-style pages the day someone writes one. Opt in explicitly:
| Query | Returns |
|---|---|
GET /api/posts | Articles only (the default; unchanged from before pages existed) |
GET /api/posts?kind=page | Pages only |
GET /api/posts?kind=all | Both |
An unrecognised kind falls back to the default rather than erroring, so a typo
cannot empty a production listing.
Which page (if any) serves as the site root is published as the home_page_slug
setting via GET /api/settings, so a decoupled front end can render the same
home document the CMS does.
Searching
Two endpoints search, and they cover different things. There is no single endpoint that searches the whole site.
| Endpoint | Covers | Minimum query |
|---|---|---|
GET /api/products?search= | Products | none |
GET /api/search?q= | Published articles only — no products, no pages | 2 characters |
If you want one search box over a shop, call GET /api/products?search=. A box
wired only to /api/search returns zero products, however many you have.
What a product search matches. Five fields, weighted, most important first:
| Field | Weight |
|---|---|
name | 6 |
sku | 5 |
gtin | 5 |
brand | 3 |
tags | 2 |
description and short_description are not searched, and neither are
variant SKUs. Barcodes and tags are, because both are things people paste and
both are short enough not to flood the results the way free text would.
Results are ranked, not merely filtered, and the ranking is stable — equally
relevant products keep the merchandising order the shop chose (position, then
newest).
Case and accents don’t matter. Queries and stored text are folded the same
way: Unicode NFD, combining marks stripped, lowercased, whitespace collapsed,
and Greek final sigma ς unified with σ. So ΑΛΥΣΙΔΑ, αλυσίδα and
Αλυσίδα are one query, and Σκελετός is found by σκελετοσ. Folding is
not NFKD, so ß, ligatures and full-width characters do not fold.
What it will not do is translate. A Greek shopper typing Ρέι Μπαν will not
find Ray-Ban: that is a phonetic rendering, not an accent difference, and no
folding rule can bridge it. Nothing in the core guesses. The mechanism for it is
the operator’s own synonym table — Settings → search synonyms — which the
catalogue and the blog both read.
Brands
GET /api/brands returns every maker with active products, plus any curated
brand record:
{ "name": "Ray-Ban", "slug": "ray-ban", "count": 17, "key": "rayban",
"spellings": [{ "name": "Rayban", "count": 16 }, { "name": "RAYBAN", "count": 1 }],
"curated": false }
count is a promise about ?brand=: it is exactly what
GET /api/products?brand=<slug> returns. Render a brands menu or an A–Z page
from this rather than downloading the catalogue to derive one.
Brand matching ignores case, spacing and punctuation. A product’s brand is
free text typed by whoever added it, and real catalogues accumulate spellings —
one live shop held 72 strings for 62 makers. So ?brand= matches on identity,
not on the exact string: Ray-Ban, Rayban, RAYBAN and the importer’s
ray-ban slug all return the same products, and the listing shows them as one
entry.
It never merges across a word, which is the distinction that matters:
Solano Clips and SOLANO stay separate, and so do Tipi Diversi and
Tipi Diversi Clip. Brands that merely look related are reported to the
operator in the admin, never merged automatically.
A curated entry (curated: true) is a row in the brands table, which adds a
logo, a stable id and translations. A derived entry has none of those — it is
not a record, and no id is invented for it. Both carry count.
4. The typed SDK (astrobaas/client)
Not installable from npm yet.
astrobaasis not published to the registry, sonpm install astrobaasin a separate frontend project will 404. Two things work today:
- Inside this repo,
astrobaas/clientresolves to source via tsconfig paths — every example below runs as written.- From another project, either call the REST API directly (§2 and §3; it is plain HTTP and JSON, and
/openapi.jsondescribes all of it), or build a local tarball:npm run build:pkg && npm pack, thennpm install ../AstroCMS_v1/astrobaas-0.1.0.tgz.The SDK is a convenience over the same endpoints, not a requirement — nothing in the API needs it.
The package ships built JS + .d.ts for astrobaas/client (and /core,
/plugins); the client bundle is dependency-free. Skip hand-rolling fetch +
envelope unwrapping + error handling:
import { createClient, AstroBaasError } from 'astrobaas/client';
const baas = createClient('https://cms.example.com', {
apiKey: process.env.ASTROBAAS_KEY, // omit for anonymous reads
timeoutMs: 10_000, // abort slow requests (optional)
retries: 2, // retry 429/5xx with backoff (optional)
});
// Pagination + auto-pagination
const page = await baas.posts.page({ limit: 20, page: 2 }); // { items, total, hasMore, … }
const everyPublished = await baas.posts.listAll({ status: 'published' });
// Reads
const posts = await baas.posts.list({ status: 'published', limit: 10 });
const post = await baas.posts.get('hello-world');
// Writes (needs a key with the right role)
const draft = await baas.posts.create({ title: 'From the SDK', status: 'draft' });
await baas.posts.update(draft.id, { status: 'published' }); // by id or slug
await baas.posts.remove(draft.id);
// Custom content types
const products = baas.content('product');
await products.create({ name: 'Widget', price: 9 });
await products.update(id, { name: 'Widget v2', price: 12 });
// Introspect / admin
const me = await baas.auth.me(); // { id, role, type: 'apikey' | 'user' }
try {
await baas.posts.create({ title: 'x' });
} catch (e) {
if (e instanceof AstroBaasError) console.error(e.status, e.code, e.message);
}
Each method returns the unwrapped data and throws AstroBaasError(status, code, details) on failure. The client is isomorphic (global fetch; Node 18+,
Deno, browsers) and dependency-free; pass options.fetch for SSR/tests, and
setApiKey(key) to rotate the credential at runtime.
5. Webhooks
Get notified when content changes instead of polling. Register a receiver (admin):
curl -X POST https://cms.example.com/api/webhooks \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"url":"https://hooks.example.com/astrobaas","events":["post.*","content.created"]}'
# → data.secret is returned ONCE — store it to verify deliveries.
Events: post.created, post.updated, post.deleted, content.created,
content.updated, content.deleted. Subscribe to "*" for all or a
"prefix.*" wildcard. With the SDK: baas.webhooks.register({ url, events }).
Each delivery is a POST with headers X-AstroBaaS-Event,
X-AstroBaaS-Timestamp, and X-AstroBaaS-Signature: sha256=<hex> where
hex = HMAC-SHA256(secret, rawBody). Verify it with the SDK helper (universal /
WebCrypto, constant-time) — pass the raw body bytes:
import { verifyWebhookSignature } from 'astrobaas/client';
// e.g. in an Express handler with the raw body captured
const ok = await verifyWebhookSignature(secret, rawBody, req.header('x-astrobaas-signature'));
if (!ok) return res.status(401).end();
// Optionally reject stale deliveries using the X-AstroBaaS-Timestamp header.
Durability. A failed delivery is retried with backoff
(WEBHOOK_RETRY_DELAYS_MS, default 30s/2m/10m) and every attempt is recorded in
a delivery log. Inspect it at GET /api/webhooks/deliveries (or
baas.webhooks.deliveries()), and re-send any delivery with
POST /api/webhooks/deliveries/{id}/redeliver (baas.webhooks.redeliver(id)).
Retries run on in-process timers, so they don’t survive a restart — use
redelivery to recover anything left pending/failed.
6. AI agents (MCP + llms.txt)
Two ways an AI agent can use AstroBaaS:
-
Just read the contract.
GET /llms.txtis a plain-text brief (base URL, auth schemes, endpoints);GET /openapi.jsonis the OpenAPI 3.1 spec with abearerApiKeyscheme. Both are public. -
Operate it as MCP tools. Run the bundled MCP server so an agent can call the full CRUD surface as tools —
whoami,list_posts/get_post/create_post/update_post/delete_post, andlist_content/create_content/update_content/delete_content— and browse published posts as MCP resources (astrobaas://post/<slug>). On startup it probes the key and logs the resolved role (or a warning) to stderr. Example Claude Desktop config:{ "mcpServers": { "astrobaas": { "command": "npx", "args": ["astrobaas-mcp"], "env": { "ASTROBAAS_URL": "https://cms.example.com", "ASTROBAAS_KEY": "abk_..." } } } }It speaks stdio JSON-RPC (the MCP standard) and is dependency-free.
7. Embeddable AI assistant widget
Put the chat bubble on any site with one script tag — no build step, no framework, no npm install:
<script src="https://cms.example.com/assistant-widget.js"
data-color="#e11d48"
data-position="bottom-left"
data-title="Ask us"
data-greeting="Hi! What are you looking for?"
defer></script>
| Attribute | Default | Notes |
|---|---|---|
data-color | #2563eb | Launcher, header and sent bubbles |
data-text-color | #ffffff | Text on data-color |
data-position | bottom-right | or bottom-left |
data-title | from Settings | Header text |
data-greeting | from Settings | First message |
data-launcher | 💬 | Any character or emoji |
data-z-index | 2147483000 | Raise if your header covers it |
data-require-consent | false | Do not mount until you call the API below |
The host page keeps control:
AstroBaaSAssistant.mount(); // after your own consent tool resolves
AstroBaaSAssistant.open(); // e.g. from your own "Chat with us" button
AstroBaaSAssistant.close();
AstroBaaSAssistant.destroy();
Allow-list the host origin in CORS_ORIGINS (see §2) — the widget will not
work otherwise, by design.
Why this is safe to expose cross-origin
The widget never holds your API key. It POSTs to /api/assistant/chat on the
AstroBaaS origin, and the server forwards the request with the credential.
That POST is exempt from the usual CSRF double-submit check, which is worth spelling out because “we turned off CSRF for this endpoint” deserves scrutiny. The exemption applies only when both hold:
- The request carries no session cookie. The widget sends
credentials: 'omit', so there is no ambient authority to ride — an attacker gains nothing they could not already do withcurl. If a session cookie is present, CSRF applies in full, so no page can drive this endpoint using a signed-in admin’s session. Originis inCORS_ORIGINS. Browsers set this on cross-origin POSTs and page JavaScript cannot forge it, so embedding stays limited to sites you named.
Condition 1 is the load-bearing one; this is the same reasoning that already exempts bearer-token requests. The endpoint remains public, rate-limited per IP, and length-bounded either way. The smoke suite asserts all four cases — allow-listed, non-allow-listed, no-Origin, and cookie-bearing.
Consent
The widget does not render a consent banner on your site — that is yours to
run, and a second banner would collide with it. Set
data-require-consent="true" and call AstroBaaSAssistant.mount() when your own
tool grants the relevant category.
(The bubble on AstroBaaS’s own pages is a separate, first-party path: activate the AI Assistant plugin, and it honours the built-in consent banner.)
CSP on the host site
Nothing is inlined as script and no inline style= attributes are used, so a
strict host policy needs only:
script-src https://cms.example.com;
connect-src https://cms.example.com;
Styles are injected into one <style> element the widget creates (an external
page never loads /plugins.css), so a host with a strict style-src needs
'unsafe-inline' there or the element’s hash.
When it says “unavailable”
Provider failures are logged server-side with the status and the provider’s own
message, credentials redacted — grep '\[assistant\]' in your logs. The most
recent failure is also shown in Settings → AI assistant, and clears on the
next successful reply.
8. The CLI
Run these from the project directory. npx resolves them from this package’s
own bin entries; the package is not on npm, so the same commands in an
unrelated directory will 404.
npx astrobaas init # write .env with a CSPRNG AUTH_SECRET (then: npm install)
npx astrobaas secret # print a fresh 32-byte secret to stdout
npx astrobaas setup # create/replace the admin account
npx astrobaas-mcp # start the MCP server (configure via env, see above)
An MCP client’s config file is read from your home directory, not from the
clone, so point it at an absolute path instead:
node /absolute/path/to/AstroCMS_v1/bin/astrobaas-mcp.mjs.
init refuses to overwrite an existing .env without --force.
See STABILITY.md for what’s covered by the API-stability promise, STORAGE.md for durable persistence, and SECURITY.md for the security model.