diff --git a/AGENTS.md b/AGENTS.md index 314db705..b645d079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,167 @@ # AGENTS.md -## Repository layout +## Which checkout to work in -This checkout is the `Coder-soft/renderdragon.org` fork. The canonical upstream -for this project is **`creatorcluster/renderdragon.org`** (base branch `main`), -which itself forks `Yxmura/renderdragon`. +- **Canonical:** the worktree **`/Users/techspot/renderdragon-redesign`** (branch `redesign/app`) + of the main repo `/Users/techspot/renderdragon.org`. Do your work here. +- `/Users/techspot/reddragon/renderdragon.org` is a **separate, stale clone** (behind by many + commits). Do not work in it; it caused a lot of "wrong code / wrong port" confusion. +- `/Users/techspot/renderdragon.org` (main checkout) is a worktree on `feat/admin-panel-redesign`. +- `origin` = `Coder-soft/renderdragon.org` (fork), `upstream` = `creatorcluster/renderdragon.org`. -## Pull requests +## Repository layout / pull requests -Open every pull request against upstream **`creatorcluster/renderdragon.org` `main`**, -not against the `Coder-soft` fork. Push the branch to `origin` (the fork) and open a -cross-repository PR: +This checkout is the `Coder-soft/renderdragon.org` fork. The canonical upstream for this project is +**`creatorcluster/renderdragon.org`** (base branch `main`), which itself forks `Yxmura/renderdragon`. + +Open every pull request against upstream **`creatorcluster/renderdragon.org` `main`**, not against the +`Coder-soft` fork. Push the branch to `origin` (the fork) and open a cross-repository PR: ```sh git push -u origin gh pr create --repo creatorcluster/renderdragon.org --base main --head Coder-soft: ``` -GitHub's native linked-branch feature (`gh issue develop`) does not work in the fork, -so reference the relevant fork issue by URL in the PR body instead. +GitHub's native linked-branch feature (`gh issue develop`) does not work in the fork, so reference the +relevant fork issue by URL in the PR body instead. There is an existing open redesign+partners PR +(`Coder-soft:redesign/app` → `creatorcluster:main`); pushing to that branch updates it. + +## Dev environment + +- `pnpm dev` runs **Vite on :8080** plus an **Express server on :3000** (`server.js`, which adapts the + `api/*.js` Vercel handlers and `/api/uploadthing`). Run **one** dev server only. +- **Dev assets bypass the gateway**: `vite.config.ts` proxies `/api/assets-gw/*` straight to the public + upstreams. Only production goes through `gateway.codersoft.xyz`. +- Commands: `pnpm lint`, `pnpm exec tsc -b`, `pnpm exec vite build`. + - `tsc` has **pre-existing errors** in `ProfileEditor`/`useCreatorPacks`/`useProfile`/`migrateResources`/ + `AdminResourcesManager`/`chart`/`Analytics` — ignore them; do not "fix" unrelated ones. + - If deps look stale/wrong (missing module, version mismatch): `pnpm install --frozen-lockfile --config.confirmModulesPurge=false`. +- `/account` = Connected apps; `/partners/dashboard` = partner dashboard; `/oauth/consent` = consent screen. + +## Infrastructure map + +- **Supabase** project `bmywdrwjdqmrkafhiuwn`. Schema/RLS in `supabase/migrations/*.sql` (all applied). + Edge Function `partner-keys` (key provisioning). Auth config via Management API. +- **Cloudflare account** "Powernplant101@gmail.com's Account" (id `c6bc31479acbdc8f997bcfa3cacb42b4`). + Zone **`codersoft.xyz` is in this account; `renderdragon.org` is in a DIFFERENT Cloudflare account** — + that's why the browser gateway lives on `gateway.codersoft.xyz` instead of a `renderdragon.org` route. +- **Workers** (sources in `workers/`): + - `codersoft-api-router` → **`api.codersoft.xyz`** (Durable Object `AuthStore`). Verified request auth, + scopes/rate limits, ordered origin pools with per-origin circuit breaking (`RESOURCES_ORIGINS` / + `ASSETS_ORIGINS`), `__version`, `__admin/*`. + - `codersoft-assets-gateway` → **`gateway.codersoft.xyz`** (holds `GATEWAY_SECRET`, minted browser + sessions, CORS + `SameSite=None` cookies, proxies to the router). +- **e3** (ssh alias `e3`, `home@192.168.100.30`, Debian): systemd service **`hamburger-api`** on + `127.0.0.1:8788`, source `/home/home/assets-api/server.mjs`, env `/etc/hamburger-api.env`. It builds + the asset catalog from a local clone `/home/home/assets-renderdragon` (of + `Renderdragonorg/resources_renderdragon`) and serves the **files from disk** (not GitHub). Exposed via + the Cloudflare Tunnel as **`hamburger.codersoft.xyz`**. +- **Data flow:** browser → `gateway.codersoft.xyz` (session, signs) → `api.codersoft.xyz` (verifies) → + e3 `hamburger.codersoft.xyz` (resources catalog + files) / `assets-api-worker` (uploads) → on e3 down, + fall back to `hamburger-api`/`assets-api-worker`. + +## Auth model (why "network manipulation" can't bypass) + +- The router requires **HMAC-SHA256 signed requests** (`X-Auth-Client/Timestamp/Nonce/Signature`) for + every endpoint except `OPTIONS`, `/__router/health`, `/__version`, `/__admin/*`. Canonical string: + `v1\nMETHOD\nPATH?QUERY\nCLIENT_ID\nTIMESTAMP\nNONCE\nSHA256_HEX(BODY)`. ±300s window; nonce replay + cache (Durable Object) for writes. +- **Partner keys** carry scopes (e.g. `assets:read`) and rate limits; read-only + path-limited. +- **e3** verifies the router's `X-E3-*` signature with `E3_SHARED_SECRET` (same canonical string, client + id `api-router`). +- **Supabase OAuth 2.1 server** is enabled (`site_url=https://renderdragon.org`, + `oauth_server_authorization_path=/oauth/consent`, dynamic registration off). Consent notes below. + +## Secrets (NAMES ONLY — never print, echo, or commit values) + +- Router Worker: `ADMIN_KEY`, `E3_SHARED_SECRET`. +- Gateway Worker: `GATEWAY_SECRET`, `TURNSTILE_SECRET`; var `SUPABASE_ANON_KEY` (publishable, not secret). +- Edge Function `partner-keys`: `ROUTER_ADMIN_KEY` (= the router's `ADMIN_KEY`). +- e3: `/etc/hamburger-api.env` → `E3_SHARED_SECRET`, `WEBHOOK_SECRET`. +- GitHub webhook on `Renderdragonorg/resources_renderdragon` → `WEBHOOK_SECRET`. +- Frontend `.env`: `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY`; production also needs + `VITE_TURNSTILE_SITE_KEY`. +- Tooling creds: `~/.config/sbase/.env` (`SBASE_ACCESS_TOKEN`), `~/.wrangler/config/default.toml` + (Cloudflare OAuth token). e3 root password is documented in the global `e3-server` skill. + +## Partner program model + +- Invite-only. **1 organization = 1 OAuth app** (`partner_apps.org_id` unique). +- Permissions live in **`src/lib/partners/permissions.ts`** (currently `profile`, `email`, + `favourites:read`) — the single source of truth driving the consent screen, the dashboard, and API + scopes. Add/remove there and it propagates everywhere. +- `partner_consents` stores the approved profile snapshot; the user-facing **Connected apps** section on + `/account` reads the `get_my_connected_apps()` RPC and revokes via `partner_consents.revoked_at`. +- The consent page only records a consent when the OAuth client maps to a + `partner_apps.supabase_client_id` (via the `partner_app_public` RPC). A raw OAuth client that isn't a + provisioned partner app won't appear in Connected apps. +- Register partner/clients: `POST /auth/v1/admin/oauth/clients` (service role). Delete demo clients when + done. + +## Operational pitfalls (learned the hard way) + +- **Cloudflare bot protection** returns `1010` for clients with no/blocked `User-Agent`. Real clients send + one; python-urllib is blocked. +- **wrangler OAuth token expires**; running any `wrangler` command refreshes it. The token has **no DNS + scope** — create DNS records with `cloudflared tunnel route dns ` on e3 (uses + `~/.cloudflared/cert.pem`), not the CF API. +- **sbase / ssh sudo:** never feed the root password and a `bash -s` heredoc down the same stdin (sudo eats + it). Stage the script to a file and run `echo root | sudo -S -p "" bash /tmp/x.sh`. +- **React StrictMode** double-invokes effects in dev — a one-time request (OAuth authorization) gets + consumed twice and 400s. Guard with a ref (`startedRef` in `OAuthConsent.tsx`). +- **Supabase OAuth consent endpoints** (`/oauth/authorizations/*`) require the **legacy anon JWT** as the + `apikey` header (the `sb_publishable_…` key is rejected there). Confidential clients authenticate at + `/oauth/token` with **`client_secret_basic`**. Consent POST body is `{ "action": "approve"|"deny" }`. +- **e3 powers off nightly 03:00–05:00 PKT** (RTC wake 05:00), so the router falls back to the Cloudflare + workers. While e3's catalog is empty (e.g. mid-clone) it returns `503` so the router also falls back. +- **Avatars:** third-party hosts get hidden by ad/tracker blockers — served same-origin via `/api/avatar` + (`api/avatar.js` + the `server.js` route), and the client rewrites URLs with `toDisplayAvatarUrl`. +- **Assets in dev bypass the gateway**; production uses `gateway.codersoft.xyz`. +- **This Mac's tooling:** `jq` is NOT installed (parse with `python3`); `python3` lacks a CA bundle (use an + unverified `ssl` context, or just `curl`); in zsh never `echo "$JSON"` (interprets escapes) — use + `printf '%s'`. +- **Design language:** the redesign `index.css` zeroes every radius + (`[class*="rounded-"]:not(.rounded-full){border-radius:0!important}`) and uses `chamfer`; avoid `rounded-*` + corners. Colors/type come from the token system (dark, primary purple, Geist/Montserrat). +- **Multi-checkout port confusion** is real — keep a single dev server and kill strays on :8080–:8084. + +## Deploy & verify + +```sh +# Workers +cd workers/ && pnpm install --ignore-workspace && pnpm exec wrangler deploy + +# SQL migration +sbase apply-migration --project-id bmywdrwjdqmrkafhiuwn --name --file + +# Edge Function +sbase deploy-edge-function --project-id bmywdrwjdqmrkafhiuwn --name partner-keys \ + --file index.ts=supabase/functions/partner-keys/index.ts --entrypoint-path index.ts + +# e3 assets service (after editing /home/home/assets-api/server.mjs or credits.json) +ssh e3 'echo root | sudo -S -p "" systemctl restart hamburger-api' + +# Health +curl https://api.codersoft.xyz/__router/health +curl https://gateway.codersoft.xyz/__version +curl https://hamburger.codersoft.xyz/__health +``` + +- **Auto-update:** pushing to `Renderdragonorg/resources_renderdragon` fires its webhook → + `https://hamburger.codersoft.xyz/__update` (HMAC-verified) → `git pull` + rebuild the catalog. +- **Signed-request check:** mint a key via the `partner-keys` Edge Function, then sign + `v1\nGET\n/all\n\n\n\n` (HMAC-SHA256 hex) into `X-Auth-*` and call + `api.codersoft.xyz/all`; check the `x-api-router-upstream` header (`e3` vs `fallback`). +- **Asset pipeline:** catalog = dir tree of `Renderdragonorg/resources_renderdragon`; item fields + `{id,title,credit,filename,ext,url,size,subcategory}`. Credits come from the site's resource exports + (by repo path/filename) plus the `__` filename convention (`credits.json` on e3). File + `url`s point at `hamburger.codersoft.xyz/`. + +## Still-open / config-only (as of last touch) + +- `VITE_TURNSTILE_SITE_KEY` must be set in the deploy env for anonymous asset sessions (logged-in users + bypass Turnstile via their Supabase token). +- OIDC asymmetric signing keys are not yet enabled in Supabase. `standardScopesFor` requests + `openid` (per `oauth-consent.md`), which needs RS256/ES256 signing keys enabled before ID tokens work. +- Demo OAuth clients should be deleted. +- The site must be deployed from this branch for production to call the gateway. diff --git a/api/avatar.js b/api/avatar.js new file mode 100644 index 00000000..e2b646ff --- /dev/null +++ b/api/avatar.js @@ -0,0 +1,78 @@ +// Same-origin avatar proxy. +// +// Third-party avatar hosts (Google, GitHub, Discord) are routinely blocked by ad/tracker +// blockers and privacy DNS, which leaves users with a blank avatar. Serving them from our +// own origin sidesteps that entirely and keeps the image request first-party. +// +// SSRF guard: only a fixed allowlist of avatar hosts may be fetched, and redirects are +// never followed. + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +const ALLOWED_AVATAR_HOSTS = new Set([ + 'lh3.googleusercontent.com', + 'lh4.googleusercontent.com', + 'lh5.googleusercontent.com', + 'lh6.googleusercontent.com', + 'avatars.githubusercontent.com', + 'cdn.discordapp.com', + 'media.discordapp.net', +]); + +export function isAllowedAvatarUrl(rawUrl) { + let url; + try { + url = new URL(rawUrl); + } catch { + return false; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return false; + return ALLOWED_AVATAR_HOSTS.has(url.hostname.toLowerCase()); +} + +export default async function handler(req) { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + try { + const target = new URL(req.url, `http://${req.headers.host}`).searchParams.get('u'); + if (!target) { + return new Response('Missing avatar url', { status: 400, headers: corsHeaders }); + } + if (!isAllowedAvatarUrl(target)) { + return new Response('URL not allowed', { status: 400, headers: corsHeaders }); + } + + // redirect: 'manual' — never follow redirects (they could point at internal hosts) + const upstream = await fetch(target, { redirect: 'manual' }); + if (upstream.status >= 300 && upstream.status < 400) { + return new Response('Redirect not allowed', { status: 502, headers: corsHeaders }); + } + if (!upstream.ok) { + return new Response('Upstream error', { status: 502, headers: corsHeaders }); + } + + const contentType = upstream.headers.get('content-type') || ''; + if (!contentType.startsWith('image/')) { + return new Response('Not an image', { status: 502, headers: corsHeaders }); + } + + return new Response(upstream.body, { + status: 200, + headers: { + ...corsHeaders, + 'Content-Type': contentType, + // Avatars change rarely; cache hard at the edge and lightly in the browser. + 'Cache-Control': 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400', + }, + }); + } catch (error) { + console.error('Avatar proxy error:', error); + return new Response('Avatar proxy error', { status: 502, headers: corsHeaders }); + } +} diff --git a/api/cors.js b/api/cors.js index 995f3ab8..b9b71ded 100644 --- a/api/cors.js +++ b/api/cors.js @@ -7,6 +7,8 @@ export const allowedOrigins = new Set([ 'http://127.0.0.1:3000', 'https://renderdragon.org', 'https://www.renderdragon.org', + 'https://api.codersoft.xyz', + 'https://assets.codersoft.xyz', 'https://assets-api-worker.powernplant101-c6b.workers.dev', ]); diff --git a/api/stats.js b/api/stats.js index a8538ab0..3e3c5569 100644 --- a/api/stats.js +++ b/api/stats.js @@ -9,6 +9,16 @@ const FALLBACK_ADMIN_EMAILS = [ const GRANULARITIES = new Set(['day', 'week', 'month']); +// The local Express adapter in server.js passes plain objects, while Vercel +// passes a real Request; support both shapes. +function getHeader(request, name) { + const headers = request.headers; + if (!headers) return null; + if (typeof headers.get === 'function') return headers.get(name); + const lower = name.toLowerCase(); + return headers[lower] ?? headers[name] ?? null; +} + function adminEmails() { const configured = (process.env.ANALYTICS_ADMIN_EMAILS || '') .split(',') @@ -18,7 +28,7 @@ function adminEmails() { } function corsHeaders(request) { - const origin = request.headers.get('origin'); + const origin = getHeader(request, 'origin'); const headers = { 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', @@ -58,7 +68,7 @@ export async function handler(request) { return json({ error: 'server not configured' }, 500, headers); } - const authHeader = request.headers.get('authorization') || ''; + const authHeader = getHeader(request, 'authorization') || ''; if (!authHeader.toLowerCase().startsWith('bearer ')) { return json({ error: 'unauthorized' }, 401, headers); } diff --git a/api/track.js b/api/track.js index 81abf9c7..f520abeb 100644 --- a/api/track.js +++ b/api/track.js @@ -9,8 +9,30 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ const BOT_RE = /bot|crawl|spider|slurp|bingpreview|headless|lighthouse|pingdom|uptime|monitor|facebookexternalhit|whatsapp|telegram|discordbot|preview/i; +// The local Express adapter in server.js passes plain objects, while Vercel +// passes a real Request; support both shapes. +function getHeader(request, name) { + const headers = request.headers; + if (!headers) return null; + if (typeof headers.get === 'function') return headers.get(name); + const lower = name.toLowerCase(); + return headers[lower] ?? headers[name] ?? null; +} + +async function readJsonBody(request) { + if (request.body && typeof request.body === 'object') return request.body; + if (typeof request.json === 'function') { + try { + return await request.json(); + } catch { + return {}; + } + } + return {}; +} + function corsHeaders(request) { - const origin = request.headers.get('origin'); + const origin = getHeader(request, 'origin'); const headers = { 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', @@ -38,7 +60,7 @@ function supabaseConfig() { } async function resolveUser(url, anonKey, request) { - const header = request.headers.get('authorization') || request.headers.get('Authorization'); + const header = getHeader(request, 'authorization'); if (!header || !header.toLowerCase().startsWith('bearer ')) return null; const accessToken = header.slice(7).trim(); if (!accessToken) return null; @@ -66,15 +88,10 @@ export async function handler(request) { }); } - const userAgent = request.headers.get('user-agent') || ''; + const userAgent = getHeader(request, 'user-agent') || ''; if (!userAgent || BOT_RE.test(userAgent)) return new Response(null, { status: 204, headers }); - let payload = {}; - try { - payload = await request.json(); - } catch { - payload = {}; - } + const payload = await readJsonBody(request); const browserId = truncate(payload?.browserId, 64); if (!browserId || !UUID_RE.test(browserId)) return new Response(null, { status: 204, headers }); @@ -88,7 +105,7 @@ export async function handler(request) { try { const user = anonKey ? await resolveUser(url, anonKey, request) : null; const visitorKey = user ? `u:${user.id}` : `b:${browserId}`; - const country = request.headers.get('x-vercel-ip-country') || null; + const country = getHeader(request, 'x-vercel-ip-country') || null; const admin = createClient(url, secretKey, { auth: { autoRefreshToken: false, persistSession: false }, diff --git a/docs/partner-program/BUILD.md b/docs/partner-program/BUILD.md new file mode 100644 index 00000000..bb1f86cc --- /dev/null +++ b/docs/partner-program/BUILD.md @@ -0,0 +1,58 @@ +# Partner Program — build notes + +Status: **built**. Pages, data layer, schema, read-only API scoping and key +provisioning are in place. Remaining items are deployment/secrets (below). + +## What shipped + +### Supabase (applied) +- `supabase/migrations/20261015120000_partner_program.sql` — `partner_orgs`, + `partner_members`, `partner_apps` (one per org, `unique (org_id)`), `partner_consents` + (stores the approved profile snapshot), `partner_api_keys`, `partner_requests`, + `partner_usage_events`, all with RLS. +- `supabase/migrations/20261015120100_partner_app_public.sql` — public + `partner_app_public(client_id)` lookup for the consent screen + user consent insert policy. +- Edge Function `partner-keys` (deployed) — issues/revokes read-only keys and registers the + secret in the router's Durable Object. + +### Frontend +- There is **no public `/partners` page**; partners are handled through contacts, not a + self-serve page. `/partners` does not exist (404). The private `/partners/dashboard` + remains and is reachable only via a "Partner dashboard" link in the account menu for + users who belong to a partner org. +- `/partners/dashboard` — Overview / App / Users / Keys / Stats. +- **First-time onboarding wizard** on the dashboard (shown until `partner_apps.onboarded_at` + is set): 2 steps (app setup → permissions & first key). Motion comes from the + `transitions-dev` skill (`jakubantalik/transitions-dev`, installed at + `~/.agents/skills/transitions-dev`): page side-by-side between steps, text-states-swap on the + title, success-check finale. Snippets + tokens live in `src/styles/transitions.css`. +- `/oauth/consent` — the consent screen; lists every field shared, approve/deny, records consent. +- Modules: `src/lib/partners/{permissions,client,oauthConsent,provisioning}.ts` + (`permissions.ts` is the single source of truth for what is requested and shown). + +### Router (`workers/api-router`) +- Client registry now carries `scopes` and `rate_limit_per_min`. +- Partner keys with `assets:read` are enforced read-only and limited to + `/all`, `/categories`, `/category/*`, `/mcicons`, `/fonts`; per-key rate limiting (429) in + the Durable Object. + +## To finish (owner actions) + +1. **Router**: `cd workers/api-router && pnpm wrangler secret put ADMIN_KEY` (long random). +2. **Edge Function secret**: set `ROUTER_ADMIN_KEY` to the same value at + https://supabase.com/dashboard/project/bmywdrwjdqmrkafhiuwn/settings/functions, then re-run + `sbase create-edge-function-secret --name ROUTER_ADMIN_KEY` to confirm `stored: true`. +3. **OAuth server**: enable it (Authentication → OAuth Server), set the authorization path to + `/oauth/consent`, enable asymmetric JWT signing (RS256/ES256), disable dynamic registration. + Register each approved partner's client and set `partner_apps.supabase_client_id`. +4. **Provision a partner**: insert a `partner_orgs` row, a `partner_members` row for the owner, + and a `partner_apps` row (with permissions + redirect URIs + client id). +5. Deploy the three frontend routes with the normal site deploy. + +## Notes / follow-ups +- The consent flow prefers `supabase.auth.oauth.*` and falls back to the documented GoTrue REST + routes; update `@supabase/supabase-js` (installed tree is stale) before relying on it. +- `partner_usage_events` has no writer yet — the router should emit usage events per partner key + to populate the Stats charts (currently shows zeros until then). +- Local `pnpm install` is stale (`@fontsource/montserrat`, `@supabase/auth-js` mismatch); run a + clean install before building the site locally. diff --git a/docs/partner-program/PLAN.md b/docs/partner-program/PLAN.md new file mode 100644 index 00000000..2fdea236 --- /dev/null +++ b/docs/partner-program/PLAN.md @@ -0,0 +1,126 @@ +# Partner Program — implementation plan + +## Goals + +1. Let an approved partner add **"Sign in with RenderDragon"** to their app. +2. Give partners **read-only** access to the asset catalog. +3. Give partners a **dashboard** to manage their app, its users, and usage statistics. +4. Keep the whole thing **invite-only, verified, and transparent** to end users. + +## Non-goals (for now) + +- Public/self-serve developer signup. +- Write access to assets for partners. +- Custom OAuth scopes (blocked upstream — see below). +- Billing/monetization, tiering, or a marketplace. + +## Key constraint that shapes everything + +Supabase OAuth scopes are limited to `openid email profile phone`, and OAuth **access tokens +carry full user privileges** (RLS decides what they can read). Custom scopes are "planned" but +not available. Therefore: + +- The **consent screen is ours** and is authoritative for *what we tell the user is shared*. +- **Granular partner permissions** (e.g. `favourites:read`, `stats:read`) live in **our** registry + and are enforced by **our** API/RLS using the token's `client_id` claim — never assumed from + the OAuth `scope` string. +- We treat the OAuth layer as *identity only*; data authorization is ours. + +## Architecture + +``` +Partner app ──"Sign in with RenderDragon"──► Supabase Auth /oauth/authorize + │ │ (validates client, PKCE) + │ ▼ + │ RenderDragon consent UI /oauth/consent + │ (SPA page: shows app + shared fields) + │ │ approve / deny + │ ▼ + │ Supabase Auth issues code → partner exchanges + │ for access + refresh (+ ID) token + ▼ +Partner uses token ──► RenderDragon data endpoints ──► enforce via client_id + our registry + RLS +Partner server ──────► read-only Asset API (HMAC key) ──► api.codersoft.xyz (scoped, GET-only) + +RenderDragon dashboard (SPA, /partners/dashboard) + ├─ app settings (redirect URIs, branding, requested permissions) + ├─ users (consents for THIS app only) + ├─ API keys (read-only) + └─ stats (sign-ins, users, calls, top assets) +``` + +Components: + +| Component | Lives in | Notes | +| --- | --- | --- | +| OAuth/OIDC protocol | Supabase Auth | OAuth 2.1 server; asymmetric signing for OIDC | +| Consent UI | RenderDragon SPA | `/oauth/consent` (authorization path) | +| Partner registry | Supabase (Postgres) | orgs, apps, permissions, consents, keys metadata | +| Partner dashboard | RenderDragon SPA | `/partners/dashboard` | +| Read-only Asset API | `workers/api-router` | existing HMAC keys + scope/method enforcement | +| Usage analytics | `workers/analytics` | events attributed by `client_id` / key id | +| Webhooks (later) | worker | signed HMAC, reuse existing signing scheme | + +## Phases + +### Phase 0 — Enabling + pilot (manual) +- Enable `[auth.oauth_server]`, set `authorization_url_path = /oauth/consent`, enable + asymmetric JWT signing, disable dynamic client registration. +- Build the consent page ([`oauth-consent.md`](./oauth-consent.md)). +- Register 1–2 pilot partners' OAuth clients by hand in the Supabase dashboard. +- Deliverable: a real partner can complete "Sign in with RenderDragon" end to end. + +### Phase 1 — Registry + invite +- `partner_orgs`, `partner_apps`, permissions; invite + verification flow (manual approve). +- Provision OAuth client via Supabase admin API on approval; map `client_id ↔ org/app`. +- Read-only API keys issued and registered in the router's Durable Object. +- Deliverable: onboarding without touching the Supabase dashboard by hand. + +### Phase 2 — Dashboard: users + revocation +- Partner dashboard app settings, users list (consented users of this app only), revoke. +- User-facing "Connected apps" page + revoke. +- Deliverable: partners can manage their users; users can revoke. + +### Phase 3 — Statistics +- Emit per-app usage events; aggregate; expose partner-scoped stats endpoints + dashboard charts. +- Deliverable: sign-ins, active users, calls by endpoint, top assets, quota. + +### Phase 4 — Extras +- Webhooks (signed), SDK + OpenAPI, sandbox env, partner directory, badge, embeddable widget. + +## Deliverables checklist (when building) + +- [ ] Supabase OAuth server config + asymmetric keys + RLS policies for `client_id`. +- [ ] `/oauth/consent` page + consent records + revocation. +- [ ] `/partners` marketing page ([`page-benefits.md`](./page-benefits.md)). +- [ ] `/partners/dashboard` (app, users, keys, stats). +- [ ] Partner registry schema + RLS + admin provisioning flow. +- [ ] Scoped read-only keys in `workers/api-router` (scopes, method allowlist, rate limit). +- [ ] Analytics events + partner stats API. +- [ ] Docs for partners (quickstart, scopes, error codes, attribution). + +## Risks & mitigations + +| Risk | Mitigation | +| --- | --- | +| Custom OAuth scopes unavailable | Own permission registry; enforce in API + RLS; disclose on consent screen. Document on partner docs which fields are shared and why. | +| OAuth tokens are full-privilege | Tight RLS keyed on `client_id`; API re-checks permissions; audit logs; least-privilege grants. | +| Supabase OAuth is beta / could change | Wrap in a thin abstraction (a `partnerAuth` module) so a self-hosted OIDC provider (e.g. Ory Hydra on e3) can replace it without touching the dashboard/consent UI contracts. | +| MAU billing impact | Track OAuth MAUs; note on the page that sign-ins count toward MAU. | +| Consent fatigue / unclear disclosure | One screen, exact field list, extras only if requested, per-app "remember" via Supabase auto-approve. | +| Partner abuse / scraping | Verified-only, read-only, per-key rate limits, audit, revocable instantly. | +| One-app-per-org blocks dev/staging | Decide: allow a linked *test* redirect + test key on the same app, or a hidden second client not shown to users (see open questions). | + +## Open questions + +1. **Dev vs prod**: 1 org = 1 app is firm — but do we allow a separate test client/redirect for + development, or a sandbox mode on the same app? +2. **Consent auto-approve**: rely on Supabase's "already consented" behaviour, or always show + the screen? (Recommend: show once per app, then auto-approve, with a "not you?" path.) +3. **Which extras can partners request?** Proposed initial set: `profile`, `email`, + `favourites:read`, `uploads:read` (their own users' uploads), `stats:read`. Confirm. +4. **Stats retention window** and whether partners can pull raw event exports. +5. **Attribution requirements** wording for the Asset API license. +6. **Partner directory**: opt-in? logo/link moderation rules? +7. **Revocation semantics**: revoking an app's access — invalidate refresh tokens too? (Supabase + token revocation behaviour to verify.) diff --git a/docs/partner-program/README.md b/docs/partner-program/README.md new file mode 100644 index 00000000..fddb5c43 --- /dev/null +++ b/docs/partner-program/README.md @@ -0,0 +1,36 @@ +# RenderDragon Partner Program — plan + +**Status: PLAN ONLY. Nothing here is built yet. Do not implement until explicitly told to.** + +An invite-only program where hand-picked, verified partners get: + +- **"Sign in with RenderDragon"** (OAuth 2.1 / OIDC) so partners can authenticate RenderDragon + users inside their own services and apps. +- A **read-only Asset API** key scoped to the asset catalog. +- A **partner dashboard**: manage their app, see the users who signed in through it, and view + statistics. +- A transparent **consent screen** that lists exactly what is shared (avatar/icon, name, email, + and any extras the app requests, e.g. favourites). + +## Program rules (non-negotiable) + +- **Invite-only.** Only people we trust are given access; there is no self-serve signup and no + public client registration. Every partner is reviewed and verified by us. +- **No tiers.** Every partner is a verified partner with the same footing. +- **1 organization = 1 OAuth app.** +- **Least privilege, read-only by default.** Partners never get write access to the catalog. +- **Transparency.** Users see exactly which fields are shared and can revoke at any time. + +## Platform decision + +**Use Supabase Auth's OAuth 2.1 server** as the protocol backbone (no custom auth server). See +[`oauth-consent.md`](./oauth-consent.md) for the caveats that shape the rest of the design. + +## Read order + +1. [`PLAN.md`](./PLAN.md) — scope, architecture, phases, risks, open questions. +2. [`oauth-consent.md`](./oauth-consent.md) — the OAuth flow and the consent screen spec. +3. [`data-model.md`](./data-model.md) — tables, RLS, analytics events. +4. [`api-security.md`](./api-security.md) — read-only asset API, keys, scopes, enforcement. +5. [`dashboard-stats.md`](./dashboard-stats.md) — partner dashboard pages and metrics. +6. [`page-benefits.md`](./page-benefits.md) — the public `/partners` page and final benefit list. diff --git a/docs/partner-program/api-security.md b/docs/partner-program/api-security.md new file mode 100644 index 00000000..f8c3fd46 --- /dev/null +++ b/docs/partner-program/api-security.md @@ -0,0 +1,82 @@ +# Read-only Asset API + security + +## What partners get + +- **Read-only** access to the asset catalog over `api.codersoft.xyz`. +- A dedicated **API key** (HMAC-signed requests, using the scheme already built for the router). +- Per-key **scopes** and **rate limits**, revocable instantly. + +Partners do **not** get write access. Upload endpoints (`POST /api/assets`) are never granted to +partner keys. + +## Endpoints (read-only scope `assets:read`) + +| Path | Returns | +| --- | --- | +| `GET /all` | full catalog grouped by category | +| `GET /categories` | category list | +| `GET /category/:name` | one category | +| `GET /mcicons` | Minecraft icons | +| `GET /fonts` | fonts | +| `GET /__version` | catalog version (public; usable for cache validation) | + +Only `GET`/`HEAD` are accepted for partner keys; everything else is rejected by the router. + +## Enforcement in `workers/api-router` + +Extend the existing auth layer (HMAC client registry in the Durable Object): + +1. The client record gains `scopes: string[]` and `rateLimitPerMin`. +2. After signature verification, enforce: + - **Method allowlist** — `GET`/`HEAD` only for `assets:read` clients. + - **Path allowlist** — the endpoints above only; no `/api/assets` writes. + - **Rate limit** — per key (and optionally per key+IP), counted in the Durable Object. +3. Attribution is logged as a usage event (`key_id`). + +Because the router is the single chokepoint and the key registry already lives in a Durable +Object, scopes/limits are enforceable and revocable in one place with no redeploy. + +## Key lifecycle + +- Issued by RenderDragon on approval (partner dashboard "Keys" page can show `last_used_at` and + allow rotation). +- Secret shown **once**; stored only in the router's Durable Object (never in Postgres). +- Rotation = register a new key, revoke the old. +- Revocation is immediate (Durable Object is strongly consistent). + +## OAuth tokens vs API keys + +Two distinct things, used for different jobs: + +| | API key (HMAC) | OAuth token | +| --- | --- | --- | +| Who | partner's **server** | a RenderDragon **user** via the partner's app | +| Purpose | read the catalog | read that user's consented data | +| Scope | `assets:read` | token's `client_id` + consent record | + +User-data endpoints (favourites, the user's uploads, per-user stats) require an **OAuth token** +whose `client_id` maps to a partner app that has that permission, plus a non-revoked +`partner_consents` row for `user_id`. The token's `scope` string is **not** trusted for this — +we check our registry + consent. + +## Attribution & license + +- Partners must display "Assets provided by RenderDragon" (wording TBD) and link back. +- `GET /__version` lets partners cache the catalog and only refresh when it changes + (fewer calls, consistent snapshots). + +## Threat model additions + +- **Key leak** → revoke per key; scopes + rate limits bound the blast radius; read-only. +- **Scraping** → verified partners only, per-key limits, audit; catalog is public-ish by nature, + so the goal is abuse control, not secrecy. +- **Token replay / forgery** → OAuth tokens are asymmetric JWTs verified via JWKS by partners; + for our API, the `client_id` claim is verified by Supabase-issued signatures (or introspected), + and the consent record is checked server-side. +- **Privilege escalation** → never derive authorization from the OAuth `scope` string; always from + `partner_apps.permissions` + `partner_consents` + RLS. + +## Partner-facing docs to produce + +- Quickstart (get a key, first request), signing examples (Node/Python), error codes, + rate-limit headers, `__version` caching, attribution rules, and the list of shared user fields. diff --git a/docs/partner-program/dashboard-stats.md b/docs/partner-program/dashboard-stats.md new file mode 100644 index 00000000..881fd115 --- /dev/null +++ b/docs/partner-program/dashboard-stats.md @@ -0,0 +1,77 @@ +# Partner dashboard + statistics + +Route: `/partners/dashboard` (signed-in partner org members only). Separate from the public +`/partners` page. + +## Pages + +### Overview +- App status (active/suspended), verified badge. +- Headline numbers: total sign-ins, unique users, API calls (30d), quota used. +- Quick links: docs, rotate key, request support. + +### App settings +- Name, logo, description (these appear on the **consent screen** — changes are shown to users). +- Redirect URIs (validated; mirrored to the Supabase OAuth client). +- Requested **permissions** (from our registry): `profile`, `email`, `favourites:read`, + `uploads:read`, `stats:read`. Adding an email/favourite permission changes the consent screen. +- Client type (public/confidential) and token auth method (read-only display). +- Danger zone: suspend app (RenderDragon-only), rotate client secret. + +### Users +- The list of RenderDragon users who **authorized this app** (from `partner_consents`). +- Columns: avatar/icon, display name, username, email (only if `email` was consented), + granted permissions, granted date, last seen, status. +- Actions: **revoke** a user's access (deletes the consent + invalidates their refresh token). +- Export (CSV/JSON) of consented users, limited to consented fields. +- **Privacy:** this page can only ever show users of *this* app and only the fields they + approved. No access to RenderDragon's user base at large. + +### Keys (read-only Asset API) +- Create/rotate/revoke read-only keys; secret shown once. +- Per key: scopes (`assets:read`), rate limit, `last_used_at`, usage sparkline. + +### Stats +See below. + +### Docs & support +- Quickstart, signing examples, error codes, consent/data-sharing reference, support contact / + Discord channel. + +## Statistics + +| Metric | Definition | Source | +| --- | --- | --- | +| Sign-ins | completed consents / token issuances | `partner_usage_events` (`sign_in`, `token_issued`) | +| Unique users | distinct `user_id` per app | `partner_consents` | +| Active users | distinct users with a token use in the window | `partner_usage_events` | +| API calls | requests by `key_id` / `client_id` | router usage events | +| Calls by endpoint | breakdown over `/all`, `/category/*`, `/mcicons`, … | router | +| Error rate | non-2xx / total | router | +| p95 latency | per endpoint | router | +| Top assets/categories | most requested | router (endpoint + params) | +| Version checks | `/__version` hits vs refreshes | router | +| Downloads | asset download events (if instrumented) | router/analytics | +| Quota / rate-limit headroom | current usage vs limit | Durable Object counters | + +- Time ranges: 24h / 7d / 30d / custom. +- Charts: line (calls, users), bar (top endpoints/assets), table (recent events). +- **Partner-scoped API**: `GET /partners/api/stats?range=30d` returns only the caller org's + aggregates (RLS/org check). + +## Data sources & plumbing + +- **Sign-in / token events**: log from the consent page on `approveAuthorization`, and/or a + Custom Access Token Hook that records `client_id` + `user_id` on token issuance. +- **API call events**: emit from `workers/api-router` after auth, keyed by `key_id`/`client_id`, + into the existing analytics worker. +- Aggregation: pre-aggregate daily rollups so the dashboard queries are cheap; keep raw events + for a bounded window. + +## Retention & privacy + +- Raw events: keep ~90 days (TBD); daily aggregates kept longer. +- Partners see only aggregate stats for their app plus their own consented-user list. +- User-level analytics respect consent: if a user revokes, associated `user_id` rows are removed + or anonymized per policy. +- Export endpoints are org-scoped and rate-limited. diff --git a/docs/partner-program/data-model.md b/docs/partner-program/data-model.md new file mode 100644 index 00000000..c410acd1 --- /dev/null +++ b/docs/partner-program/data-model.md @@ -0,0 +1,108 @@ +# Data model + +All partner data lives in Supabase (Postgres) with RLS. OAuth tokens carry a `client_id` claim; +RLS uses it (plus our consent records) to scope access. Partners never query these tables +directly — the dashboard reads them through our API/RLS with the partner's own org context. + +## Tables + +### `partner_orgs` +| Column | Type | Notes | +| --- | --- | --- | +| `id` | uuid pk | | +| `name` | text | display / legal name | +| `verified` | boolean | set by us on approval | +| `verified_domain` | text | domain-verified for badge | +| `status` | text | `invited` \| `active` \| `suspended` | +| `contact_email` | text | | +| `created_at` / `updated_at` | timestamptz | | + +Constraint: a `partner_orgs` row is created only through our invite/approval flow. + +### `partner_apps` (one per org) +| Column | Type | Notes | +| --- | --- | --- | +| `id` | uuid pk | | +| `org_id` | uuid fk | **UNIQUE** → 1 org = 1 app | +| `supabase_client_id` | text unique | Supabase OAuth client | +| `name` | text | shown on the consent screen | +| `description` | text | | +| `logo_url` | text | shown on the consent screen | +| `redirect_uris` | text[] | mirrored from Supabase | +| `type` | text | `public` \| `confidential` | +| `token_auth_method` | text | e.g. `client_secret_basic` | +| `permissions` | text[] | requested capabilities (our registry) | +| `status` | text | `active` \| `suspended` | +| `created_at` / `updated_at` | timestamptz | | + +### `partner_consents` +One row per (app, user) authorization; the source of truth for "who signed in through this app". + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | uuid pk | | +| `app_id` | uuid fk | | +| `user_id` | uuid fk → auth.users | | +| `granted_permissions` | text[] | subset of app permissions the user approved | +| `granted_at` | timestamptz | | +| `revoked_at` | timestamptz null | | +| `last_seen_at` | timestamptz | updated on token use | + +**Visibility rule:** a partner can only see users in `partner_consents` for **their own app** and +only the fields those users consented to. + +### `partner_api_keys` (read-only asset API) +| Column | Type | Notes | +| --- | --- | --- | +| `id` | uuid pk | | +| `org_id` | uuid fk | | +| `key_id` | text unique | public identifier sent as `X-Auth-Client` | +| `label` | text | | +| `scopes` | text[] | e.g. `assets:read` | +| `rate_limit_per_min` | int | | +| `status` | text | `active` \| `revoked` | +| `last_used_at` | timestamptz | | +| `created_at` / `revoked_at` | timestamptz | | + +The **secret** is never stored here — it is registered in the router's Durable Object +(`workers/api-router`), and Postgres stores only the key id + label — no secret or hash. + +### `partner_requests` (invite/application queue) +| Column | Type | Notes | +| --- | --- | --- | +| `id`, `org_name`, `contact_email`, `use_case`, `status`, `reviewed_by`, `created_at` | | intake for our review; only approved entries become `partner_orgs` | + +## RLS outline + +- `partner_orgs` / `partner_apps` / `partner_api_keys` / `partner_consents`: readable by the + owning partner's users (via an org-membership table) and by RenderDragon admins; never public. +- End-user visibility of their own consents via `partner_consents.user_id = auth.uid()` for the + "Connected apps" page. +- For user-data endpoints called with an OAuth token: policies check + `auth.jwt() ->> 'client_id'` maps to a `partner_apps` row with the needed permission + **and** a non-revoked `partner_consents` row for the requesting `user_id`. + +## Analytics events + +Emitted by `workers/api-router` (and the consent/token hooks) and aggregated by +`workers/analytics`. + +`partner_usage_events` (append-only, aggregated for the dashboard): +| Column | Notes | +| --- | --- | +| `ts` | | +| `app_id` / `key_id` | attribution (from `client_id` claim or key id) | +| `user_id` | nullable (user-context calls) | +| `type` | `sign_in` \| `token_issued` \| `api_call` \| `download` \| `consent_granted` \| `consent_revoked` | +| `endpoint` | for `api_call` | +| `status` | | +| `latency_ms` | | + +Retention and raw-export policy: see `dashboard-stats.md`. + +## Notes + +- Keep the OAuth client mapping in one place so a future migration off Supabase OAuth (e.g. to a + self-hosted OIDC provider) only changes how `supabase_client_id` is populated. +- All admin/provisioning actions are audited (`audit_log`) since access is invite-only and + manual. diff --git a/docs/partner-program/emails/emotion-gg-invite.html b/docs/partner-program/emails/emotion-gg-invite.html new file mode 100644 index 00000000..4a3d79ec --- /dev/null +++ b/docs/partner-program/emails/emotion-gg-invite.html @@ -0,0 +1,130 @@ + + + + + + + You're invited to the RenderDragon Partner Program + + +
+ Sign in with RenderDragon, read the asset catalog, and run it all from one dashboard. +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ RenderDragon +
+

+ Partner program, by invitation +

+

+ emotion.gg, we'd like to build with you. +

+

+ We're inviting a few teams we trust into the RenderDragon Partner Program, and we'd like + emotion.gg to be one of them. +

+

+ Here's what that gets you. +

+
 
+ + + + + + + + + + + + + + + + + +
● +

Sign in with RenderDragon

+

Your users sign in with their RenderDragon account. It's plain OAuth 2.1, so it drops into whatever you already run.

+
● +

The whole catalog, one key

+

Resources, Minecraft icons, sounds, fonts, presets. One key gets you all of it.

+
● +

A dashboard for your app

+

See who signed in through your app, and cut their access when you need to.

+
● +

Consent people can actually read

+

We show users what they're sharing before they say yes, and let them undo it later.

+
+
+ + + + +
+ + Accept the invitation + +
+

+ Or just reply to this email and we'll get your app and key set up. +

+
+
 
+

+ Talk soon,
+ The RenderDragon team +

+
+

+ RenderDragon. Free tools and assets for Minecraft creators.
+ renderdragon.org +  ·  + Contact +  ·  + Privacy +

+

+ You're getting this because we think you'd be a good fit. Not interested? Ignore it. +

+
+ +
+ + diff --git a/docs/partner-program/oauth-consent.md b/docs/partner-program/oauth-consent.md new file mode 100644 index 00000000..3b9b45f3 --- /dev/null +++ b/docs/partner-program/oauth-consent.md @@ -0,0 +1,105 @@ +# OAuth + consent screen + +## Supabase OAuth 2.1 server configuration + +```toml +# supabase/config.toml +[auth.oauth_server] +enabled = true +authorization_url_path = "/oauth/consent" +allow_dynamic_registration = false # invite-only: we register clients ourselves +``` + +- **Asymmetric signing (RS256/ES256) is required** for OIDC/ID tokens. Enable it, since partners + will validate ID tokens via the JWKS endpoint. +- OAuth sign-ins count toward the project's **MAU**. +- Client registration via the Supabase dashboard or admin SDK. We disable dynamic registration + because access is invite-only. + +## Client registration (1 org = 1 app) + +On approval, we register exactly one Supabase OAuth client per organization and store the +mapping: + +| Supabase client | Ours | +| --- | --- | +| `client_id` | `partner_apps.supabase_client_id` | +| `redirect_uris` | `partner_apps.redirect_uris` | +| `client_type` (public/confidential) | `partner_apps.type` | +| `token_endpoint_auth_method` | `partner_apps.token_auth_method` | + +- **Confidential** clients (server-side) get a client secret (stored hashed by Supabase; we + never store it). +- **Public** clients (SPA/mobile) use PKCE, no secret. + +## Flow (what the partner integrates) + +1. Partner redirects to `https://.supabase.co/auth/v1/oauth/authorize?client_id=…& + redirect_uri=…&response_type=code&code_challenge=…&scope=openid email profile&state=…`. +2. Supabase validates and 302s the user to our `authorization_url_path` with `authorization_id`. +3. Our SPA route `/oauth/consent`: + - If the user isn't signed in → send to our sign-in, preserving `authorization_id`. + - `supabase.auth.oauth.getAuthorizationDetails(authorization_id)` → client name, redirect URI, + requested scopes, and (our addition) the partner app + its requested permissions. + - Render the consent screen (below). + - On approve → `approveAuthorization(authorization_id)`; on deny → `denyAuthorization(...)`; + redirect to the returned `redirect_url`. +4. Supabase issues the code; the partner exchanges it for access + refresh (+ ID) token. + +## Consent screen — required content + +The screen must be unambiguous about **the exact data shared** and **who** is asking. It shows: + +**The requester** +- Partner app **name**, **logo/icon**, description. +- Owning org name + **Verified partner** badge. +- The redirect URI / domain the user will be sent back to. + +**The data that will be shared** (only what the app requested). Rendered as a field-by-field list +with icons, not vague scope names: + +| Group (permission) | Shown when requested | Fields displayed to the user | +| --- | --- | --- | +| Basic profile (`profile`) | always (part of sign-in) | **Avatar/icon**, **display name**, **username** | +| Email (`email`) | if requested | **Email address** and whether it's verified | +| Favourites (`favourites:read`) | if requested | The user's **favourited assets** (list) | + +Rules: +- **Default sign-in** shows at least avatar + name + username; email/others appear only if the + app requests them. +- Each requested extra is a separate line the user can read; nothing is bundled under an opaque + label. +- Show a short plain-language sentence per item (e.g. "See the assets you have favourited"). +- Links to TOS + Privacy. Buttons: **Approve** / **Deny**. A note that the user can revoke later + from **Settings → Connected apps**. + +## Mapping to real capabilities (important) + +Supabase only supports `openid email profile phone`. We therefore: + +- Send the standard `scope` to Supabase for token/ID-token content. +- Keep our own **`partner_apps.permissions`** registry as the authoritative request list, and drive + the consent UI from it. +- Record `partner_consents` (app_id, user_id, granted permissions, timestamps) on approval. +- Enforce the granted permissions in **our** API + RLS using the token's `client_id` claim and the + consent record — never trusting the `scope` string alone. + +## Consent lifecycle + +- **First time**: show the screen. +- **Already consented** (Supabase auto-approve) — still show a lightweight confirmation or skip, + per the open question in `PLAN.md`. +- **Deny**: `denyAuthorization`, return the user to the partner with an error. +- **Revoke**: + - User side: `/settings/connected-apps` lists apps + granted fields with a **Revoke** button. + - Partner side: dashboard "remove user". + - Revocation must invalidate the partner's refresh token (verify Supabase revocation support; + otherwise call the admin revoke endpoint) and delete our `partner_consents` row. + +## Edge cases + +- Not signed in when hitting `/oauth/consent` → sign-in, then resume with same `authorization_id`. +- Signed in as the wrong account → allow "use a different account". +- Unknown/disabled `client_id` → error page, never fall through to approval. +- Missing/expired `authorization_id` → error page. +- Partner app in `suspended` state → block approval, show a message. diff --git a/docs/partner-program/page-benefits.md b/docs/partner-program/page-benefits.md new file mode 100644 index 00000000..b8375de7 --- /dev/null +++ b/docs/partner-program/page-benefits.md @@ -0,0 +1,64 @@ +# Public `/partners` page + benefit list + +A separate marketing page (not the dashboard). Invite-only: the page sells the program and lets +people **request an invite**; it does not let anyone self-serve. + +## Page sections + +1. **Hero** — "Build with RenderDragon." One line: verified partners get sign-in, a read-only + asset API, and a dashboard. CTA: **Request an invite** / **Partner docs**. +2. **Who it's for** — tools, bots, editors, studios, and services in the Minecraft creator + ecosystem. Note it's invite-only and each partner is verified by us. +3. **Benefits** (cards — see list below). +4. **How it works** — 4 steps: request invite → we verify & approve → you get an OAuth app + + read-only key → build with "Sign in with RenderDragon". +5. **Partner directory** — opted-in partners (logo, name, blurb, link). +6. **Trust & privacy** — exactly what's shared, on-screen consent, one-click revoke, read-only, + least privilege. +7. **FAQ** — invite-only? cost? what data is shared? can I write assets? rate limits? +8. **Apply** — invite request form (`partner_requests`). +9. **Footer** — links to docs, TOS, privacy. + +## Benefit list (final) + +### Core +- **"Sign in with RenderDragon" (OAuth 2.1 / OIDC)** — standards-compliant; verify tokens via + JWKS; PKCE. +- **Read-only Asset API** — the full catalog: resources, Minecraft icons, sounds, fonts, presets, + with a catalog **version** endpoint for cheap, consistent caching. +- **Partner dashboard** — manage your app, see the users who signed in through it, and view + statistics. +- **User management** — see and revoke the users who authorized your app (only your users, only + consented fields). +- **Transparent consent** — users see exactly which fields are shared (avatar/icon, name, + username, email, and any extras such as favourites). + +### Developer platform +- **Signed webhooks** — catalog version changed, user revoked, new asset published. +- **OpenAPI spec + typed SDKs + sandbox fixtures.** +- **Bulk snapshots** and **stable, versioned API** with a deprecation policy and changelog feed. +- **Embeddable asset-browser widget** ("Powered by RenderDragon"). +- **Per-key rate limits, IP allowlists, and audit logs.** + +### Distribution & growth +- **Partner directory** listing + backlink. +- **"Built with RenderDragon" badge.** +- **Co-marketing**: blog feature, changelog shout-out, newsletter/Discord spotlight. +- **Early access** to new features and **new Minecraft-version asset packs**. +- **Priority support** via a private Discord channel. + +### Trust +- **Verified partner badge** (domain-verified, manually reviewed). +- **Read-only, least-privilege, instantly revocable.** +- **Clear data-sharing disclosure** and user-side revocation. + +*(No tiers and no pricing on the page — every partner is a verified partner. If support/marketing +asks for it later, add a note that the program is by invitation and free to approved partners.)* + +## Copy notes / guardrails + +- Don't overclaim: say "read-only", "invite-only", "verified". Never imply partners can write or + that they can see all RenderDragon users. +- Always show the data-sharing example so applicants know what users will consent to. +- Mention that OAuth sign-ins count toward RenderDragon's auth usage only if we want to; likely + omit from public copy. diff --git a/index.html b/index.html index 993d0947..47d9a552 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + @@ -23,8 +23,7 @@ - - + @@ -58,7 +57,7 @@ - +