AstroBaaS

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

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 to resource:action capabilities, 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; omit scopes for 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 & pathPurposeMin 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}/relatedRelated articles for the strip under a post
GET /api/productsThe 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/quotePrice a basket, server-side
POST /api/ordersPlace an order — no account needed
POST /api/payments/startOpen a payment session for an order
POST /api/orders/{id}/shipRecord tracking; emails the customer oncestaff
GET /api/ordersList ordersstaff
GET /api/customersList customersstaff
GET/POST /api/couponsCoupons and automatic cart rulesstaff

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:

QueryReturns
GET /api/postsArticles only (the default; unchanged from before pages existed)
GET /api/posts?kind=pagePages only
GET /api/posts?kind=allBoth

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.

EndpointCoversMinimum query
GET /api/products?search=Productsnone
GET /api/search?q=Published articles only — no products, no pages2 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:

FieldWeight
name6
sku5
gtin5
brand3
tags2

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. astrobaas is not published to the registry, so npm install astrobaas in a separate frontend project will 404. Two things work today:

  • Inside this repo, astrobaas/client resolves 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.json describes all of it), or build a local tarball: npm run build:pkg && npm pack, then npm 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.txt is a plain-text brief (base URL, auth schemes, endpoints); GET /openapi.json is the OpenAPI 3.1 spec with a bearerApiKey scheme. 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, and list_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>
AttributeDefaultNotes
data-color#2563ebLauncher, header and sent bubbles
data-text-color#ffffffText on data-color
data-positionbottom-rightor bottom-left
data-titlefrom SettingsHeader text
data-greetingfrom SettingsFirst message
data-launcher💬Any character or emoji
data-z-index2147483000Raise if your header covers it
data-require-consentfalseDo 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:

  1. 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 with curl. If a session cookie is present, CSRF applies in full, so no page can drive this endpoint using a signed-in admin’s session.
  2. Origin is in CORS_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.

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.