From c517d6e3b8a5244fcb290578078f0c13be55090c Mon Sep 17 00:00:00 2001 From: thanabartbb <326990420+thanabartbb@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:05:37 +0700 Subject: [PATCH] feat(connections): revoke GitHub grant, public_repo option, audit log, user guide + privacy note (PR-E) --- CHANGELOG.md | 6 +++ docs/design/ui-ux-revamp-plan.md | 15 ++++++ docs/user-guide/customer-deploy.md | 64 +++++++++++++++++++++++ scripts/build-docs.mjs | 2 +- src/app/api/auth/github/callback/route.ts | 8 +-- src/app/api/auth/github/route.ts | 4 +- src/app/api/connections/audit/route.ts | 12 +++++ src/components/connections-panel.tsx | 32 +++++++++++- src/lib/auth/github-api.ts | 26 +++++++-- src/lib/auth/github-policy.ts | 13 +++++ src/lib/connections/revoke.ts | 19 +++++-- tests/connections-revoke.test.ts | 23 ++++++++ 12 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 docs/user-guide/customer-deploy.md create mode 100644 src/app/api/connections/audit/route.ts create mode 100644 tests/connections-revoke.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 315fdad..0b0d352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Revoke, least-privilege scope, audit log, docs (PR-E).** + - **Revoke:** disconnecting GitHub now revokes the OAuth grant at GitHub (`DELETE /applications/{client_id}/grant`) before deleting the stored token. + - **Least-privilege scope:** `/api/auth/github?scope=public` requests `public_repo` instead of `repo`. The callback stores the scopes GitHub actually granted. + - **Audit log:** `GET /api/connections/audit` returns event types only, and `/settings/connections` shows them. + - **User guide:** `docs/user-guide/customer-deploy.md` (TH + EN) covers the Cloudflare token template (Workers Scripts: Edit + Account Settings: Read), limits, self-heal and a privacy note. + - **Design refs:** recorded in `docs/design/ui-ux-revamp-plan.md` section 16. - **Direct deploy + self-heal (PR-D).** `POST /api/projects/:id/deploy` uploads `direct` profiles (static sites and plain ESM Workers, from the mapping table) into the customer's own Cloudflare account with their token: assets upload session → script upload → workers.dev URL. Limits: 20 per day, one in flight per project, 200 files / 10 MB. Status goes queued → uploading → live/failed and is polled through `GET /api/deployments/:id`. `/projects/[id]` shows a deployments timeline with a status pill, the live URL with a copy button, and the error code with a log. Providers sit behind `DeployProvider` (`src/lib/deploy/provider.ts`), and Vercel is a registered stub (TODO). Self-heal: failures store a sanitized `error_log` (`sanitizeLog` strips tokens). Dev Chat tools `get_deploy_logs` and `apply_fix` read the failure and commit a fix to the deploy branch or `devstack/fix-*`, never the default branch, with at most 3 attempts per deployment. Each attempt shows in the activity list and a `deploy` UiEvent renders a chat card with **Retry deploy**. Only the customer's click redeploys. - **Deploy mapping + Deploy to Cloudflare button (PR-C).** `src/lib/deploy/templates.ts` is the single table mapping every Stack Template, the Studio export, chat-generated code and the sandbox example to a target (worker-assets / worker-script / opennext / not-deployable), path (direct / cf-build / none), build command, entry, assets dir, required config and secrets; documented in `docs/deploy-templates.md` (audit flags: `collab-workspace` ws server breaks on Workers, Studio export's `wrangler.toml` targets Pages, express/fastapi/streamlit/CLI/PyPI are not deployable). `/projects/[id]` shows the profile, a "Prepare for Cloudflare" PR (`POST /api/projects/:id/prepare` adds wrangler.jsonc / open-next.config.ts / package.json scripts) and the Deploy to Cloudflare button (public repos only; no token stored). - **Customer repos, multi-file commits and PRs (PR-B).** `POST /api/projects` creates a repo in the customer's GitHub (5/day) and records a project; `POST /api/projects/:id/changes` and `POST /api/github/pulls` branch, commit many files in ONE Git Data commit (blobs → tree → commit → ref) and open a PR (30/day). `/projects` and `/projects/[id]` list projects and PRs; the chat "Commit to GitHub" dialog can now open a PR with all code blocks. Dev Chat gets customer GitHub tools: `github_list_repositories`, `github_read_tree`, `github_read_file`, `github_create_repo`, `github_create_branch`, `github_write_files` (create/update/delete, one commit), `github_open_pull_request`, `github_list_pull_requests`, `github_get_pull_request` — each with an activity label. Writes never hit the default branch directly, `.github/workflows` and `.git/` are blocked, max 200 files / 10 MB, GitHub 403/429 back-off. diff --git a/docs/design/ui-ux-revamp-plan.md b/docs/design/ui-ux-revamp-plan.md index a000db1..3710834 100644 --- a/docs/design/ui-ux-revamp-plan.md +++ b/docs/design/ui-ux-revamp-plan.md @@ -312,3 +312,18 @@ The owner shared these 21st.dev links as inspiration only. We redesigned everyth | Spinners ([21st.dev](https://21st.dev/community/components/s/spinner)) | A choice between a ring and a dots variant | `Spinner` in `src/components/ui/skeleton.tsx` (extended, not duplicated): `variant="ring" \| "dots"`, `size`, `currentColor`, `role=status` when it has a label | Sandpack loading (`home/live-example.tsx`), chat status (`chat/chat-app.tsx`, dots), login submit (`login-form.tsx`), plus the existing Button `loading`, Factory and the sandbox panel | ก่อนจะนำโค้ดจากลิงก์เหล่านี้มาใช้จริงในอนาคต ต้องตรวจสัญญาอนุญาตและขออนุมัติตามข้อ 9 ก่อน + +## 16. Customer connections, projects and deploys (PR-A to PR-E) + +Pages: `/settings/connections`, `/projects`, `/projects/[id]`, plus the deploy card in chat. Every one uses NVX UI with monochrome tokens: `border-border`, `text-fg`, `text-subtle`, and `bg-fg text-bg` for the strongest state. No colour is used for status; the shape and fill of the pill carry it. No code was copied. These are bookmark references from section 13, used as ideas only: + +| Reference | Idea taken | Our implementation | +|---|---|---| +| Process Timeline by shadcnui-blocks (21st.dev id 28381) | A vertical connector line with a dot per step | `src/components/projects/direct-deploy-panel.tsx`: a `border-l` rail with an outlined dot per deployment; `StatusPill` in `src/components/deploy/deploy-status.tsx` uses outline for live, solid fg for failed and a Spinner while running | +| Dismissible Alert Stack by olewandowski1 (id 26527) | Inline, low-noise alerts | Connections and project errors render as a `role="alert"` outline box (`border-fg/40`), not a red banner | +| nextjs.org settings tabs (visual reference) | An underline tab row | `src/components/settings-nav.tsx`: MCP, Connections and Projects tabs with `aria-current` | + +Accessibility rules: +- Status regions use `aria-live="polite"`. +- Secret inputs use `type="password"` with `autocomplete="off"` and are cleared after submit. +- Every control has a visible focus ring. diff --git a/docs/user-guide/customer-deploy.md b/docs/user-guide/customer-deploy.md new file mode 100644 index 0000000..1e1d7e2 --- /dev/null +++ b/docs/user-guide/customer-deploy.md @@ -0,0 +1,64 @@ +# Connect accounts, open PRs and deploy · เชื่อมบัญชี เปิด PR และ deploy + +DevStack works inside **your own** GitHub and Cloudflare accounts. You connect them once at `/settings/connections`. After that, the chat or the `/projects` page can: +- create repositories +- commit many files in one commit +- open pull requests +- deploy into your account + +DevStack ทำงานในบัญชี GitHub และ Cloudflare **ของคุณเอง** ผูกบัญชีครั้งเดียวที่ `/settings/connections` จากนั้นแชตหรือหน้า `/projects` จะสร้าง repo, commit ทีละหลายไฟล์, เปิด PR และ deploy เข้าบัญชีของคุณได้ + +## GitHub + +- **Connect GitHub** asks for `repo`, so DevStack can work with your private repositories. +- **Public repos only** asks for `public_repo` (least privilege). With it, DevStack can't read or write private repositories. +- **Disconnect** revokes the OAuth grant at GitHub and deletes the stored token. + +Safety rules: +- DevStack never pushes straight to your default branch. Changes go to a `devstack/…` branch and a pull request. +- DevStack never writes `.github/workflows/` or `.git/`. + +ปุ่ม "เฉพาะ repo สาธารณะ" ขอสิทธิ์น้อยที่สุด ส่วนปุ่มยกเลิกการเชื่อมต่อจะเพิกถอนสิทธิ์ที่ GitHub และลบ token ทันที + +## Cloudflare token + +Create the token at dash.cloudflare.com → My Profile → API Tokens → **Create Custom Token**. Give it only these two permissions: + +| Permission | Access | +|---|---| +| Account · **Workers Scripts** | Edit | +| Account · **Account Settings** | Read | + +1. Limit the token to one account and copy it. +2. Copy your **Account ID** from the Workers overview page. +3. Paste both into `/settings/connections` and click **Verify & save**. + +DevStack checks the token with `GET /user/tokens/verify` and checks the account with `GET /accounts/{id}`, both before saving. + +สร้าง token แบบ Custom ให้สิทธิ์แค่ Workers Scripts: Edit และ Account Settings: Read แล้ววางพร้อม Account ID ระบบจะตรวจกับ Cloudflare ก่อนบันทึก + +## Deploying + +The page [deploy-templates.md](../deploy-templates.md) shows how each template deploys. +- **Direct deploy** handles static sites and plain JavaScript Workers. DevStack uploads them into your account and shows the `*.workers.dev` URL. +- **Deploy to Cloudflare** is for projects that need a build, such as Next.js or Vite. Cloudflare builds those from your repo. It works for public repos only; for a private repo, connect it in Workers Builds instead. +- **Prepare for Cloudflare** opens a PR that adds the config files the project still needs. + +Daily limits per account: 5 new repos, 30 pull requests and 20 deploys. Each project can run only one deploy at a time. + +If a deploy fails, ask Dev Chat to fix it: +1. It reads the cleaned-up error log (tokens removed). +2. It commits a fix to a branch, with at most 3 attempts. +3. It shows a **Retry deploy** button. Nothing is redeployed until you click it. + +ถ้า deploy ล้มเหลว ให้ Dev Chat อ่าน log (ตัด token ออกแล้ว) แก้โค้ดลงสาขาใหม่ได้สูงสุด 3 ครั้ง แล้วคุณกด Retry deploy เอง + +## Privacy · ความเป็นส่วนตัว + +- Keys and tokens are encrypted on the server with AES-GCM, using a dedicated key and bound to your account. +- They are never shown again. You only ever see the last 4 characters. +- They are never logged and never sent to the AI model. +- Remove any connection at any time. Deleting your account deletes all of them, along with your projects, deployments and audit events. +- The audit log on `/settings/connections` records event types only (for example "credential.saved" or "deploy.live"), never secrets. + +คีย์ทั้งหมดเข้ารหัสเก็บฝั่งเซิร์ฟเวอร์ แสดงแค่ 4 ตัวท้าย ไม่บันทึกลง log และไม่ส่งให้โมเดล AI ลบได้ทุกเมื่อ และถูกลบทั้งหมดเมื่อลบบัญชี diff --git a/scripts/build-docs.mjs b/scripts/build-docs.mjs index 0c80f80..d37fa6f 100644 --- a/scripts/build-docs.mjs +++ b/scripts/build-docs.mjs @@ -45,7 +45,7 @@ export const SECTIONS = [ { id: "user-guide", label: { th: "คู่มือผู้ใช้", en: "User guide" }, - files: ["getting-started", "templates", "builder", "ai-agent", "chat", "dev-chat", "sandbox-factory", "studio", "sharing", "downloads", "language-theme", "troubleshooting"].map((f) => `user-guide/${f}.md`), + files: ["getting-started", "templates", "builder", "ai-agent", "chat", "dev-chat", "sandbox-factory", "studio", "customer-deploy", "sharing", "downloads", "language-theme", "troubleshooting"].map((f) => `user-guide/${f}.md`), }, { id: "concepts", label: { th: "แนวคิด", en: "Concepts" }, files: [...["stack-template", "composition", "safety-model", "design-system"].map((f) => `concepts/${f}.md`), "design/ui-ux-revamp-plan.md"] }, { diff --git a/src/app/api/auth/github/callback/route.ts b/src/app/api/auth/github/callback/route.ts index a0a92e5..ed82a59 100644 --- a/src/app/api/auth/github/callback/route.ts +++ b/src/app/api/auth/github/callback/route.ts @@ -1,7 +1,7 @@ import { issueSession } from "@/lib/auth/session"; import { connection } from "next/server"; -import { exchangeCode, fetchProfile } from "@/lib/auth/github-api"; -import { GITHUB_SCOPES } from "@/lib/auth/github-policy"; +import { exchangeCodeWithScope, fetchProfile } from "@/lib/auth/github-api"; +import { GITHUB_SCOPES, grantedScopes } from "@/lib/auth/github-policy"; import { planSignIn } from "@/lib/auth/link-account"; import { oauthFail, readOAuthState, signedInRedirect } from "@/lib/auth/oauth-flow"; import { seal, sealKeys } from "@/lib/connections/credentials"; @@ -32,7 +32,7 @@ export async function GET(req: Request) { const { db } = await chatRuntime(); if (!db) return oauthFail(url.origin, secure, "error", "github"); try { - const token = await exchangeCode({ + const { token, scope } = await exchangeCodeWithScope({ clientId: server.GITHUB_CLIENT_ID, clientSecret: server.GITHUB_CLIENT_SECRET, code, @@ -44,7 +44,7 @@ export async function GET(req: Request) { db, plan, { provider: "github", subject: profile.id, login: profile.login, name: profile.name, email: profile.email, avatarUrl: profile.avatarUrl }, - { tokenEnc: await seal(sealKeys(secret), "github", plan.userId, token), scope: GITHUB_SCOPES } + { tokenEnc: await seal(sealKeys(secret), "github", plan.userId, token), scope: grantedScopes(scope, GITHUB_SCOPES) } ); console.warn(JSON.stringify({ at: "api/auth/github", event: "linked", login: profile.login })); return signedInRedirect(url.origin, secure, opened.next, await issueSession(db, plan.userId, secret)); diff --git a/src/app/api/auth/github/route.ts b/src/app/api/auth/github/route.ts index 23ea0bd..65e1850 100644 --- a/src/app/api/auth/github/route.ts +++ b/src/app/api/auth/github/route.ts @@ -1,5 +1,5 @@ import { connection } from "next/server"; -import { GITHUB_SCOPES, safeNext } from "@/lib/auth/github-policy"; +import { githubScopesFor, safeNext } from "@/lib/auth/github-policy"; import { OAUTH_COOKIE_PATH, issueOAuthState, oauthFail } from "@/lib/auth/oauth-flow"; import { chatEnv } from "@/lib/chat/config"; import { OAUTH_STATE_COOKIE, namedCookie } from "@/lib/chat/identity"; @@ -22,7 +22,7 @@ export async function GET(req: Request) { const authorize = new URL("https://github.com/login/oauth/authorize"); authorize.searchParams.set("client_id", server.GITHUB_CLIENT_ID); authorize.searchParams.set("redirect_uri", `${url.origin}/api/auth/github/callback`); - authorize.searchParams.set("scope", GITHUB_SCOPES); + authorize.searchParams.set("scope", githubScopesFor(url.searchParams.get("scope"))); authorize.searchParams.set("state", state); authorize.searchParams.set("allow_signup", "true"); const headers = new Headers({ Location: authorize.toString(), "Cache-Control": "no-store" }); diff --git a/src/app/api/connections/audit/route.ts b/src/app/api/connections/audit/route.ts new file mode 100644 index 0000000..22d5763 --- /dev/null +++ b/src/app/api/connections/audit/route.ts @@ -0,0 +1,12 @@ +import { connection } from "next/server"; +import { json } from "@/lib/chat/runtime"; +import { listAudit } from "@/lib/connections/audit"; +import { gate, isResponse } from "@/lib/connections/route-kit"; + +/** GET /api/connections/audit — the caller's own audit trail (event types only, no secrets). */ +export async function GET(req: Request) { + await connection(); + const a = await gate(req, { write: false, burstKey: "audit", perMinute: 20 }); + if (isResponse(a)) return a; + return json({ events: await listAudit(a.db, a.uid).catch(() => []) }, 200, { "Cache-Control": "no-store" }); +} diff --git a/src/components/connections-panel.tsx b/src/components/connections-panel.tsx index cab54a9..cb63c5e 100644 --- a/src/components/connections-panel.tsx +++ b/src/components/connections-panel.tsx @@ -24,6 +24,15 @@ export function ConnectionsPanel() { const [busy, setBusy] = useState(""); const [cf, setCf] = useState({ token: "", accountId: "" }); const [oai, setOai] = useState(""); + const [events, setEvents] = useState<{ event: string; subject: string | null; at: number }[]>([]); + const [showAudit, setShowAudit] = useState(false); + + async function loadAudit() { + setShowAudit((v) => !v); + const r = await fetch("/api/connections/audit", { cache: "no-store" }); + const b = await r.json().catch(() => ({})); + if (r.ok) setEvents(b.events ?? []); + } const refresh = useCallback(async () => { const b = await loadConnections(); @@ -70,7 +79,8 @@ export function ConnectionsPanel() { {th ? "คีย์ถูกตรวจกับผู้ให้บริการก่อนบันทึก เก็บแบบเข้ารหัส (AES-GCM) ฝั่งเซิร์ฟเวอร์ แสดงแค่ 4 ตัวท้าย และลบได้ทุกเมื่อ ไม่มีการส่งคีย์กลับมาที่เบราว์เซอร์" : "Keys are verified with the provider before saving, stored encrypted (AES-GCM) on the server, shown only as the last 4 characters, and can be removed at any time. Keys are never sent back to the browser."} - {encryption === "v1" && (legacy key mode)} + {encryption === "v1" && (legacy key mode)}{" "} + {th ? "นโยบายความเป็นส่วนตัว" : "Privacy"}