diff --git a/SKILL.md b/SKILL.md index 63d8b3b..a6a470d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -62,6 +62,53 @@ Public images from Docker Hub (e.g., `postgres:16-alpine`, `redis:7-alpine`, `ng Upstream pre-built images from other registries (e.g., `ghcr.io/org/app:stable`) are also fine — see "Pre-built Upstream Images" below. +## Authentication (single token, HTTPS-only) + +**You only need ONE credential: a N0 Personal Access Token (PAT).** Everything — +n0 API calls, workspace lookup, Gitea repo creation, git push, Supabase — is done +over **HTTPS** with this single token. This is the path that works inside the +Claude Desktop / Claude Code sandbox (outbound HTTPS only, no SSH, no interactive +web login, no server SSH). + +The user provides the token via two environment variables (generated by the +"Build an app with Claude" button in N0 → Developer Settings → API Tokens): + +```bash +export N0_API_BASE="https://app.nzero.pro/api/v1" # or the workspace's platform host +export N0_API_TOKEN="clovr_pat_..." # scopes: apps, gitea:write, workspaces:read, supabase +``` + +Use it as a Bearer token on every n0 API call: + +```bash +curl -s -H "Authorization: Bearer $N0_API_TOKEN" "$N0_API_BASE/workspaces/" +``` + +**Getting the Gitea token (HTTPS, no web UI, no SSH):** +Mint a short-lived Gitea access token from the n0 API using the PAT. This is the +ONLY approved way to get Gitea credentials in the sandbox: + +```bash +# Requires the PAT to include the gitea:write scope +GITEA=$(curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/gitea/token/" \ + -H "Authorization: Bearer $N0_API_TOKEN") +GITEA_TOKEN=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])") +GITEA_USER=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['username'])") +GITEA_URL=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['gitea_url'])") +GITEA_HOST=$(echo "$GITEA_URL" | sed -e 's#^https\?://##' -e 's#/$##') +``` + +The response is `{"success": true, "data": {"token": "...", "username": "...", "gitea_url": "https://gitea-.apps."}}`. +The Gitea token is scoped to `write:repository,write:package,write:organization` +and returned once (not stored) — use it for repo creation and git push, then discard. + +**RULES (do not violate — SSH and interactive logins break in the sandbox):** +- ✅ Do all git operations over **HTTPS** with the Gitea token embedded in the remote URL: + `https://:@//.git` +- ❌ **NEVER** use an SSH git remote (`git@...`, `ssh://...`) — no SSH keys exist in the sandbox. +- ❌ **NEVER** use email/password `/auth/login` — use the PAT Bearer token instead. +- ❌ **NEVER** create Gitea tokens via the web UI or via `gitea admin ... generate-access-token` over SSH — use the `gitea/token/` endpoint above. + ## Workflow Always follow this order: @@ -1443,25 +1490,20 @@ After generating the manifest and pushing code to Gitea, the app must be **impor ### Prerequisites -1. **Workspace member JWT token** — any workspace member can import and deploy apps +1. **N0 Personal Access Token (PAT)** — supplied via `$N0_API_TOKEN` (see "Authentication" above). Any workspace member's PAT can import and deploy apps. 2. **Code + n0-app.json pushed to Gitea** — the repo must exist in the workspace's Gitea instance 3. **Gitea Actions build completed** — the Docker image must be in the registry before deploy ### Step 1: Import App Definition -Register the app in the workspace catalog by pointing at the Gitea repo: +Register the app in the workspace catalog by pointing at the Gitea repo. Use the +PAT as a Bearer token (no email/password login): ```bash -# Login to get JWT token -TOKEN=$(curl -s -X POST https://app.privateprompt.tech/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"you@example.com","password":"..."}' | \ - python3 -c "import json,sys; print(json.load(sys.stdin)['data']['tokens']['access_token'])") - # Import app definition from Gitea repo # NOTE: trailing slash is required on this endpoint -curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/definitions/" \ - -H "Authorization: Bearer $TOKEN" \ +curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/definitions/" \ + -H "Authorization: Bearer $N0_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"repo_url": "clovrlabs/my-app"}' ``` @@ -1473,8 +1515,8 @@ This fetches `n0-app.json` from the repo, validates it, and creates an `AppDefin ### Step 2: Deploy App Instance ```bash -curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/" \ - -H "Authorization: Bearer $TOKEN" \ +curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/" \ + -H "Authorization: Bearer $N0_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"app_type": "my-app-slug", "subdomain": "my-app"}' ``` @@ -1494,24 +1536,24 @@ The deploy is async — a background Huey task: ### Step 3: Redeploy (after code changes) ```bash -curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/${APP_ID}/redeploy" \ - -H "Authorization: Bearer $TOKEN" +curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/redeploy" \ + -H "Authorization: Bearer $N0_API_TOKEN" ``` ### Other Operations ```bash # Stop app -curl -X POST ".../apps/${APP_ID}/stop" -H "Authorization: Bearer $TOKEN" +curl -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/stop" -H "Authorization: Bearer $N0_API_TOKEN" # Start app -curl -X POST ".../apps/${APP_ID}/start" -H "Authorization: Bearer $TOKEN" +curl -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/start" -H "Authorization: Bearer $N0_API_TOKEN" # Delete/uninstall app -curl -X DELETE ".../apps/${APP_ID}" -H "Authorization: Bearer $TOKEN" +curl -X DELETE "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}" -H "Authorization: Bearer $N0_API_TOKEN" # Pre-deploy validation -curl ".../apps/${APP_ID}/validate" -H "Authorization: Bearer $TOKEN" +curl "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/validate" -H "Authorization: Bearer $N0_API_TOKEN" ``` ### Image Mirroring (How K8s Pulls Images) @@ -1540,6 +1582,11 @@ Both the loopback address (`127.0.0.1:{port}`) and external hostname (`gitea-{sl ### Troubleshooting Registry Credentials +> **Admin-only fallback — not part of the normal flow.** This requires server SSH +> and is NOT available in the Claude Desktop / Claude Code sandbox. Skip it unless +> you are a platform operator debugging stale org secrets. Normal app deploys never +> need this; use the `gitea/token/` endpoint (see "Authentication") for credentials. + If Gitea Actions builds succeed but `docker push` fails, the org-level Actions secrets may be stale: ```bash @@ -1565,9 +1612,16 @@ Here's the full workflow for developing an app locally and deploying it to N0: ### 1. Create a Gitea Repo +First mint a Gitea token from the n0 API using your PAT (see "Authentication" +above — HTTPS-only, sandbox-safe). `GITEA_TOKEN`, `GITEA_USER`, and `GITEA_HOST` +come from `POST $N0_API_BASE/workspaces/$WS_ID/gitea/token/`: + ```bash -GITEA_TOKEN="" -GITEA_HOST="gitea-clovrlabs.apps.privateprompt.tech" +GITEA=$(curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/gitea/token/" \ + -H "Authorization: Bearer $N0_API_TOKEN") +GITEA_TOKEN=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])") +GITEA_USER=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['username'])") +GITEA_HOST=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['gitea_url'])" | sed -e 's#^https\?://##' -e 's#/$##') curl -s -X POST "https://${GITEA_HOST}/api/v1/orgs/clovrlabs/repos" \ -H "Authorization: token $GITEA_TOKEN" \ @@ -1604,8 +1658,9 @@ Use this value in `n0-app.json` image fields. In the workflow YAML, always use ` ### 4. Push to Gitea ```bash +# HTTPS remote with the Gitea token embedded — NEVER use an SSH remote (git@...) git init && git add -A && git commit -m "Initial commit" -git remote add origin "https://:${GITEA_TOKEN}@${GITEA_HOST}/clovrlabs/my-app.git" +git remote add origin "https://${GITEA_USER}:${GITEA_TOKEN}@${GITEA_HOST}/clovrlabs/my-app.git" git push -u origin main --force ``` @@ -1621,19 +1676,18 @@ curl -s -H "Authorization: token $GITEA_TOKEN" \ ### 6. Import + Deploy via API ```bash -N0_TOKEN="" -WS_ID="" -API="https://app.privateprompt.tech/api/v1" +# N0_API_BASE and N0_API_TOKEN come from the environment (see "Authentication") +# WS_ID is the workspace UUID (look it up via GET $N0_API_BASE/workspaces/) # Import app definition (trailing slash required) -curl -s -X POST "$API/workspaces/$WS_ID/apps/definitions/" \ - -H "Authorization: Bearer $N0_TOKEN" \ +curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/apps/definitions/" \ + -H "Authorization: Bearer $N0_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"repo_url": "clovrlabs/my-app"}' # Deploy (trailing slash required) -curl -s -X POST "$API/workspaces/$WS_ID/apps/" \ - -H "Authorization: Bearer $N0_TOKEN" \ +curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/apps/" \ + -H "Authorization: Bearer $N0_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"app_type": "my-app", "subdomain": "my-app"}' ``` @@ -1642,48 +1696,42 @@ curl -s -X POST "$API/workspaces/$WS_ID/apps/" \ ```bash # Check app status -curl -s "$API/workspaces/$WS_ID/apps/" \ - -H "Authorization: Bearer $N0_TOKEN" | \ +curl -s "$N0_API_BASE/workspaces/$WS_ID/apps/" \ + -H "Authorization: Bearer $N0_API_TOKEN" | \ python3 -c "import json,sys; apps=json.load(sys.stdin)['data']; [print(f'{a[\"subdomain\"]}: {a[\"status\"]}') for a in apps if a['subdomain']=='my-app']" -# Visit the app +# Visit the app (host matches your platform domain, e.g. apps.nzero.pro) open "https://my-app.apps.privateprompt.tech" ``` ### Getting Credentials for Local Development -**N0 API JWT token** (for deploying): -```bash -# NOTE: no trailing slash on /auth/login -curl -s -X POST "$API/auth/login" \ - -H "Content-Type: application/json" \ - -d '{"email":"you@example.com","password":"..."}' | \ - python3 -c "import json,sys; print(json.load(sys.stdin)['data']['tokens']['access_token'])" -``` +All credentials come from the single N0 PAT — no email/password, no web UI, no SSH. +See "Authentication (single token, HTTPS-only)" near the top. + +**N0 API token**: use `$N0_API_TOKEN` (a `clovr_pat_...` PAT) as `Authorization: Bearer $N0_API_TOKEN` on every call. Do NOT use `/auth/login`. **Workspace ID** (needed for deploy API calls): ```bash -curl -s "$API/workspaces/" \ - -H "Authorization: Bearer $N0_TOKEN" | \ +curl -s "$N0_API_BASE/workspaces/" \ + -H "Authorization: Bearer $N0_API_TOKEN" | \ python3 -c "import json,sys; ws=json.load(sys.stdin)['data']; [print(f'{w[\"slug\"]}: {w[\"id\"]}') for w in ws]" ``` -**Gitea personal access token** (for git push): -- Visit `https://gitea-{workspace-slug}.apps.{platform-domain}/-/user/settings/applications` -- Click "Generate New Token" → select `write:repository, write:package` scopes → copy token -- Or via Gitea API (if you already have a token with `write:user` scope): - ```bash - curl -s -X POST "https://${GITEA_HOST}/api/v1/users/{username}/tokens" \ - -H "Authorization: token $EXISTING_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name": "deploy-token", "scopes": ["write:repository", "write:package"]}' - ``` +**Gitea token** (for repo creation + git push): mint it from the n0 API with the +PAT (requires `gitea:write` scope) — HTTPS-only, sandbox-safe: +```bash +curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/gitea/token/" \ + -H "Authorization: Bearer $N0_API_TOKEN" | \ + python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(f'token={d[\"token\"]}\nuser={d[\"username\"]}\nurl={d[\"gitea_url\"]}')" +``` +Do NOT create Gitea tokens via the web UI or via `gitea admin ... generate-access-token` (SSH). Use an HTTPS git remote, never SSH. **Supabase credentials** (for apps using the workspace Supabase): - **Via API** (recommended): ```bash - curl -s "$API/workspaces/$WS_ID/supabase/credentials/" \ - -H "Authorization: Bearer $N0_TOKEN" | \ + curl -s "$N0_API_BASE/workspaces/$WS_ID/supabase/credentials/" \ + -H "Authorization: Bearer $N0_API_TOKEN" | \ python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(f'URL: {d[\"url\"]}\nAnon key: {d[\"anon_key\"]}')" ``` - **URL pattern**: `https://supabase-api-{workspace-slug}.apps.{platform-domain}` @@ -1691,8 +1739,8 @@ curl -s "$API/workspaces/" \ **Running Supabase migrations** (for creating tables, RLS policies, etc.): ```bash -curl -s -X POST "$API/workspaces/$WS_ID/supabase/sql/" \ - -H "Authorization: Bearer $N0_TOKEN" \ +curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/supabase/sql/" \ + -H "Authorization: Bearer $N0_API_TOKEN" \ -H "Content-Type: application/json" \ -d "$(python3 -c "import json; print(json.dumps({'sql': open('migration.sql').read()}))")" ```