# Chavivim Blog API — integration guide

For the developer (and their AI assistant) wiring the **dispatch / central admin portal** to manage blog posts on the Chavivim division sites over HTTP. Pair this with the interactive spec on this same site (`openapi.yaml`, rendered by the Swagger page).

---

## TL;DR

- **Base URL:** `https://chavivim.org/api/v1` — any division host works too (`https://kjchavivim.org/api/v1`); the API is identical on every host.
- **Auth:** send `Authorization: Bearer cvk_…` on **every** request. One cross-tenant key for the whole app.
- **The token authenticates the APP, not the end user.** *Your* app decides which division each of its admins may touch.
- **Format:** JSON in / JSON out. Image upload is `multipart/form-data`.
- **CORS:** open. It's safe to call from a browser, **but keep the token server-side** (a backend-for-frontend) — never ship `cvk_…` to the browser.

---

## The model you must implement (consumer-enforced RBAC)

This is the one thing to get right. The token is **cross-tenant** — it can write to any division. The API does **not** know your users. So:

1. Hold **one** key (scope `blog:*`). Do **not** mint per-division tokens.
2. Every write carries an explicit **`tenant`** (a division slug, or `"shared"`).
3. For each of your admins, keep two facts (mirrors our own `admin/permissions.ts`):
   - `divisions: string[]` — which slugs they may target.
   - `canEditShared: boolean` — may they target the cross-division `"shared"` bucket (it publishes to every division at once, so it's an elevated grant).
4. **Reject a write whose `tenant` is outside that admin's grant BEFORE calling the API.** The API will happily accept it otherwise.
5. Send **`actor`** (the admin's email) on writes — the API stamps it into `created_by` / `updated_by` for per-user audit.

A runnable reference of exactly this lives in the repo at `examples/dispatch-blog-demo/server.mjs`.

---

## Scopes

`blog:read`, `blog:write`, `blog:delete` (or `blog:*`). A 403 with `code: "insufficient_scope"` means the key lacks the scope.

---

## Endpoints & flows (copy-paste)

`$T` = your token. Set `Authorization: Bearer $T` on every call (omitted below for brevity).

### Tenants / targets
```
GET /blog/tenants
→ { "divisions": ["kj","rockland",…], "shared_target": "shared" }
```
Valid `tenant` values = `divisions` + `"shared"`.

### List
```
GET /blog/posts?tenant=kj&status=published&q=fire&limit=200
→ { "posts": [ { id, tenant, slug, title, date, status, hero_url, excerpt, tag, updated_at, published_at, public_url }, … ], "count": N }
```
Omit `tenant` for all divisions. `status` = `draft|published`. (`q` currently scans only the first `limit` rows.)

### Create (as a draft)
```
POST /blog/posts
{ "tenant":"kj", "title":"Title", "date":"2026-06-09",
  "excerpt":"One-liner", "body_md":"## Heading\n\nBody.",
  "tag":"News", "vehicles":["KJ-501"], "units":["KJ-25"],
  "status":"draft", "actor":"admin@dispatch" }
→ 201 { "post": {…full row…}, "urls": { "public_url":"…", "preview_url":"…" } }
```
- `tenant`, `title`, `date` (YYYY-MM-DD) are required.
- `slug` auto-derives from `title` if omitted; `409 slug_conflict` if it already exists in that tenant.
- `body_md` is rendered to `body_html` server-side — the **same** renderer the public site uses.

### Hero / images
Two ways:
```
# 1) Standalone — attach a hero BEFORE the post exists:
POST /blog/images?tenant=kj           (multipart/form-data, part "image")
→ 201 { "image_url": "/media/kj/post-…png" }
# then pass that as hero_url on create/PATCH.

# 2) On an existing post:
POST /blog/posts/{id}/image           (multipart "image"; optional field=hero)
→ 201 { "image_url":"…", "post":{…} }
```
Body images: upload via `/blog/images`, then insert `![alt](image_url)` into `body_md`.

### Editor preview (render markdown the way the site does)
```
POST /blog/preview   { "body_md":"## Hi\n\n- a\n- b" }
→ { "html":"<h2>Hi</h2>…" }
```

### Edit and/or publish
```
PATCH /blog/posts/{id}
{ "excerpt":"edited", "status":"published", "actor":"admin@dispatch" }
→ { "post":{…}, "urls":{…} }

# or status only:
POST /blog/posts/{id}/status   { "status":"published", "actor":"…" }
```
`tenant` is **immutable**. Publishing sets `published_at` and rotates the preview token for you.

### Tag fleet vehicles (drives "See KJ-501 in action" on the fleet page)
```
GET /blog/vehicles?tenant=kj
→ { "tenant":"kj", "vehicles":[ { "code":"KJ-501", "name":"…", "subtitle":"…", "id":"kj-501", … }, … ] }
```
Use the exact `code` values in the post's `vehicles` array.

### Shared (cross-division) posts
Create with `"tenant":"shared"` (gate behind the admin's `canEditShared`). It renders on **every** division — no static file. Same edit/publish/delete flow. `urls` point at the flagship host, but it appears network-wide.

### View / preview links
Every get/create/update returns `urls.public_url` (live) and `urls.preview_url` (a draft URL carrying a rotating token). List items carry `public_url`.

### Delete
```
DELETE /blog/posts/{id}      (optional body { "actor":"…" })
→ { "post_id":"…", "deleted": true }
```
Hard delete — there is no trash / restore.

---

## Errors

Uniform envelope:
```
{ "error": { "code": "insufficient_scope", "message": "…" } }
```
Common codes: `unauthorized` (401), `insufficient_scope` (403), `not_found` (404), `invalid_field` / `invalid_json` (400), `slug_conflict` (409), `method_not_allowed` (405), `no_database` / `no_media` / `service_unavailable` (503). `forbidden_tenant` is *your* app's job, not the API's.

---

## Gotchas (so you don't get stuck)

- `tenant` is **immutable** on edit — no moving a post between divisions.
- **Get is by id only** — to find by slug, list with `?tenant=` and match client-side.
- **Hard delete**, no restore.
- The `q` search currently scans only the first `limit` rows (fine at current volume).
- Keep the token **server-side**; treat it like a password (it's stored only as a hash; shown once at mint).

---

## A complete create→publish in one glance (curl)

```sh
T=cvk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
H="Authorization: Bearer $T"
B=https://chavivim.org/api/v1

# 1. upload a hero (no post yet)
IMG=$(curl -s -H "$H" -F image=@hero.jpg "$B/blog/images?tenant=kj" | jq -r .image_url)

# 2. create a draft with that hero
ID=$(curl -s -H "$H" -H 'Content-Type: application/json' "$B/blog/posts" -d "{
  \"tenant\":\"kj\",\"title\":\"New rig in service\",\"date\":\"2026-06-09\",
  \"hero_url\":\"$IMG\",\"excerpt\":\"…\",\"body_md\":\"## …\",
  \"vehicles\":[\"KJ-501\"],\"actor\":\"admin@dispatch\"}" | jq -r .post.id)

# 3. publish
curl -s -H "$H" -H 'Content-Type: application/json' \
  "$B/blog/posts/$ID/status" -d '{"status":"published","actor":"admin@dispatch"}' | jq .urls
```
