You flagged three concrete gaps after the deploy went live — all
addressed in this commit, plus the API + how-to docs you asked for.
A) Create Project dialog was still HP-centric
Placeholders like "HP Envy x360 Renders" / "HP-2026-001" / "NPI /
Refresh" / "Form Factor" etc. bore no relation to Dow's actual XLSX
columns and the form had no ClientTeam selector — so any
admin-created project was orphaned from the visibility layer.
- src/lib/validators/project.ts: added clientTeamId + omgJobNumber;
status enum now includes PIPELINE and CANCELED
- src/components/projects/project-form-dialog.tsx: rewritten around
the Dow XLSX schema. Three tabs (Details / Dates / References)
instead of four. Placeholders reference real Dow values
(Celena / Yzabella etc. for Owner, 2337959 for OMG #, Brand / Events
etc. for Team, Copywriting/Display/... for Category). ClientTeam
selector populated from /api/client-teams with a "no teams — add
one in Settings" fallback. Category is a typed enum dropdown with
the 8 XLSX values. Risk/Priority wording mirrors the XLSX labels
(Priority = URGENT). Dropped HP-only fields from the UI
(formFactor, codeName, npiOrRefresh, businessUnit placeholder,
agency, Financial tab, Workfront ID placeholder). Legacy fields
are still in the Zod schema for back-compat but not rendered.
B) Users invisible because only the admin was seeded
The plan flagged "real Dow/Oliver roster — open question" and we
never got the list, so the seed only created admin@dowjones.com.
prisma/seed-dow.ts now also creates the 9 placeholder resources
from the Resources.html prototype (Alice Chen, Ben Marsh, Cara Wu,
Dan Koch, Eva Stone, Frank Osei, Grace Lee, Hiro Tanaka, Isla Reeve),
distributed round-robin across the three placeholder pods. Each has
role + department + maxCapacity set but no passwordHash, so they
show up in the UI immediately but can't log in until an admin
invites them via Settings → Team (which issues a reset link).
Swap for the real roster whenever Zia delivers it — the emails are
@example.com so they're safe to delete.
C) Resource Manager page (matching Resources.html)
New capacity planner UI — daily hours-per-job grid.
- Schema: new ResourceBooking model { userId, date, jobNumber,
hours, note, organizationId, createdById }. Migration at
prisma/migrations/20260421000000_resource_bookings.
- Validator (src/lib/validators/booking.ts): create + list schemas
with date-only coercion.
- Service (src/lib/services/booking-service.ts): week window
helpers, create/list/delete + known-job-numbers lookup for the
popover autocomplete.
- API: GET/POST /api/resources/bookings, DELETE
/api/resources/bookings/[id], GET /api/resources/job-numbers.
Writes gated to ADMIN + PRODUCER; reads open to any signed-in
member of the org (capacity view is a shared studio-level thing,
not per-team visibility).
- Hook (src/hooks/use-bookings.ts) with TanStack Query wiring +
week-scoped cache keys.
- Page (src/app/(app)/resources/page.tsx) ports the Resources.html
design to the app's Tailwind + shadcn primitives: Resource × Day
grid grouped by department, week navigator, click-to-assign
popover with job-number autocomplete + hour chips (1/2/3/4/6/8 +
custom), capacity bar per cell, week total column with over-cap
warning, collapsible role bands. Matches the prototype's
color-hashed job chips so the same job number gets a consistent
color across the grid.
- Sidebar nav: added "Resources" entry next to Workload.
D) Docs — full README + API reference + how-to
- API.md: complete REST + webhook reference. Three auth modes
documented (session cookie / X-API-Key / OMG HMAC). XLSX upload
header map with the Dow XLSX column correspondences. OMG webhook
has the speculative payload shape + a working bash example that
signs + sends a request. Common flows at the bottom: bootstrap
from zero, OMG publishes a status change, update a job from an
external script.
- HOWTO.md: end-to-end runbook. Mental model, local dev, prod
deploy pointers, first-login ritual, add-users flow (UI + API),
client teams + pods config, XLSX ingest (UI + curl + idempotency
notes), OMG webhook wiring (secret gen through verification),
producer daily workflow, client-viewer experience, resource
planning walk-through, RBAC matrix, common-problems table, and
"change the model" pointer map for future edits.
- README.md: top intro now points at API.md / HOWTO.md / DEPLOY.md.
Verified: npx tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
526 lines
14 KiB
Markdown
526 lines
14 KiB
Markdown
# Dow Jones Studio Tracker — API Reference
|
|
|
|
All endpoints live under the app's base path **`/dow-prod-tracker`**. Examples
|
|
in this doc use `https://optical-dev.oliver.solutions/dow-prod-tracker` as the
|
|
origin — substitute your own when self-hosting.
|
|
|
|
**The canonical key for jobs is `omgJobNumber`.** XLSX uploads and the OMG
|
|
webhook both upsert projects on this field. The REST API exposes it on every
|
|
project payload.
|
|
|
|
---
|
|
|
|
## Authentication
|
|
|
|
Three ways to call the API.
|
|
|
|
### 1. Session cookie (browser / user-flow)
|
|
|
|
After a user signs in via `POST /api/local-auth/login`, the response sets an
|
|
`authjs.session-token` cookie. Subsequent requests in the same browser use
|
|
that cookie automatically. Session gets per-team visibility scoping
|
|
(admins see everything; others see only their `ClientTeam`'s projects).
|
|
|
|
### 2. API key (machine-to-machine)
|
|
|
|
Send `X-API-Key: <value>` where `<value>` matches the server's `API_KEY` env
|
|
var. The caller is authenticated as the first ADMIN in the resolved org.
|
|
The `X-Org-Id: <cuid>` header can narrow to a specific org (defaults to the
|
|
first one found).
|
|
|
|
### 3. HMAC (webhooks only — see the OMG section below)
|
|
|
|
Scoped to `/api/webhooks/*`, not the rest of the API.
|
|
|
|
---
|
|
|
|
## Data model — what is a "job"?
|
|
|
|
In Dow terms: a **job** ≈ one row in the Studio Tracker XLSX ≈ one `Project`
|
|
in the system. Key fields:
|
|
|
|
| App field | XLSX column | Notes |
|
|
|---|---|---|
|
|
| `omgJobNumber` | Number | **Canonical key.** String. Upsert primary. |
|
|
| `name` | Project Name | E.g. `"2337959 — BRND-CMKT-WSJ-March Education Media Pack"` |
|
|
| `status` | Status | Enum: `PIPELINE`, `ACTIVE`, `ON_HOLD`, `COMPLETED`, `CANCELED`, `ARCHIVED` |
|
|
| `priority` | Risk | Enum: `LOW`, `MEDIUM`, `HIGH`, `URGENT` (from Low / Medium / High / Priority) |
|
|
| `clientTeamId` | Team | FK → ClientTeam. Drives visibility. |
|
|
| `businessUnit` | Project Category | Copywriting / Display / GIF / PDF / Print / Social / Static / Video |
|
|
| `requestor` | Owner | Project manager name |
|
|
| `startDate` | Brief Acceptance Date | ISO 8601 |
|
|
| `dueDate` | External Deadline | ISO 8601 |
|
|
| `description` | Status Details | Free-form |
|
|
|
|
Under each project are **deliverables** (1:N), under each deliverable are
|
|
**pipeline stages** (N per template), which carry **revisions**, **comments**,
|
|
**annotations**.
|
|
|
|
---
|
|
|
|
## Endpoints
|
|
|
|
### Health
|
|
|
|
```
|
|
GET /api/health → 200, liveness only (DB + env vars)
|
|
GET /api/health?strict=1 → 200 only if seeded (org + pipeline templates present)
|
|
```
|
|
|
|
Return shape:
|
|
|
|
```json
|
|
{
|
|
"status": "healthy",
|
|
"mode": "liveness",
|
|
"checks": {
|
|
"database": { "status": "ok" },
|
|
"pgvector": { "status": "ok", "detail": "v0.8.2" },
|
|
"organization": { "status": "ok", "detail": "1 org(s)" },
|
|
"pipeline_templates": { "status": "ok", "detail": "1 template(s)" },
|
|
"dev_bypass": { "status": "ok" },
|
|
"auth_secret": { "status": "ok" }
|
|
},
|
|
"timestamp": "2026-04-21T..."
|
|
}
|
|
```
|
|
|
|
Use the liveness endpoint for Docker/Kubernetes health checks; `strict=1` is
|
|
for post-deploy verification.
|
|
|
|
---
|
|
|
|
### Projects (CRUD)
|
|
|
|
#### List
|
|
|
|
```
|
|
GET /api/projects
|
|
```
|
|
|
|
Scoped by visibility. Returns JSON array of:
|
|
|
|
```jsonc
|
|
[
|
|
{
|
|
"id": "cmo7...",
|
|
"omgJobNumber": "2337959",
|
|
"projectCode": "BRND-CMKT-WSJ-03",
|
|
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
|
|
"status": "ACTIVE",
|
|
"priority": "HIGH",
|
|
"startDate": "2026-03-10T00:00:00.000Z",
|
|
"dueDate": "2026-04-03T00:00:00.000Z",
|
|
"businessUnit": "PDF",
|
|
"requestor": "Celena",
|
|
"clientTeam": { "id": "...", "name": "Brand", "slug": "brand" },
|
|
"_count": { "deliverables": 42 },
|
|
"updatedAt": "2026-04-20T..."
|
|
}
|
|
]
|
|
```
|
|
|
|
#### Get one
|
|
|
|
```
|
|
GET /api/projects/:projectId
|
|
```
|
|
|
|
Returns the project with nested `deliverables[].stages[]` — enough data to
|
|
render the Kanban / deliverable detail page in one fetch.
|
|
|
|
#### Create
|
|
|
|
```
|
|
POST /api/projects
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"projectCode": "BRND-CMKT-WSJ-03",
|
|
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
|
|
"omgJobNumber": "2337959",
|
|
"clientTeamId": "cmo7...",
|
|
"businessUnit": "PDF",
|
|
"priority": "HIGH",
|
|
"status": "ACTIVE",
|
|
"startDate": "2026-03-10",
|
|
"dueDate": "2026-04-03",
|
|
"requestor": "Celena",
|
|
"description": "Timeline notes go here"
|
|
}
|
|
```
|
|
|
|
Responses: `201` + project body, `400` on validation errors, `403` if caller
|
|
lacks `PROJECT_CREATE`. Caller needs an existing client team — fetch via
|
|
`GET /api/client-teams`.
|
|
|
|
#### Update
|
|
|
|
```
|
|
PATCH /api/projects/:projectId
|
|
```
|
|
|
|
Same body shape, all fields optional. Visibility pre-checked.
|
|
|
|
#### Delete
|
|
|
|
```
|
|
DELETE /api/projects/:projectId
|
|
```
|
|
|
|
Cascades deliverables + stages.
|
|
|
|
---
|
|
|
|
### XLSX upload — the "Job Tracker " sheet
|
|
|
|
The bulk-import endpoint parses the Dow Studio Tracker workbook and upserts
|
|
one project per row (keyed on `omgJobNumber`).
|
|
|
|
#### Preview (dry-run, nothing written)
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-H "X-API-Key: $API_KEY" \
|
|
-F "file=@Dow Jones_Studio Tracker_Example.xlsx" \
|
|
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=false"
|
|
```
|
|
|
|
Response:
|
|
|
|
```json
|
|
{
|
|
"preview": true,
|
|
"totalRows": 27,
|
|
"validRows": 26,
|
|
"errors": [
|
|
{ "row": 21, "reason": "omgNumber: Invalid input" }
|
|
],
|
|
"rows": [
|
|
{
|
|
"omgNumber": "2337959",
|
|
"projectName": "2337959 - BRND-CMKT-WSJ-March Education Media Pack-26",
|
|
"clientTeamSlug": "brand",
|
|
"status": "ACTIVE",
|
|
"priority": "HIGH",
|
|
"externalDeadline": "2026-04-03T00:00:00.000Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
The first 25 normalized rows ship in the response so you can show a
|
|
preview UI. Errors are row-scoped and don't abort the batch.
|
|
|
|
#### Commit (actually write)
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-H "X-API-Key: $API_KEY" \
|
|
-F "file=@Dow Jones_Studio Tracker_Example.xlsx" \
|
|
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=true"
|
|
```
|
|
|
|
Response:
|
|
|
|
```json
|
|
{
|
|
"preview": false,
|
|
"totalRows": 27,
|
|
"imported": 26,
|
|
"created": 18,
|
|
"updated": 8,
|
|
"deliverablesCreated": 267,
|
|
"errors": [
|
|
{ "row": 21, "reason": "omgNumber: Invalid input" }
|
|
]
|
|
}
|
|
```
|
|
|
|
- Idempotent on `omgJobNumber` — re-upload the same file tomorrow and rows
|
|
with unchanged numbers update in place; rows with new numbers create.
|
|
- Rows that already exist: only fields that are set on the new row overwrite;
|
|
blank cells don't clobber producer-edited data.
|
|
- On create, one `Deliverable` is spawned per unit of `# of outputs` and the
|
|
default Dow pipeline stages are attached.
|
|
|
|
#### Expected XLSX structure
|
|
|
|
Sheet name: **`Job Tracker `** (trailing space is tolerated — parser
|
|
normalizes headers). Header row detected automatically within the first
|
|
5 rows. Row 2 (the example/instructions row) is skipped.
|
|
|
|
Column mapping is substring-matched on normalized headers:
|
|
|
|
| Header (any case / trailing space) | Maps to |
|
|
|---|---|
|
|
| `Owner` | `requestor` |
|
|
| `Risk` | `priority` (Priority→URGENT, High→HIGH, etc.) |
|
|
| `Creative Team Member…` | assignee (not persisted on Project; flagged in errors) |
|
|
| `Number` | `omgJobNumber` **(required)** |
|
|
| `Team` | `clientTeamId` (resolved by slug; auto-creates if missing) |
|
|
| `Status` | `status` (Brief in Review→PIPELINE, Amends/In production/Client Review→ACTIVE, On hold→ON_HOLD) |
|
|
| `Project Category` | `businessUnit` |
|
|
| `Project Name` | `name` **(required)** |
|
|
| `Brief Acceptance Date` | `startDate` |
|
|
| `External Deadline` | `dueDate` |
|
|
| `# of outputs` | count of deliverables created |
|
|
| `Status Details` | `description` |
|
|
|
|
---
|
|
|
|
### OMG webhook — near-real-time ingest
|
|
|
|
For the OMG platform to push job updates directly. Same upsert path as the
|
|
XLSX importer, so the two can never drift.
|
|
|
|
#### Endpoint
|
|
|
|
```
|
|
POST /api/webhooks/omg
|
|
Content-Type: application/json
|
|
X-OMG-Signature: sha256=<hex HMAC-SHA256 of the raw body>
|
|
```
|
|
|
|
HMAC is computed with the server's `OMG_WEBHOOK_SECRET` (a shared-secret
|
|
ops hands to the OMG team). Timing-safe compare.
|
|
|
|
**Dev bypass**: set `OMG_WEBHOOK_ALLOW_INSECURE=true` in `.env` to skip
|
|
signature verification. DO NOT use in production.
|
|
|
|
Route is exempt from session auth; middleware passes all `/api/webhooks/*`
|
|
through.
|
|
|
|
#### Payload shape (speculative — confirm with Shashank)
|
|
|
|
```jsonc
|
|
{
|
|
"event": "job.created", // or job.updated | job.assigned | job.status_changed | job.completed
|
|
"timestamp": "2026-04-21T12:34:56Z",
|
|
"job": {
|
|
"number": "2337959", // → omgJobNumber. REQUIRED.
|
|
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
|
|
"client": "Dow Jones",
|
|
"team": "Brand", // → clientTeamId, resolved by name
|
|
"category": "PDF", // → businessUnit
|
|
"status": "In production", // → ProjectStatus, mapped
|
|
"priority": "High", // → Priority, mapped
|
|
"assignees": [
|
|
{ "email": "foo@dowjones.com", "role": "Creative" }
|
|
],
|
|
"dates": {
|
|
"accepted": "2026-03-10T00:00:00Z",
|
|
"externalDeadline": "2026-04-03T00:00:00Z"
|
|
},
|
|
"outputs": 42
|
|
},
|
|
"raw": {
|
|
// any OMG-native fields we haven't mapped — land on Project.customFields
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Responses
|
|
|
|
- `200 { "ok": true, "projectId": "cmo7..." }` — upsert succeeded
|
|
- `401 { "error": "Invalid signature" }` — HMAC mismatch
|
|
- `400 { "error": "Invalid payload" }` — body didn't parse
|
|
- `500 { "error": "Internal server error" }` — something went wrong server-side
|
|
|
|
#### Replay safety
|
|
|
|
Idempotent: same `(omgJobNumber, timestamp)` replayed is effectively a noop
|
|
(the upsert just writes the same fields back). OMG can replay a queue
|
|
without worrying about duplicates.
|
|
|
|
#### Example — signing a payload in bash
|
|
|
|
```bash
|
|
SECRET="$(grep OMG_WEBHOOK_SECRET .env | cut -d= -f2- | tr -d '"')"
|
|
BODY='{"event":"job.updated","timestamp":"2026-04-21T12:00:00Z","job":{"number":"2337959","name":"Test","team":"Brand"}}'
|
|
SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
|
curl -X POST \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-OMG-Signature: sha256=$SIG" \
|
|
-d "$BODY" \
|
|
https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg
|
|
```
|
|
|
|
---
|
|
|
|
### Client Teams
|
|
|
|
```
|
|
GET /api/client-teams # list
|
|
POST /api/client-teams # admin only
|
|
PATCH /api/client-teams/:teamId # admin only
|
|
DELETE /api/client-teams/:teamId # admin only (blocks if team has projects)
|
|
|
|
POST /api/client-teams/:teamId/members # body: { userId, isPrimary? }
|
|
DELETE /api/client-teams/:teamId/members?userId=... # remove
|
|
```
|
|
|
|
Team slugs drive the XLSX/webhook resolution — keep them stable.
|
|
|
|
---
|
|
|
|
### Pods
|
|
|
|
```
|
|
GET /api/pods
|
|
POST /api/pods # { name, slug?, leadUserId? }
|
|
PATCH /api/pods/:podId
|
|
DELETE /api/pods/:podId
|
|
POST /api/pods/:podId/members # body: { userId } — sets homePodId
|
|
DELETE /api/pods/:podId/members?userId=...
|
|
```
|
|
|
|
Pods are orthogonal to client teams — they drive internal capacity
|
|
planning, not project visibility.
|
|
|
|
---
|
|
|
|
### Invitations (add users)
|
|
|
|
```
|
|
GET /api/org/invitations
|
|
POST /api/org/invitations # body: { email, role, isExternal? }
|
|
DELETE /api/org/invitations/:id
|
|
```
|
|
|
|
`POST` creates/upserts the placeholder User, issues a password-reset token,
|
|
and returns `acceptUrl` — the `/reset-password/<token>` link you hand to the
|
|
invitee.
|
|
|
|
---
|
|
|
|
### Resource bookings (capacity planner)
|
|
|
|
```
|
|
GET /api/resources/bookings?weekStart=YYYY-MM-DD
|
|
POST /api/resources/bookings # ADMIN or PRODUCER only
|
|
DELETE /api/resources/bookings/:id
|
|
|
|
GET /api/resources/job-numbers # autocomplete source for the UI
|
|
```
|
|
|
|
Body for POST:
|
|
|
|
```json
|
|
{
|
|
"userId": "cmo7...",
|
|
"date": "2026-04-21",
|
|
"jobNumber": "2337959",
|
|
"hours": 4,
|
|
"note": "Client review prep"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Dashboards / reads
|
|
|
|
```
|
|
GET /api/dashboard/stats
|
|
GET /api/my-work
|
|
GET /api/workload?numWeeks=8
|
|
GET /api/calendar?startDate=...&endDate=...
|
|
GET /api/reports/weekly?date=2026-04-21
|
|
POST /api/search/semantic # { query }
|
|
```
|
|
|
|
All visibility-scoped — non-admins see only their client-team projects.
|
|
|
|
---
|
|
|
|
## Common flows
|
|
|
|
### Bootstrap a tenant from zero
|
|
|
|
```bash
|
|
# 1. Health
|
|
curl https://optical-dev.oliver.solutions/dow-prod-tracker/api/health
|
|
|
|
# 2. Invite the first producer (needs X-API-Key — you're the admin who ran seed)
|
|
curl -X POST \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-API-Key: $API_KEY" \
|
|
-d '{"email":"producer@oliver.agency","role":"PRODUCER"}' \
|
|
https://optical-dev.oliver.solutions/dow-prod-tracker/api/org/invitations
|
|
|
|
# response: {"id":"...","email":"...","acceptUrl":".../reset-password/<token>"}
|
|
# hand that URL over for the producer to set a password.
|
|
|
|
# 3. Upload the current XLSX tracker
|
|
curl -X POST \
|
|
-H "X-API-Key: $API_KEY" \
|
|
-F "file=@Dow Jones_Studio Tracker_2026_04_20.xlsx" \
|
|
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=true"
|
|
```
|
|
|
|
### OMG publishes a status change
|
|
|
|
OMG side (pseudocode):
|
|
|
|
```python
|
|
import hmac, hashlib, json, requests
|
|
|
|
secret = os.environ["DOW_TRACKER_SECRET"]
|
|
body = json.dumps({
|
|
"event": "job.status_changed",
|
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
"job": {
|
|
"number": "2337959",
|
|
"name": "…",
|
|
"team": "Brand",
|
|
"status": "Client Review",
|
|
}
|
|
}).encode("utf-8")
|
|
|
|
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
requests.post(
|
|
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg",
|
|
data=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-OMG-Signature": f"sha256={sig}",
|
|
},
|
|
)
|
|
```
|
|
|
|
Returns `200 { "ok": true, "projectId": "..." }` — project updated in place
|
|
if the `number` already exists, created otherwise.
|
|
|
|
### Update a single job from an external script
|
|
|
|
```bash
|
|
JOB="2337959"
|
|
PROJECT_ID=$(curl -s -H "X-API-Key: $API_KEY" \
|
|
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects" \
|
|
| jq -r ".[] | select(.omgJobNumber == \"$JOB\") | .id")
|
|
|
|
curl -X PATCH \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-API-Key: $API_KEY" \
|
|
-d '{"status":"COMPLETED","description":"Delivered 2026-04-21"}' \
|
|
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/$PROJECT_ID"
|
|
```
|
|
|
|
---
|
|
|
|
## Error conventions
|
|
|
|
All endpoints return JSON on error:
|
|
|
|
- `400 { "error": "<human message>" }` — validation / bad input
|
|
- `401 { "error": "Unauthorized" }` — no valid session / API key
|
|
- `403 { "error": "Forbidden" }` — authenticated but lacks the required permission
|
|
- `404 { "error": "Not found" }` — resource doesn't exist or isn't visible to this caller
|
|
(visibility-scoped endpoints return 404 instead of 403 so ACLs aren't leaked)
|
|
- `500 { "error": "Internal server error" }` — server-side bug; check logs
|
|
|
|
Permission errors surface the missing permission:
|
|
|
|
```json
|
|
{ "error": "Missing permission: PROJECT_CREATE" }
|
|
```
|