#!/usr/bin/env bash # # quickstart.sh — a MooverApp site, from no account to a page on its live domain. # # THIS FILE IS THE DOCUMENTATION. Every shell block on dev.moover.app comes out of the # `#region` blocks below (docs/portal/scripts/content.mjs) — no request in the guides is # typed by hand into a page, because a hand-typed one keeps parsing long after the field # it names is gone, and nobody ever runs it. This one is run. # # Requires: bash, curl (>= 7.76, for --fail-with-body) and jq. # # Usage: # EMAIL=you@example.com PASSWORD=… ./quickstart.sh # an account you already have # RECAPTCHA_TOKEN=… ./quickstart.sh # register a new one # # Knobs, all optional: # SITE_API sign-up API base (default: the production host) # TENANT_API tenant API base (default: the production host) # EMAIL, PASSWORD the account to work as. No EMAIL means "register a new one". # RECAPTCHA_TOKEN a CAPTCHA proof. /site-api is the platform's only unauthenticated # surface and every call on it is bot-checked, so registering from a # terminal needs one; without it, sign up in a browser and pass # EMAIL/PASSWORD instead. # WEBSITE_NAME what to call the new site when registering. # SITE_URL where the finished site answers. Set automatically when this script # registers; needed otherwise, for the last step. # CURL_OPTS extra curl arguments, applied to every call (e.g. --resolve). # set -euo pipefail : "${SITE_API:=https://api.moover.app}" : "${TENANT_API:=https://tenant-api.mvrapp.io}" : "${EMAIL:=}" : "${PASSWORD:=Quickstart2026}" : "${WEBSITE_NAME:=Northwind Store}" : "${RECAPTCHA_TOKEN:=}" : "${SITE_URL:=}" : "${CURL_OPTS:=}" : "${LIVE_TIMEOUT_SECONDS:=120}" # shellcheck disable=SC2086 # CURL_OPTS is a deliberate word-split knob. curl_json() { curl -sS --fail-with-body -H 'Content-Type: application/json' $CURL_OPTS "$@"; } step() { printf '\n\033[1m▸ %s\033[0m\n' "$1" >&2; } if [ -z "$EMAIL" ] && [ -z "$RECAPTCHA_TOKEN" ]; then echo "Set EMAIL and PASSWORD for an existing account, or RECAPTCHA_TOKEN to register." >&2 exit 2 fi if [ -z "$EMAIL" ]; then EMAIL="quickstart-$(date +%s)@example.com" # ------------------------------------------------------------------------------------ step "1. Is that address still free?" # ------------------------------------------------------------------------------------ # A question, not a reservation: the answer can go stale, and the Create below is what # actually decides. Like every call on this surface it is bot-checked — that is the # price of an endpoint anyone can reach without an account. #region check_email curl_json -X POST "$SITE_API/site-api/v1/users:check_email_availability" \ -d "$(jq -n --arg email "$EMAIL" --arg captcha "$RECAPTCHA_TOKEN" \ '{email: $email, recaptcha_token: $captcha}')" | jq '{available}' #endregion # ------------------------------------------------------------------------------------ step "2. An account, and a site being built" # ------------------------------------------------------------------------------------ # Creating the user also creates the account and starts provisioning the first website, # so the response carries three things the user resource itself does not have: the # `account_id`, a one-time `signup_token`, and the `operation` to poll. # # `request_id` makes the call replayable: a timeout you retry with the same value gives # back the first attempt's result instead of provisioning a second site. #region signup signup=$(curl_json -X POST "$SITE_API/site-api/v1/users" \ -d "$(jq -n \ --arg email "$EMAIL" --arg password "$PASSWORD" --arg website "$WEBSITE_NAME" \ --arg captcha "$RECAPTCHA_TOKEN" --arg request_id "quickstart-$(date +%s)-$$" \ '{email: $email, password: $password, website_name: $website, default_language: "en", recaptcha_token: $captcha, request_id: $request_id}')") signup_token=$(jq -r '.signup_token' <<<"$signup") #endregion # ------------------------------------------------------------------------------------ step "3. Waiting for the site to be provisioned" # ------------------------------------------------------------------------------------ # The account exists the moment step 2 returns; the site is not servable until the # Operation that call named is done. `metadata.step` is the hop the work is on — the # honest version of a progress bar. Here the operation's own id is the signup token, so # the poll authenticates itself and still needs no bearer token. #region poll state='{"done": false}' until [ "$(jq -r '.done' <<<"$state")" = true ]; do sleep 2 state=$(curl_json "$SITE_API/site-api/v1/operations/$signup_token") jq -r '" " + (.metadata.step // "?")' <<<"$state" >&2 done # Done means finished, not succeeded: exactly one of `response` and `error` is there. jq -e '.error | not' <<<"$state" >/dev/null || { jq '.error' <<<"$state" >&2; exit 1; } SITE_URL=$(jq -r '.response.website_url' <<<"$state") #endregion fi # -------------------------------------------------------------------------------------- step "4. A bearer token" # -------------------------------------------------------------------------------------- # Everything a site is made of lives behind `Authorization: Bearer `. Two ways to # get one: the password the account signs in with, or — right after signup, so nobody has # to retype the password they just chose — the one-time `signup_token`, traded in exactly # once at `users:exchange_handoff`. #region authenticate token=$(curl_json -X POST "$TENANT_API/tenant-api/v1/users:authenticate" \ -d "$(jq -n --arg email "$EMAIL" --arg password "$PASSWORD" \ '{email: $email, password: $password}')" | jq -r '.token') auth=(-H "Authorization: Bearer $token") #endregion # -------------------------------------------------------------------------------------- step "5. Finding the website" # -------------------------------------------------------------------------------------- # Three lists down the hierarchy the resource names spell out: an account owns projects, # a project owns websites. Every list answers in the same envelope, with the array named # exactly after the collection — one reader handles all three. #region website account=$(curl_json "${auth[@]}" "$TENANT_API/tenant-api/v1/accounts" | jq -r '.accounts[0].id') project=$(curl_json "${auth[@]}" \ "$TENANT_API/tenant-api/v1/accounts/$account/projects" | jq -r '.projects[0].id') website=$(curl_json "${auth[@]}" \ "$TENANT_API/tenant-api/v1/projects/$project/websites" | jq -r '.websites[0].id') site="$TENANT_API/tenant-api/v1/websites/$website" #endregion # -------------------------------------------------------------------------------------- step "6. A block, then a page" # -------------------------------------------------------------------------------------- # A block is a fragment with a handle: pages, themes and rich-text links pull it in by # its `identifier`, and editing it once changes it everywhere it is used. A page is what # a URL resolves to — `url_key` is its last segment on the live site. # # Create answers 201 with the resource and a `Location` header, so the client that just # wrote something never has to read it back to learn what the server made of it. #region create curl_json "${auth[@]}" -X POST "$site/cms_blocks" \ -d '{"title": "Store hours", "identifier": "store-hours", "is_active": true, "content": "

Open Monday to Friday, 9:00–18:00.

"}' >/dev/null url_key="hello-api" page=$(curl_json "${auth[@]}" -X POST "$site/cms_pages" \ -d "$(jq -n --arg url_key "$url_key" \ '{title: "Hello, API", url_key: $url_key, is_active: true, content: "

This page was created with three lines of curl.

"}')") jq '{name, id, create_time, etag}' <<<"$page" #endregion page_id=$(jq -r '.id' <<<"$page") # -------------------------------------------------------------------------------------- step "7. Reading the collection back" # -------------------------------------------------------------------------------------- # The same four parameters on every list on the platform: `page_size`, `page_token`, # `order_by` and `filter`. A field is filterable or sortable only if the resource says so # — an unknown one is an INVALID_ARGUMENT naming it, never a silently ignored URL. #region list curl_json -G "${auth[@]}" "$site/cms_pages" \ --data-urlencode 'page_size=5' \ --data-urlencode 'order_by=create_time desc' \ --data-urlencode 'filter=is_active = true AND title:"Hello"' | jq '{total_size, next_page_token, titles: [.cms_pages[].title]}' #endregion # -------------------------------------------------------------------------------------- step "8. Changing it without overwriting somebody else" # -------------------------------------------------------------------------------------- # PATCH merges: the fields in the body are written, everything else is left alone. The # `etag` a resource carries is its version — send it back in `If-Match` and the write is # refused if anybody has changed the page since you read it. #region etag etag=$(jq -r '.etag' <<<"$page") curl_json "${auth[@]}" -X PATCH "$site/cms_pages/$page_id" \ -H "If-Match: $etag" \ -d '{"meta_description": "Created from the quickstart."}' >/dev/null # That write minted a new etag, so the one above is now stale. Sending it again is what a # second editor's Save looks like: 409 ABORTED, with the current resource in the body. stale=$(curl_json "${auth[@]}" -X PATCH "$site/cms_pages/$page_id" \ -H "If-Match: $etag" -d '{"title": "Hello again"}' || true) jq '.error | {code, status, message}' <<<"$stale" #endregion # -------------------------------------------------------------------------------------- step "9. What a refusal looks like" # -------------------------------------------------------------------------------------- # One shape for every failure on every surface, so a client needs one error reader. Here: # a sort on a field the resource does not declare sortable. #region error refused=$(curl_json -G "${auth[@]}" "$site/cms_pages" \ --data-urlencode 'order_by=content desc' || true) jq '.error | {code, status, message, details}' <<<"$refused" #endregion # -------------------------------------------------------------------------------------- step "10. The page, on the site" # -------------------------------------------------------------------------------------- # Publishing is asynchronous: the write returns as soon as it is stored, and the rendered # site follows a moment later. Polling for it is the whole of the difference. if [ -z "$SITE_URL" ]; then echo "Set SITE_URL to the site's own address to watch the page go live." >&2 exit 0 fi #region live deadline=$(( $(date +%s) + LIVE_TIMEOUT_SECONDS )) until curl -sSf -o /dev/null $CURL_OPTS "$SITE_URL/$url_key"; do [ "$(date +%s)" -lt "$deadline" ] || { echo "$SITE_URL/$url_key never appeared" >&2; exit 1; } sleep 3 done echo "live: $SITE_URL/$url_key" #endregion