Knowledge base

A collection of "how to" procedures in the admin panel and via the API. Each section is self-contained.

How to add a new content model

  1. /admin/models → New model. Provide a key (snake_case), a name, and a kind

(collection, single, component).

  1. In Taxonomy choose a parent category and the category name (one per model)

and comma-separated tags.

  1. Add fields in the field editor: kind, requiredness, validation, UI variants, localized.
  2. Save draft → Publish. The panel will show a diff and flag breaking changes

(column removal, type change) before running the migration.

  1. After publishing, the model immediately has REST, JSON:API, GraphQL, OpenAPI, and a form — check /admin/interfaces.

Alternative: /admin/models/ai — describe the model in plain language, and AI will generate a definition for review. On this page you also get:

  • Domain templates — a ready brief for a typical domain (e-commerce, editorial CMS, PIM, CRM, events, knowledge base, real estate, telecom TMF, HR, IoT). Check the add-ons (localized content, seo component, category+tags taxonomy, workflow fields, external_id), add your own requirements, and click *Use this brief*.
  • Samples / Tests — for each proposed model we generate sample records and run the same validator on them as the API (missing required field, wrong enum, exceeded limits, wrong formats, invalid relation). A red test = the proposed model is inconsistent, fix the field before saving.
  • DDL / GraphQL SDL / OpenAPI — the exact contract that will result from publishing: cms.* tables with indexes and RLS, GraphQL types, and OpenAPI 3.2 paths. Nothing is saved until you choose *Save as drafts* or *Save and publish*.

How to organize taxonomy

  • /admin/models → Manage taxonomy.
  • Add category: name + parent (No parent = root). The category is saved in the term registry,

so it's visible (with an empty badge) even when no model uses it yet.

  • Add tag: the tag is normalized to a slug (lowercase, -), and it too can exist "empty".
  • Rename / move: a new path moves the entire subtree (Marketing / Web → Growth / Web).
  • Delete: models fall back to the parent or to the indicated category — nothing is left without a category.
  • Tags: global rename and deletion across the entire registry.
  • Assign models: select models, choose a category and/or provide comma-separated tags → a single save

rewrites all selected definitions as new draft versions (with an audit entry).

How to ingest files from Google Drive

Drive has folders and nothing else — we add categories, tags, alt text, and descriptions at import time.

  1. /admin/media → Import from Drive. If the company Drive account isn't connected, the dialog will

say so directly — connecting it is done by an administrator in the project's connectors.

  1. Browse the Drive tree (breadcrumb at the top) or search by name. Select files with checkboxes.

Google Docs/Sheets/Slides files import as PDF/XLSX; forms and shortcuts are skipped.

  1. Choose a Mode:
  • Copy into library — bytes land in our bucket: CDN, variants, checksum deduplication,

no dependency on Drive permissions.

  • Reference — the file stays in Drive, we keep metadata and a link. Zero duplication, but access

depends on Drive.

  1. Set the Library folder, Category (brand/logos), and Tags. Empty fields aren't a problem:

the Drive folder path (Brand/Logos & Marks) itself yields the brand/logos-marks category and tags.

  1. Leave Let AI fill missing… enabled — AI fills in only the missing title, description, alt text

(for images), and tags. Nothing you set manually or that a rule provided will be overwritten.

  1. Save folder rule saves the selected Drive folder as a rule: the next import from it (including

subfolders) automatically gets the same destination folder, category, tags, and mode. Rules are visible and removable at the bottom of the dialog; the deepest rule wins over a parent rule.

After import, the panel shows the result per file (imported / linked / skipped / failed) along with whether AI filled in the metadata. Every import and every rule change is audited (drive.imported, drive.mapping.saved).

How to set up the Geolocation field (address, map, reverse geocoding)

The geolocation field stores { lat, lng, label?, zoom? }. In the admin panel you have three equivalent ways:

  1. Type an address in the search field above the map. After ~350 ms a list of suggestions (autocomplete) appears;

Enter picks the first one, Esc closes the list. Selecting one sets lat, lng, and label (the full address) and keeps the current zoom.

  1. Click the map or drag the pin. Coordinates update immediately, and in the background

*reverse geocoding* starts, filling in the address into label. If you move the point in the meantime, the late response is discarded — label will never describe a different place than lat/lng.

  1. Type lat/lng manually — this also works when the geocoding service is unavailable.

Example of a value saved after choosing a suggestion:

{ "lat": 52.231924, "lng": 21.006727, "label": "Pałac Kultury i Nauki, Warszawa, Polska", "zoom": 13 }

Technical details: requests go through the authenticated server functions searchPlaces and reverseGeocode (src/lib/platform.functions.ts), the adapter is src/platform/server/geocode.server.ts (OpenStreetMap Nominatim, no API key — the same data source as the map tiles). Parsing and validation of the response live in src/platform/geo/geocode.ts: a row without valid coordinates is rejected rather than coerced to 0,0. Results are cached for 5 minutes and debounced, because the provider is shared and rate-limited.

How to publish an entry

draft → review → scheduled → published

In the entry editor, the Pipeline panel shows the transitions allowed for your role. scheduled requires a date — the scheduler publishes the entry automatically. Every transition creates an immutable revision with a JSONB snapshot, so a rollback is always possible.

How to translate content

Fields with localized: true have a version per locale in cms.entry_translations. Each translation row carries its own lifecycle: draft → in_review → published (src/platform/i18n/coverage.ts). Delivery — public REST, JSON:API and the SSR site — only overlays translations whose status is published; draft and in_review values stay visible in the workspace and behind preview tokens (publicationState=draft|any), so an unfinished translation can never reach a public read. Unresolved fields keep walking the fallback chain from platform_locales.

The Translations panel on the entry sets the status and shows a chip per locale with how many localized fields are filled. The entry list adds a Localization strip (LocaleCoverageStrip) with a per-locale roll-up over the visible page and a "Show gaps" list that links straight to the entries still missing translations.

Translation work does not have to happen in the panel. Every localized model exposes GET /api/public/v1/{model}/{id}/translations, GET|PUT|DELETE /api/public/v1/{model}/{id}/translations/{locale} and GET /api/public/v1/{model}/{id}/translations/revisions, plus the matching GraphQL queries and mutations — so a translation agency, a machine-translation job or an agent can push a locale, keep it in in_review, and let an editor promote it to published. The API accepts localized fields only, refuses the default locale (those values live on the entry), and records every write as a translation revision.

How to expose data publicly

  1. /admin/tokens → create a token with a scope (read:article).
  2. Query REST with the headers:
curl -H "Authorization: Bearer $TOKEN" -H "x-account: $ACCOUNT_ID" \
  "https://schemas.xeper.io/api/public/v1/article?filter[status]=published"
  1. Draft previews: a preview token from /admin/environments and the x-preview-token header.

How to deliver content to multiple channels

  1. /admin/channels → define channels (e.g. web, smart-watch, ai-agent, in-car-display).

The platform suggests examples; disable the ones you don't support and add your own. The channel key is exactly what the consumer sends in ?channel=.

  1. In the model editor, open a field → the Rules tab → *Delivery channels*.

Leave All channels if the field is universal; select channels if it should go only to them (e.g. body → Web, short_title → Smart Watch + Voice).

  1. Query REST with the channel:
curl -H "Authorization: Bearer $TOKEN" \
  "https://schemas.xeper.io/api/public/v1/article?channel=smart-watch"
# response: universal fields + those tagged 'smart-watch'; x-cms-channel and Vary headers
  1. The Field coverage panel at /admin/channels shows how many fields each channel gets

and flags required fields that never reach a given channel — this is usually a modeling error (the consumer will get an incomplete payload).

Omitting ?channel= returns the full payload, so existing integrations don't change behavior after tags are added. Tagging has no effect on writes, validation, or forms.

How to promote between environments

/admin/environments → the Promotion panel. The engine computes a plan per entry (publish, submit, skip) and respects requires review. The result is written to platform_environment_promotions and to the audit log.

How to manage the team and access

/admin/people (visible to a role with manage_users).

  1. Invitation — email + baseline role + optional account. The person receives an email

invitation; if the account already exists, we update the role instead of creating a duplicate.

  1. Baseline role — one platform role per user (Administrator,

Developer, Product manager, Content editor, Viewer); it determines capabilities everywhere in the panel and API.

  1. Per-account access — "Manage access" → add an account with the role that applies in that

tenant; access is inherited down the account tree. "Revoke" removes it immediately.

  1. Groups — the *Groups* tab: hierarchical teams (optionally per account).

Permission sets can target a group instead of individual people.

Effective access = baseline role + roles from account memberships, narrowed by permission sets (grant → restrict) and FLS. You can check it in the Access simulator at /admin/permissions.

How to grant permissions

  1. /admin/permissions → a grant set (adds) or restrict set (subtracts).
  2. Assign to a role, group, or user; add record conditions and field-level FLS.
  3. Check the effect in the Access simulator — it shows the decision and the rule that made it.

How to import a model from another CMS

  1. /admin/models/ai → Import definitions → *Open import wizard*.
  2. Upload or paste the export (.json, .yaml): Strapi content-types, a Directus snapshot,

Contentful content types, or a canonical export of this platform. *Load a Strapi example* shows the expected shape.

  1. Analyse export — the wizard names the detected dialect and lists every field with its match

quality: exact, approximate, widened (the type had to be broadened) and unsupported (not importable, skipped on purpose).

  1. Correct anything you disagree with: click the canonical type to pick another field type, or

toggle *included / excluded* per field.

  1. Use this import loads the models into the review panel. Check the derived contract (DDL,

GraphQL SDL, OpenAPI 3.2) and sample records, then save as drafts or publish.

Relations, components, dynamic zones and many-to-any references are only kept when the target model is in the same export or already in the registry — otherwise they are dropped and listed under *Auto-corrected*. Import the target model first, or import both exports together.

How to reword the interface (translations)

English is the source language: every key is authored in src/platform/i18n/messages/en.ts, and other locales are translations of the same key set. /admin/translations adds an editable layer on top of the shipped catalogs, scoped to the selected account.

  1. Pick the locale you are editing (English stays the untouched source).
  2. Filter the inventory: free-text search across key, English source and current translation, plus a

namespace filter (nav, content, access, …) and a state filter — *Shipped copy*, *Customised*, *Needs translation*.

  1. Edit the value. Live preview renders real panel chrome with your unsaved value applied, so the

wording is judged next to its neighbours.

  1. Save stores the override and appends a version; Reset to shipped copy removes it.
  2. Version history lists every edit with author and timestamp; *Restore* writes an old value

forward as a new version — nothing is rewritten.

Resolution order at runtime is override → bundled translation → English source, so a missing, blank or removed override can never blank the interface. Overrides are inherited down the account tree: a value set on the organisation applies to its brands and projects unless they define their own. Editing requires the manage_schema capability; other roles see the screen read-only.

How to connect an AI agent

  • Content MCP: /mcp (OAuth 2.1) — reading and writing content.
  • Management MCP: managing schemas and permissions.
  • Live events: GET /api/public/v1/events/stream (SSE). Every frame carries an id: cursor (<createdAt>|<eventId>); on reconnect the browser sends it back as Last-Event-ID (non-browser clients can pass ?since=<cursor>) and the stream resumes without gaps or duplicates. ?types=entry.*,asset.created filters events — an invalid pattern returns 400 instead of being ignored. The server advertises retry: 3000, closes after 5 minutes and allows 4 concurrent streams per principal (429 with Retry-After beyond that).

How to search from the admin panel

Two surfaces, one engine (searchContent), so permissions, field-level security and ranking are identical wherever you search from.

  • Quick search (⌘K / Ctrl+K) — available on every admin screen. Type at least two characters,

move with the arrow keys, press Enter to open the entry, or choose "See all results" to continue on the full screen with the query carried over in the URL.

  • Search workspace (`/admin/search`) — facet rail on the left, ranked results in the middle,

preview of the selected hit on the right. The preview shows status, model, locale, relevance, highlighted excerpts and the entry's scalar fields, with shortcuts to edit the entry or open the model list. Query and page live in the URL, so a result page is shareable.

  • Search preferences (button next to the search box) — pagination style (numbered pages with a

counter, or a "load more" stream), results per page (25/50/100) and where a hit opens (side panel or sheet). Preferences are stored per person and inherit from a role default; "Reset to defaults" removes your row so the role default applies again.

  • Ranking is never configurable: relevance is computed by the query engine over the whole match set

before paging, which is why page 2 continues page 1 instead of reshuffling it.

How to author a recipe and change it at order time

The recipe lives on the menu item, next to the option groups: recipe[] is what the kitchen puts on before the guest touches anything, and the option groups are the questions asked at order time.

  • Recipe line — ingredientCode, quantity + unit, allergens, plus three switches:

removable: false marks a structural line (dough, bun, patty) the guest cannot take off, removalCredit is the money returned when the line does come off, and swapOptions[] lists the option codes that may replace the line.

  • Removals — a removal without a credit travels with the order verbatim so the kitchen reads

"no onion"; a removal with a credit also reduces optionsTotal. Structural lines are rejected.

  • Swaps — a swap is an ordinary option with action: "swap", so it is priced, validated and

audited like every other selection; the recipe only says which swaps make sense for that line.

  • Live configurator (`/admin/configurator`) — pick a menu item that exists as content, toggle live recalculation and

every click recomputes the base price, each option line with its reason code (base_size, size_factor, free_allowance, extra_portions, removal_credit, implied_by_rule), the recipe credits and the rule verdicts (satisfied / violated / advisory / implied). Turning live mode off queues changes and recomputes on demand, which is how you demo an expensive pricing run.

  • The screen calls configureItem + explainConfiguration from src/platform/commerce — the same

functions the REST and GraphQL order endpoints use, so a price shown here is the price charged.

Behaviours: audit, history, attachments, duplicates

Each model has a Behaviours panel in the Schema Builder (/admin/models/{key}):

  • Audit changes / Track changes — turn the audit trail and the revision history off for

high-volume models where diffs are noise. Both are on by default.

  • Attachments — adds an attachments asset list to the model, so authors can attach files from

the media library to any record. Turning it off removes the managed field again.

  • Quick create — relation pickers can create the related entry inline.
  • Help link / help text — shown above the entry form, so editorial guidance lives with the model.
  • Duplicate detection — pick the columns that define identity, then tune each one separately:

*Exact*, *Ignore case*, *Normalized* (ignores accents, punctuation and double spaces, so „ACME, Inc." equals „acme inc"), *Same prefix* (first N characters — good for surnames), *± tolerance* for numbers and dates (e.g. amounts within 100, dates within 7 days) or *Similar* for fuzzy text matching with a minimum similarity. Each column carries a weight (how much it counts towards the score) and can be marked optional (an empty value no longer cancels the rule). The rule fires when every required column agrees and the weighted score reaches your threshold, and then either *Blocks the save* (the author gets a 409 with your message) or *Warns and audits* (the save goes through and entry.duplicate_warning lands in the audit log). Narrow a rule further with a scope: only within the same locale, or only against chosen lifecycle statuses (e.g. compare against published rows only). Use a field's *unique* switch when one column alone defines identity.

Behaviours are saved as a new draft version — publish the model to apply them.

Where recipes and options live (Content Manager, not code)

Ingredients, allergens, options, option groups and recipes are ordinary entries of the restaurant-menu models (food_ingredient, food_allergen, food_option, food_option_group, food_menu_item, …), so they are edited through normal layouts, versioned, translated, permissioned and exposed over REST/GraphQL like any other content. No code change is needed to add a topping, re-price a size or write a new recipe.

  • Seed — src/platform/templates/seeds/restaurant-menu.ts holds the reference Margherita and

house burger. References between rows use @schema_key:code tokens that the seeder resolves to the generated UUIDs, so the file stays readable. Seeding is idempotent: existing codes are skipped.

  • Server — src/platform/server/menu-configurator.server.ts publishes the template if needed,

seeds on request (seedRestaurantMenu), lists configurable items and loads one full snapshot (loadConfigurableMenuItem) with its groups, options, ingredients and allergens.

  • Bridge — src/platform/commerce/food-menu.ts (buildConfigurableItem) maps those rows to the

engine's ConfigurableItem: recipe lines with quantity/unit/costPerUnit/allergens, removable lines, option ingredient costs and size factors.

  • RPC — src/lib/menu.functions.ts exposes listMenuItemsFn, getConfigurableItemFn and

seedRestaurantMenuFn to the admin UI. The configurator route fetches the item and holds no menu data of its own; an empty workspace shows an "install models & seed demo menu" panel.

  • Editor layouts — src/platform/templates/seeds/configurator-layouts.ts ships curated table

and form layouts for food_menu_item, food_modifier_group, food_modifier_option, food_ingredient, food_pricing_policy and food_menu_category: linked name column, filters and grouping in the lists; tabbed dish form (Presentation / Pricing / Recipe & cost / Guest options / Service) with a relationList for upsell items. installConfiguratorLayouts (src/platform/server/configurator-layouts.server.ts, exposed as seedConfiguratorLayoutsFn, button "Install configurator layouts" on /admin/configurator) publishes them through the normal saveViewDraft → publishView lifecycle, so product and variant editing is standard CRUD in the Content Manager, with the generated REST/GraphQL endpoints unchanged. Re-running updates instead of duplicating; a layout whose model is not published in the account is reported as skipped.

  • Recipe line behaviour is data — the food_recipe_line component carries removable (per-dish

override; empty inherits the ingredient's own flag), removal_credit (money returned when a guest takes the line off) and swap_options (many-to-many to food_modifier_option, the alternatives offered instead). buildConfigurableItem maps them to RecipeLine.removable / removalCredit / swapOptions and derives removableIngredients from the line overrides, so removals, credits and swaps that used to exist only in the hard-coded demo objects are now editable content. DEMO_PIZZA / DEMO_BURGER in src/platform/commerce/demo-configurations.ts are test fixtures only — the reference pizza and burger the workbench prices come from the restaurant-menu seed.

The engine stays in code (pricing, COGS, rules, half portions), the catalogue stays in the database.

How to track COGS and margins on a recipe

Every recipe line and configurable option can carry a costPerUnit. The costing engine uses the same configuration the pricing engine uses, so the margin shown next to the price is auditable line by line.

  • Recipe cost — sum of quantity * costPerUnit * sizeCostFactor for every recipe line still on the dish.
  • Options cost — added options and extras that are not part of the recipe.
  • Removal credits — negative cost lines for recipe ingredients the guest removed (only when the line has a cost).
  • Swap adjustment — swapping out a recipe ingredient credits its cost; swapping in an option adds its cost.
  • Half portions — options with action: "half_left" or "half_right" scale both price and cost by 0.5.
  • Margin / markup — computed from the priced lineTotal:
  • margin = lineTotal - lineCost
  • marginPercent = (margin / lineTotal) * 100
  • markupPercent = (margin / lineCost) * 100

In the admin configurator (/admin/configurator) the Cost & margin panel shows the unit cost, line cost, margin, margin percent and markup percent, plus a per-line breakdown with reason codes (recipe_default, recipe_scaled, option_added, extra_portion, half_portion, recipe_removal, swap_in, swap_out). Use this to validate that a half-pizza or a "no cheese" order still leaves the kitchen with a positive margin.

How to steer pricing from a policy entry instead of code

Publish a Pricing policy (food_pricing_policy) entry and the configurator reads its numbers:

FieldEffect on the cost breakdown
currencyoverrides the dish currency everywhere in the workbench
target_margin_percentverdict below_target plus a suggested price that hits it
min_margin_percentverdict below_floor — the configuration loses money
labour_cost_per_item, labour_cost_per_minutea labour line (per minute uses prep_minutes of the dish)
packaging_costa packaging line
overhead_percentan overhead line, percentage of the ingredient cost
waste_percentwaste_allowance lines for recipe lines that declare no own waste
roundingrounds the suggested price (up_05, up_09, `nearest_1050100`)

Scope the policy with item_types, categories and channels and order competing policies with priority — an empty scope array means "everything". A dish can pin one policy through its pricing_policy relation, which always wins over scope matching. Set waste_percent on a single recipe line to override the policy default (trimming loss on onions, dough scrap), and cost_factor on a size to scale ingredient cost separately from the topping price factor.

The rule trace next to the price now names the entry that authored each rule (item, group or option, with the group code), so fixing a contradictory rule means opening that entry — no code change.

How to get a price change approved (policy versions)

Editing a pricing policy does not change any price. The policy entry is a working copy; prices are produced from an approved version — a frozen snapshot of the numbers above with a checksum and an approval trail. The whole flow lives in the "Pricing policy approvals" panel on /admin/configurator:

  1. Edit the policy entry in the Content Manager as usual. The panel now reports **Working copy

drifted** and lists every changed number (before → after). Prices keep using the previously approved version.

  1. Write a short note (why the numbers change) and press Submit working copy for review. That

freezes the numbers as v(N) with status in_review. Submitting unchanged numbers is refused.

  1. A controller presses Approve v(N) — the version becomes effective immediately, the version it

replaces is retired with an effective_to stamp, and every price from that moment names v(N). Reject sends it back without touching prices.

Because exactly one approved version is effective at a time, every cost breakdown can answer "which ruleset produced this price, who signed it off and when" — the version number, checksum, approver and effective window travel with the price. The history table in the panel keeps all versions, including rejected ones, and each submission and decision also lands in the audit log and fires a pricing_policy.version_* webhook event.

States you may see: Approved & effective (working copy matches what was signed off), Working copy drifted (edits waiting to be submitted), Awaiting decision (a version is in review) and Never approved (nothing was signed off yet, so the policy does not price anything).

Troubleshooting

SymptomCauseFix
403 on RESTmissing token scope or restrictcheck the Access simulator
Field not in the APIFLS or password/secretsecret fields are never returned
Draft not visible publiclydelivery enforces publisheduse a preview token
Publish blockedrequires review in the environmentgo through review

Read-only in the admin panel (UI capabilities)

Authorization is resolved on the server (requireCapability), and the UI only reflects it, so as not to show buttons that would return a 403. The useCapabilities() hook (src/hooks/use-capabilities.ts) reads roles from the session:

const { can } = useCapabilities();
const canManage = can("manage_schema");

<PageHeader title="Channels" actions={canManage ? undefined : <ReadOnlyBadge />} />
{canManage ? <Panel title="Define a channel">…</Panel> : null}

Rules:

  • Preview for all logged-in users: /admin/api, /admin/interfaces, /admin/channels, /admin/sdk

(public contract — the same as openapi.json). A Content editor sees them with a read-only badge.

  • Hidden without the permission (requires in NAV_GROUPS): Environments and Webhooks (manage_api),

Accounts (manage_users), Permissions (manage_permissions), Audit (read_audit).

  • Writes always remain gated by a capability: e.g. saveChannel/applyManifest → manage_schema.

Who has access to what over the API (RBAC + ABAC + scopes)

The access decision is formed in four steps — each subsequent one can only narrow:

  1. The actor's baseline role (user_roles or token roles) → capabilities and actions on a model.
  2. Permission sets (grant, then restrict) and FLS — per user, group, and account.
  3. Layout sections and fields — a tab/group with roles/visibleWhen and a field's Access tab

(permissions.read, permissions.write, readWhen, writeWhen).

  1. API token scopes — an absolute limit for machine calls.

Scope grammar (/admin/tokens → the *Scopes* section):

article:read              # read a single model
article:read:title,slug   # read only the indicated fields
*:read                    # read everything the roles allow
article:write             # write to a single model
*:*                       # unrestricted (the only scope allowing management operations)

The same result applies across REST (/api/public/v1/...), JSON:API, GraphQL, search, export, and both MCP servers — field projection is computed once, in readableFieldsFor/writableFieldsFor.

Bulk operations on models (Schema builder)

The list at /admin/models lets you select multiple models and change them with a single operation:

  • Move to category — moves the selected models within the category hierarchy.
  • Add tags / Remove tags — a comma-separated list (crm, catalog).
  • Publish latest — publishes the latest draft of each selected model (compiles DDL and refreshes REST/OpenAPI/GraphQL).
  • Deprecate / Reactivate — changes the status in the registry without touching versions.

The selection works together with filters: "Select all filtered" takes exactly what's visible after searching, status, kind, category, and tags. Every taxonomy change is a new draft version, so a bulk operation is versioned and audited the same way as a manual edit, and a status change requires the manage_schema capability.

Every panel screen and every public page ends with a Related block with links to the knowledge base and to articles and use cases matching where the user currently is.

  • The rules are described in src/platform/docs/related.ts — one rule per route (/admin/tokens,

/admin/models/ai, /blog, …). For nested routes, the most specific one wins.

  • Documentation links point to a specific section (/docs/knowledge-base#how-to-grant-permissions)

and are verified against the real headings in docs/*.md. Renaming a heading removes the link instead of creating a dead anchor — this is enforced by the related-content.test.ts test.

  • Articles and use cases are read through the public delivery layer (the same mechanism as the blog)

and sorted by context tag coverage, then category. The currently viewed entry is excluded.

When adding a new page: add a rule to RULES (title, one introductory sentence, documentation sections, editorial tags, optional in-app shortcuts). Nothing else is needed in the admin panel — AdminShell renders the panel automatically; on a public page, add <ContextualReading pathname=… />.

The banner lives in src/platform/consent/ and is mounted once in the root shell, so it covers the public site and the admin workspace.

  • Categories: strictly necessary (locked on), preferences, analytics,

marketing. Optional categories are denied until the visitor allows them.

  • The decision is stored in a first-party cookie cookie_consent

({"v":1,"byLocale":{"en":{...},"pl":{...}}}, 182 days). Consent is recorded per UI locale: the wording differs per language, so switching language re-opens the banner instead of reusing a decision made against other copy.

  • CONSENT_VERSION invalidates every stored record — bump it whenever the

categories or the policy wording change.

  • Feature gates read useCookieConsent().allows("analytics"); on the server use

consentFromCookieHeader(request.headers.get("cookie")).

  • Visitors can reopen the panel any time via Cookie settings in the footer;

the banner also links to /legal/cookies.

How to search across models (facets)

  1. Open Search in the admin sidebar (/admin/search) and type at least two characters.
  2. The left rail lists every dimension with a hit count: models, status, locale, categories, tags,

then per-model entry fields (brand, tone, featured, published date, ...).

  1. Checking values narrows the results. Values inside one group are an OR, groups combine with AND.

Counts update as a drill-down: the group you are editing keeps showing its alternatives with usable numbers, so you never click your way into an empty list.

  1. "Not set" filters entries where the field is empty. Dates are grouped into today / last 7 days /

last 30 days / older.

  1. "Why these results" shows, per model, which dictionary was used and whether the query was

answered by the stored index or computed per row.

  1. Same thing over the API: GET /api/public/v1/search?q=lens&facet[status]=published&facet[shop_product.brand]=zeiss

— see docs/API-COOKBOOK.md. GraphQL uses search(q:, facets: [{dimension, values}]).

If a count looks low, check whether the response says the counts are approximate — that means a model hit the scan cap (perModelLimit) and the number is a lower bound; narrow the models or raise the cap.

How to model a telco bundle with the same configurator

DEMO_MOBILE_BUNDLE shows that the configurator is not food-specific — it is a generic option/rule/price engine over a schema:

  • Sizes = plan tiers. price is the recurring base amount, toppingFactor scales every

add-on slot (a booster costs less on a higher tier), costFactor scales wholesale cost.

  • Recipe = what the tariff already includes. Network access and included minutes are

removable: false; SMS, voicemail and cloud carry a removalCredit, so trading them away lowers the recurring price with an auditable reason code.

  • Groups = the commercial catalogue. Contract term, data boosters (quantised, first slot

free), SMS/A2P bundles, roaming, bundled subscriptions, device instalment, insurance, accessories (one-off) and extra lines / multi-SIM.

  • Rules replace hand-written checks. requires ties the flagship instalment to a 24 month

term and the eSIM watch to multi-SIM, excludes blocks data boosters on unlimited data, implies auto-adds Care Basic with the security add-on, recommends warns only.

  • Margin. costPerUnit on every line and option is the wholesale/interconnect cost, so the

Cost & margin panel reports COGS and margin for the bundle exactly like for a dish.

Recurring versus one-off is a property of the price model in commerce-core (recurring vs one_time); in the workbench it is stated in the group name.