From 7b78c92d0536db31b9c70af91df541e00360f32f Mon Sep 17 00:00:00 2001 From: Rasmus Schlunsen Date: Wed, 1 Jul 2026 09:36:15 +0200 Subject: [PATCH 1/2] Make Gitea + deploy flow HTTPS-only via single N0 PAT (sandbox-safe) Adds an Authentication section documenting the one-token flow: use a N0 Personal Access Token (Bearer) for all n0 API calls, and mint the Gitea token via POST /workspaces//gitea/token/ instead of web UI, admin SSH, or email/password login. Enforces HTTPS git remotes and forbids SSH so the skill runs inside the Claude Desktop / Claude Code sandbox. Co-Authored-By: Claude Opus 4.6 --- SKILL.md | 162 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 105 insertions(+), 57 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1fef5a9..21dc675 100644 --- a/SKILL.md +++ b/SKILL.md @@ -68,6 +68,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: @@ -1587,7 +1634,7 @@ After generating the manifest and pushing code to Gitea, the app must be **impor ### Prerequisites -1. **Workspace member JWT token or Personal Access 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 (not needed for Path C zero-build apps) @@ -1597,19 +1644,14 @@ After generating the manifest and pushing code to Gitea, the app must be **impor ### 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"}' ``` @@ -1621,8 +1663,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"}' ``` @@ -1645,13 +1687,13 @@ For zero-build (config_files) apps, **re-import the definition first** so the pl ```bash # 1. Re-import (refreshes AppDefinition.manifest + source commit) -curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/definitions/" \ - -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ +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"}' # 2. Redeploy the running instance -curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/${APP_ID}/redeploy" \ - -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' +curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/redeploy" \ + -H "Authorization: Bearer $N0_API_TOKEN" -H "Content-Type: application/json" -d '{}' ``` ### Deploy Versioning & Rollback @@ -1669,16 +1711,16 @@ Practical rule for zero-build apps: since the "version" lives in the manifest (C ```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) @@ -1707,6 +1749,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 @@ -1732,9 +1779,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" \ @@ -1771,8 +1825,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 ``` @@ -1788,19 +1843,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"}' ``` @@ -1809,48 +1863,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}` @@ -1858,8 +1906,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()}))")" ``` From 4d9f83be72754948b3147a1ff828f671f276398b Mon Sep 17 00:00:00 2001 From: Rasmus Schlunsen Date: Thu, 2 Jul 2026 13:53:44 +0200 Subject: [PATCH 2/2] Field notes from downhill-havoc deploy: stale :latest on redeploy, full-URL imports, runner caveats - Warn that redeploys don't repull an unchanged :latest tag (k3s containerd tag cache); document the commit-SHA pin + re-import + redeploy flow - Prefer full Gitea URL for definition imports: short org/repo form can fail with a misleading 'No n0-app.json found' (seen on prod app.nzero.pro) - Mark the 'Import into k3s' CI step as conditional: containerized runners have no k3s binary and fail with exit 127 - Add troubleshooting rows: stale-version-after-redeploy, short-form import error, and Actions runs misreported as failed after the job succeeded - Document minting a Gitea token via POST /workspaces/{ws}/gitea/token/ Co-Authored-By: Claude Fable 5 --- SKILL.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index 21dc675..96459f0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -627,6 +627,10 @@ jobs: docker push ${{ env.IMAGE }}:latest docker push ${{ env.IMAGE }}:${{ github.sha }} + # Only include this step if the runner has the k3s binary (host-mode runners). + # Containerized runners fail here with exit 127 "k3s: command not found" — + # in that case drop this step and pin the manifest image to the commit-SHA + # tag instead (see "Redeploy" below). - name: Import into k3s run: | docker save ${{ env.IMAGE }}:latest | k3s ctr images import --all-platforms - @@ -643,7 +647,7 @@ jobs: - Both workflows use `docker login ${{ vars.REGISTRY }}` with org-level secrets `REGISTRY_USER` and `REGISTRY_PASSWORD` to authenticate with this workspace's Gitea container registry. The `REGISTRY` **variable** plus these two **secrets** are auto-provisioned at the org level for every workspace, so any new repo inherits them automatically — never hardcode the registry hostname - Workflow B uses `actions/checkout@v4` for cloning — do NOT use manual `git clone` with hardcoded runner-internal URLs - Two tags are pushed: `latest` (for the manifest) and either the commit SHA (for rollback) or the upstream tag (for version tracking) -- **Workflow B must include `docker save | k3s ctr images import`** — this imports the image directly into k3s containerd, ensuring it's available for pod scheduling even before skopeo mirroring runs +- **Workflow B should include `docker save | k3s ctr images import` when the runner supports it** — this imports the image directly into k3s containerd, ensuring it's available for pod scheduling even before skopeo mirroring runs. **Not all workspaces have host-mode runners**: on containerized runners the step fails with exit 127 (`k3s: command not found`). Check the runner first, or just rely on commit-SHA image pinning in the manifest (see the Redeploy section) which works everywhere - **Always detect the repo's default branch** — do not hardcode `main`. Common alternatives: `master`, `canary`, `develop` - The available runner labels are: `self-hosted` (host mode, has Docker), `ubuntu-latest` (containerized via `node:20-bookworm`), `ubuntu-22.04` (containerized). Use `self-hosted` for any workflow that needs Docker @@ -1552,7 +1556,7 @@ Before finalizing, verify: - [ ] **Upstream images use the full registry path** (e.g., `ghcr.io/org/app:stable`) — NOT just the image name - [ ] `.gitignore` excludes `node_modules/`, `.env`, `dist/`, etc. - [ ] `.gitea/workflows/build-and-push.yml` exists (build from source OR mirror upstream) -- [ ] **Workflow includes `docker save | k3s ctr images import` step** (ensures image is available in k3s containerd) +- [ ] **Workflow includes `docker save | k3s ctr images import` step if the runner has k3s** (containerized runners don't — omit the step and pin the manifest image to the commit-SHA tag instead) - [ ] **Workflow uses a hardcoded lowercase `IMAGE` env var** — NEVER use `${{ github.repository }}` in Docker tags (it preserves uppercase and Docker rejects it) - [ ] **Workflow branch trigger matches the repo's actual default branch** (not hardcoded `main`) - [ ] Database services have `volumes` for data persistence @@ -1660,6 +1664,13 @@ This fetches `n0-app.json` from the repo, validates it, and creates an `AppDefin - Short form: `org/repo` (uses workspace's own Gitea) - Full URL: `https://gitea-clovrlabs.apps.privateprompt.tech/org/repo` +**Prefer the full URL.** On some deployments (observed on prod `app.nzero.pro`) the +short form fails with a misleading `No n0-app.json found in org/repo` error even when +the manifest is present on the default branch; the full-URL form works immediately. + +Re-run this same call after changing `n0-app.json` (e.g. bumping a pinned image tag) — +it updates the existing AppDefinition in place (`"created": false` in the response). + ### Step 2: Deploy App Instance ```bash @@ -1696,6 +1707,23 @@ curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/redeploy" \ -H "Authorization: Bearer $N0_API_TOKEN" -H "Content-Type: application/json" -d '{}' ``` +**WARNING — redeploy does NOT pick up a new `:latest` build by itself.** k3s containerd +caches the image by tag: if the pod's image reference is unchanged (`...:latest`), the +node reuses the cached image and the app keeps serving the old build. A `{"tag": "..."}` +body on the redeploy call is accepted but ignored. Reliable update flow when the CI +workflow cannot import into k3s directly: + +1. Push code → CI builds and pushes both `:latest` and `:{commit-sha}` tags +2. Pin the image in `n0-app.json` to the full commit-SHA tag + (`localhost:5000/org/repo:`) and push +3. Re-import the definition (`POST .../apps/definitions/` with the full repo URL) +4. Redeploy — the changed image reference forces a fresh pull + +Verify what's actually deployed by temporarily setting the app public +(`PUT .../apps/{APP_ID}/access` with `{"access_level": "public"}`), curling a content +marker from the site, then reverting to `workspace`. Note: access-level changes only +take effect at the Caddy layer after a redeploy. + ### Deploy Versioning & Rollback **Every deploy gets a unique version tag** — never rely on `latest` as a version identifier: @@ -1746,6 +1774,9 @@ Both the loopback address (`127.0.0.1:{port}`) and external hostname (`gitea-{sl | `Unknown app type` | The `app_type` doesn't match any built-in or AppDefinition slug | Check the slug in n0-app.json | | Image pull failure | Image not in registry or wrong tag | Check Gitea Actions build succeeded; verify `REGISTRY_USER`/`REGISTRY_PASSWORD` org secrets | | Build push failure | Stale or missing registry credentials | Re-provision org-level Actions secrets (see below) | +| `No n0-app.json found` on import despite manifest in repo root | Short-form `org/repo` repo_url not resolved on this deployment | Use the full Gitea URL as `repo_url` | +| App serves old version after redeploy | k3s containerd cached the unchanged `:latest` tag | Pin the manifest image to the commit-SHA tag, re-import definition, redeploy | +| Actions run stuck "in_progress" then marked "failure", but image was pushed | Runner's final status report to Gitea timed out — bookkeeping only | Trust the job log (`/actions/runs/{run}/jobs` → `/actions/jobs/{id}/logs`): if it ends with "Job succeeded" and shows push digests, the build is fine | ### Troubleshooting Registry Credentials