Capabilities and integrations
Auth0, Stripe, S3, Cloudflare Images and the embedded store, with their maturity.
A Crisptastic page is not meant to need its own backend server. For the things
a site commonly needs, such as signing users in, taking payments and
uploading files, the framework provides ready-made pieces that you configure in
crisptastic.toml.
There are two different mechanisms, and it matters which one you are using.
| Helper endpoints | @call capabilities | |
|---|---|---|
| What you get | A JSON endpoint on your own site, such as /_crisp/payments/checkout | A function-like call inside a page: @call result = mail.send({...}) |
| Integrations | Auth0, Stripe Checkout, S3, Cloudflare Images | data (single host), llm, mail, arbax |
| How a page uses it | @fetch it from the page, or call it from browser JavaScript | @call |
| Maturity | Working, with the caveats below | Experimental (except where stated) |
Maturity labels used in this guide. Working: implemented and tested. Preview: works but has only been tried in limited trials. Experimental: implemented but labelled experimental by the project; do not build a production site on it without talking to the maintainers. Not implemented: described in a specification but absent from the code.
There is no local fake for any integration. In development a configured provider talks to the real service (subject to the outbound rules in Fetching and shaping data), so use test-mode accounts and test keys.
Where secrets for integrations come from
Each provider setting that is a secret comes in two forms: a literal
(secret_key = "sk_test_...") or an environment-variable name
(secret_key_env = "STRIPE_SECRET_KEY"). Prefer the _env form so the secret
never lives in your repository.
The variable is read from the environment of the process that runs the
site: your shell for dev, and the host process for a deployed site. Note
that these are not the sealed secure variables of a
Capsule. On a host you must therefore provide them to the crisptastic host
process itself (for example in its service unit), and every site on that host
sees the same environment. Only the experimental Wasm capabilities read
Capsule secure variables.
A literal secret written directly into crisptastic.toml is stored in the
Capsule's configuration and unpacked to the host's disk in plaintext. Do not do
this for real secrets.
Helper endpoints
Each endpoint is declared under [X.endpoints.<name>] with a path (must start
with /; the convention is /_crisp/...), a provider and a kind. Providers
are declared under [X.providers.<name>]. A configured path is answered by the
framework before routes and assets, and it must not clash with a page or asset
(that is a startup error).
Responses are JSON. Errors use application/problem+json. A wrong HTTP method
gets 405.
Important security properties of all helper endpoints today
- They are not authenticated and not tied to a user. Anyone who can reach your site can call them, subject only to CSRF checks that apply to sessions (see below). Treat each endpoint as a public API and design accordingly, for example by creating Stripe sessions only for prices you intend to sell.
- On a production host the CSRF check applies to
POSTrequests that carry a session. Anonymous requests are not checked.- Upstream error bodies from the provider can be echoed in
502responses.Review these before exposing them. The maintainers' notes on known issues are collected in Known gaps.
Using an endpoint from a page
A page calls an endpoint with @fetch. Because of the outbound rules, the
request must go to your site's public HTTPS origin, so a page cannot call
its own endpoint on http://localhost during development. The simpler route in
most cases is to call the endpoint from browser JavaScript:
<form id="buy">
<input type="hidden" name="_csrf" value="{{ context.csrf.token }}">
<button>Buy</button>
</form>
<script>
document.getElementById("buy").addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch("/_crisp/payments/checkout", {
method: "POST",
headers: { "content-type": "application/json",
"x-csrf-token": e.target._csrf.value },
body: JSON.stringify({ price_id: "price_123", quantity: 1 })
});
const data = await res.json();
if (data.ok) location = data.checkout_url;
});
</script>
Auth0 sign-in ([auth]), working but early
Declares helper endpoints that talk to an Auth0 tenant.
[auth.endpoints.status]
path = "/_crisp/auth/status"
provider = "auth0"
kind = "status"
[auth.endpoints.callback]
path = "/_crisp/auth/callback"
provider = "auth0"
kind = "callback"
[auth.providers.auth0]
type = "auth0"
domain_env = "AUTH0_DOMAIN"
client_id_env = "AUTH0_CLIENT_ID"
client_secret_env = "AUTH0_CLIENT_SECRET"
callback_url_env = "AUTH0_CALLBACK_URL"
# logout_url_env = "AUTH0_LOGOUT_URL" optional
For each of domain, client_id, client_secret and callback_url give
exactly one of the literal or the _env form; logout_url is optional.
| Kind | Methods | What it does |
|---|---|---|
status | GET, POST | Fetches the tenant's discovery document and reports that the provider is configured |
callback | POST | Takes {"code": "..."}, exchanges it with Auth0, reads the user profile, and returns { ok, identity: { subject, name, email, email_verified, picture } }. Tokens are never returned. |
What it does not do: it does not build the sign-in redirect, generate or
check the state value, validate ID tokens, create the session or log the user
out. Your pages do that. The shape of the flow:
- A
/loginpage generatesstate = secure_token(16), stores it in the session, and@redirects tohttps://<tenant>/authorize?plusform_urlencode({...}). - Auth0 redirects back to your callback page with
?code=...&state=.... - The callback page compares
request.query.statewith the stored value, then@fetches the helper'scallbackendpoint with the code. - On success it does
@session user = identity.emailand redirects. - A logout page does
@cookie session = nulland redirects to Auth0's/v2/logout.
A production host forbids session writes and non-GET fetches during a GET,
so the callback step must be handled on a POST there.
Stripe Checkout ([payments]), working
[payments.endpoints.create]
path = "/_crisp/payments/checkout"
provider = "stripe"
kind = "checkout_session_create"
success_url = "https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}"
cancel_url = "https://example.com/cart"
[payments.endpoints.get]
path = "/_crisp/payments/session"
provider = "stripe"
kind = "checkout_session_get"
success_url = "https://example.com/thanks"
cancel_url = "https://example.com/cart"
[payments.providers.stripe]
type = "stripe"
secret_key_env = "STRIPE_SECRET_KEY"
Both kinds accept POST only.
checkout_session_createtakesprice_id(required),quantity(default 1),mode(payment,subscriptionorsetup; defaultpayment),customer_email,client_reference_idandmetadata. It returnscheckout_session_idandcheckout_url; send the browser there.checkout_session_gettakessession_idand returns the session'sstatus,payment_status,amount_total,currencyandmetadata.
On return from Stripe, trust only a server-side read: look up the session
with checkout_session_get and fulfil the order only when
payment_status = "paid". Do not trust query-string values.
Not implemented: webhooks, refunds, idempotency keys, price allow-lists, and customer portal.
File and image uploads ([storage])
Declares endpoints that hand the browser a presigned URL to upload straight to object storage, so file bytes never pass through your site.
[storage.endpoints.upload]
path = "/_crisp/files/upload-intent"
provider = "files"
kind = "upload_intent"
[storage.endpoints.finalize]
path = "/_crisp/files/finalize"
provider = "files"
kind = "finalize_upload"
[storage.endpoints.download]
path = "/_crisp/files/download"
provider = "files"
kind = "download_resolve"
[storage.providers.files]
type = "s3"
bucket = "my-app-private"
region = "eu-west-1"
endpoint = "https://s3.eu-west-1.amazonaws.com"
key_prefix = "uploads/"
expires_in_s = 300
access_key_env = "S3_ACCESS_KEY"
secret_key_env = "S3_SECRET_KEY"
| Kind | Method | Request | Result |
|---|---|---|---|
upload_intent | POST | { "key": "a/b.png", "content_type": "image/png" } | A presigned PUT URL and headers |
finalize_upload | POST | { "key": "a/b.png" } | Checks that the object exists |
download_resolve | GET / HEAD | ?key=a/b.png | A presigned GET URL |
Keys are prefixed with key_prefix; empty segments, ./.. and backslashes
are rejected. There are no size or type limits and no per-user
scoping: any caller can request an upload URL for any key under the prefix.
For Cloudflare Images use type = "cloudflare-images" with account_id,
delivery_base_url, direct_upload_expiry_s, api_token or api_token_env,
and optionally delivery_signing_key (needed for download_resolve) and
delivery_url_expiry_s. Its upload_intent takes { id?, require_signed_urls?, metadata? }<code>,</code>finalize_upload<code>takes</code>{ image_id }<code>, and</code>download_resolve
takes ?image_id=&variant=. A caller-supplied id is accepted.
@call capabilities
@call result = capability.operation(inputExpression)
The call takes exactly one input expression (usually an object) and binds an envelope:
{ ok: true, value: ..., status: 200, error: null, meta: { ... } }
{ ok: false, value: null, status: 503, error: { code, message, retryable }, meta: {} }
A failed call does not stop the page; branch on result.ok. Operations that
change something (everything except reads) are refused during GET and HEAD
requests, so put them in a page that handles POST.
data: an embedded Residiuum store, single host only
data is an embedded Residiuum store that belongs to the application: content
collections for a content site, and record collections for application data.
It runs under dev and run, and on a single-host crisptastic host,
where the host keeps one store per application_id beneath its host root and
hands it to every generation of the site that declares it (see
Running a host). A Fabric
cluster refuses it: a store lives on one member.
[data]
provider = "residiuum-embedded-development"
root = "../data" # dev and run only, default .crisptastic/residiuum; a host chooses the location
application_id = "my-site" # the store's name; one site owns it
records = ["members", "lists"] # record collections, or
collection = "content" # ...the four content collections
site_collection = "site"
route_collection = "routes"
asset_collection = "assets"
memory_budget = "256M" # this store's own budget
unseal_secret = "RESIDIUUM_UNSEAL_KEY" # hosted: the secure variable that creates the store
Content operations: data.content_by_slug({ locale, slug }),
data.content_get({ id, locale, allow_fallback }),
data.navigation_get({ id, collection, locale }). Each record collection
name has data.name_get({ id }), data.name_list({ ... }),
data.name_create({ id, document }), data.name_replace({ ... }) and
data.name_delete({ id }). Writes need a POST. A route that declares
capabilities must list data. See
A guestbook with its own store.
llm, mail and arbax: experimental
These are WebAssembly components run by the host. They are labelled
experimental by the project, and the .wasm files are not shipped.
What makes them different from the helper endpoints:
- They read their credential from a Capsule secure variable, named by the
secret = "NAME"setting and supplied through[env_from_process]. The value never appears in pages. - Their operations are single-shot (
terminal_once) and therefore need aPOSTrequest.
Illustration, from the mail example:
@call accepted = mail.send({ sender: "orders", to: request.body.to, subject: request.body.subject, text: request.body.text })
{{~ accepted | json }}
You cannot add your own capability names from a site. The set of capabilities is fixed by the host program.
Telemetry
See Configuration and the --debug option in
the CLI reference. It is a development aid and is not active on a
deployed host.
What is not available
These are named in older documents but do not exist: a messaging capability,
@telemetry, per-operation retries and circuit breakers, local capability
fakes, Stripe webhooks and refunds, and an @call-style auth, payments,
files or images.