# Build a website on Mosana

This guide is for an assistant (or a developer) building a customer's own website that sells and shows what a Mosana profile has.
The working example is **github.com/chaldeastudios/mosana-test-ecommerce** (Next.js). Read its `src/lib/mosana.ts`, `src/lib/site.ts`,
`src/app/page.tsx` and `src/app/product/[id]/page.tsx` first; this guide says why it is built the way it is, so another website,
in any framework, can be built the same way.

The same text is served by Mosana's MCP (resource `mosana://guides/build-a-website`, or the tool `get_website_guide`) and `GET /api/v1/guides/website`.

## The idea: one key

The owner makes the website in Mosana: **Sell → (profile) → Websites → Connect a website**. They give a name and the website's
address(es), and Mosana shows **one key** (`msk_` and 48 letters and digits) once. The website's only setting is that key:

```
MOSANA_KEY=msk_...        # server only, never in the browser or the repository
MOSANA_URL=https://...    # optional: only if Mosana is not at its default address
```

Everything else is learned from the key, so there is nothing to copy between systems:

- `GET {MOSANA_URL}/api/v1/site` (Bearer key) → `{ id, name, domains[], client_id, profile: { id, name, handle } }`. Cache it for a minute.
  `profile.id` goes in every profile path; `client_id` is what customers sign in with; `domains` are the addresses the website may run on.
- The website works out **its own address from the request** (`x-forwarded-host`/`host`, `x-forwarded-proto`) and refuses to start
  payments or sign-in from an address that is not in `domains` (Mosana would refuse anyway). Changing the address is done in Mosana.
- The owner can replace the key (the old one stops at once) or change the addresses in the same page. Run no setup scripts.

The key is bound to one profile and can only: read the profile and its products, and make payment links. It cannot change anything,
use pictures, files or video, or see the owner's contact details. Running the business (products, prices, stock, orders, payouts,
memberships) is done in Mosana, not in the website: **the website has no admin**.

## What the website reads (Bearer key, `{MOSANA_URL}/api/v1`)

| Call | Gives |
|---|---|
| `GET /profiles/{id}` | name, handle, bio, location, `logo_url`, `cover_url`, followers |
| `GET /profiles/{id}/products?status=active&limit=200` | every listing on sale: `id, kind, video_type, name, description, access, price_minor, currency, free, available_qty, plan_id, gallery_urls[], still_url, duration_s, url` |
| `GET /profiles/{id}/products/{product_id}` | the listing in full: also `options`, `variants[]` (each with `id`, `options`, `price_minor`, `available_qty`, `gallery_urls`), `attrs` (director, cast, author, pages...), `file`, `weight_g`, `trailer_still_url`, `course_id`, `lessons` |
| `GET /profiles/{id}/courses/{product_id}` | a course's outline: sections, lessons (title, kind, minutes, `preview`) |
| `GET /profiles/{id}/plans` | memberships: `name, description, amount_minor, interval, includes[], join_url` |

Amounts are whole cents (KES 250 is `25000`). Pictures and stills are ready-made addresses on Mosana (`gallery_urls`, `still_url`):
use them in `<img>` as they are. `kind` is one of `physical`, `digital`, `book`, `course`, `video` (a video whose `video_type` is `movie` is a film).

## What is bought on the website, and what happens on Mosana

The website **shows everything**. What it can **sell** is anything that costs money. The rest is handed to Mosana, which holds the
signed video, the lessons and the billing:

| Listing | On the website | On Mosana |
|---|---|---|
| paid physical, download, book, course, video, film (`access` "paid", `price_minor` > 0) | cart → pay link | after paying: watching, reading, learning (links in the customer's library) |
| free (`access` "free": the seller marked it free; a price of 0 never means free) | a "Free" tag and a button to `url` | watching, taking, enrolling |
| members-only video or course (`access` "members", `plan_id` set, no price of its own: the membership is the price; it can never go in a cart) | tag it "Members" (a film: "Film" and "Members"), and on its page say "join the membership to watch it" with the plan's `join_url` | joining, then watching |
| membership (plan) | the price and what it includes, button to `join_url` | joining, billing, renewal |

Never show a `plan_id` item as "Free" or buyable, even if its price is 0.

## Paying: pay links

The cart lives in the website (browser storage); it is only a list of ids and quantities (a product with options uses the
**variant's id**). To pay:

1. `POST /profiles/{id}/pay-links` with `{ "items": [{ "product_id": "...", "qty": 1 }], "return_url": "<own address>/thanks", "email": "optional" }`
   → `{ id, url, expires_at }`. `return_url` must be on a registered address. Prices, delivery, fees and stock are decided by Mosana
   when the shopper pays: **never send or trust prices**.
2. Send the shopper to `url` (Mosana's `/pay/<id>`: card, M-Pesa, saved card, delivery address, receipt).
3. They come back to `return_url?pay_link=<id>&status=paid|expired|cancelled|failed`. The address is only a hint: confirm with
   `GET /profiles/{id}/pay-links/{pay_link_id}` → `status` (`open | paid | expired`) and `orders[]`. Clear the cart only when `paid`.
   **In a new tab instead of a redirect** (what the example website does): open the tab from the click itself (`window.open("about:blank", "_blank")`, browsers block it otherwise), create the pay link, then set the tab's location to `url`. The shopper pays on Mosana and the website's own page stays where it was. Meanwhile the page asks the website's server (which asks `GET /pay-links/{id}`) every few seconds, and also when the shopper comes back to the tab, and moves on when `status` is `paid`. If the tab is blocked, redirect instead. (A small pop-up window also works: add `?display=popup` and the pay page posts `{ type: "mosana:pay", pay_link, status }` to its opener, only to your registered origin, then closes; treat that as a hint too.)

   **Mosana also tells the website by itself.** Nothing is registered or set up: when a pay link is paid, or runs out unpaid, Mosana posts an event to `<the origin of return_url>/api/mosana/webhook`, which is one of the addresses registered in Sell → Website. The body is `{ "id": "evt_<pay_link>_paid", "type": "pay_link.paid" | "pay_link.expired", "created": <unix seconds>, "data": { "pay_link", "status", "profile_id", "return_url", "paid_at", "orders": ["MOS-…"], "saved_to_cart" } }`. It is signed: header `x-mosana-signature: t=<unix seconds>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `"<t>.<body>"` keyed with the text of **the SHA-256 hex of the website's key** (the website computes that itself from `MOSANA_KEY`; refuse an event whose `t` is more than five minutes old). Answer 2xx. Mosana retries a failure a few times over about a day (1, 5, 30 minutes, 2 hours, then 12-hourly), and sends nothing if the endpoint answers 404, so a website that doesn't need events needs no endpoint. An event can arrive twice: its `id` is its identity. This is the place to record the order, send your own email or update stock in your own system, even if the shopper closed the page.
4. If the shopper leaves without paying, Mosana puts the items in their Mosana cart (and, if it is a guest, forgets the link a week later) and tells them. The website does nothing. Pay links are tidied up: an unpaid one is deleted after 7 days for a guest or once its items are in a cart, after 30 days otherwise; a paid one after 180 days (the orders are kept for good).

## Delivery

For physical items Mosana asks the shopper for the delivery address and how it should be delivered (door delivery, next day, same day), with the price of each. The website sends none of it and never needs to: it is on the pay page.

## Customers: Sign in with Mosana

OAuth authorization code with PKCE (S256), as a public client. Customers use their Mosana account; the website sees **only this
profile's** orders and downloads.

- Start: redirect to `{MOSANA_URL}/oauth/authorize?response_type=code&client_id={client_id}&redirect_uri={origin}/auth/callback&code_challenge=…&code_challenge_method=S256&state=…&scope=shopper:profile shopper:orders shopper:library`. The redirect URI must be `<registered address>/auth/callback`.
- Callback: `POST {MOSANA_URL}/oauth/token` (form-encoded) with `grant_type=authorization_code, code, code_verifier, client_id, redirect_uri` → `access_token` (an hour), `refresh_token`. Refresh with `grant_type=refresh_token`. Revoke at `/oauth/revoke`. Keep tokens in httpOnly cookies.
- With the customer's token as Bearer: `GET /shopper/me`, `GET /shopper/orders`, `GET /shopper/orders/{number}`, `GET /shopper/library` (each item has `open_url` for things taken on Mosana), `POST /shopper/library/{product_id}/download` → a five-minute file link.
- Each order says "See full order details in Mosana." with `more_url`: show it. Nothing from the customer's shopping with other profiles is ever returned.

## Making it the owner's own: curation

The listings come from Mosana, but **how they are shown is the website's own**: sections, ordering, a featured film, collections,
copy around them. Choose by `id` (or by rule) in the website's code, and show the rest as usual. In the example, `src/shop.config.ts`
holds a list of sections (a heading and a rule that picks listings: the featured film is one id, or the newest film if none is
given); the home page shows each, then the usual lists with everything already shown left out, so nothing appears twice. Build more
sections the same way (new arrivals, free to watch, members' picks, a book bundle) by filtering the same listing call.

## Rules that keep it safe and simple

- The key stays on the server (route handlers, server components). Never ship it to the browser.
- Do not store customers, orders or money anywhere; ask Mosana each time.
- Do not build an admin: point the owner to Mosana.
- Treat every field from Mosana as text (escape it); do not trust query strings (`status=paid`) without checking the pay link.
- Expect errors as `{ error: { status, code, message } }`; show `message`.
- Provide a `/status` page that checks, without printing the key: key present, shaped `msk_` + 48, Mosana answers it, which profile,
  and whether the current address is registered. Most setup problems are: `MOSANA_KEY=` pasted into the value, a variable set for
  the wrong environment, or no redeploy after adding it.
