Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions docs/design/ui-ux-revamp-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,18 @@ The owner shared these 21st.dev links as inspiration only. We redesigned everyth
| เลื่อนตามข้อความ | `chat-app.tsx` เลื่อนเฉพาะเมื่อผู้ใช้อยู่ใกล้ล่าง; ปุ่ม “Jump to latest” (`chat.jumpToLatest`) |

SSE / รูปแบบ `UiEvent` ฝั่งเซิร์ฟเวอร์ไม่เปลี่ยน

## 17. 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.
64 changes: 64 additions & 0 deletions docs/user-guide/customer-deploy.md
Original file line number Diff line number Diff line change
@@ -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 ลบได้ทุกเมื่อ และถูกลบทั้งหมดเมื่อลบบัญชี
2 changes: 1 addition & 1 deletion scripts/build-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
{
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/auth/github/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" });
Expand Down
12 changes: 12 additions & 0 deletions src/app/api/connections/audit/route.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
32 changes: 30 additions & 2 deletions src/components/connections-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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" && <span className="font-mono"> (legacy key mode)</span>}
{encryption === "v1" && <span className="font-mono"> (legacy key mode)</span>}{" "}
<a href="/docs/user-guide/customer-deploy#privacy--ความเป็นส่วนตัว" className="underline underline-offset-4 hover:text-fg">{th ? "นโยบายความเป็นส่วนตัว" : "Privacy"}</a>
</p>

<div aria-live="polite" className="mt-4 min-h-5 text-sm">
Expand All @@ -96,7 +106,7 @@ export function ConnectionsPanel() {
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
<button disabled={busy === "cloudflare" || !cf.token || !cf.accountId} className={buttonClasses("primary", "sm")}>{th ? "ตรวจสอบและบันทึก" : "Verify & save"}</button>
{cfc && <button type="button" onClick={() => call("cloudflare", "DELETE")} className={buttonClasses("ghost", "sm")}>{th ? "ลบ" : "Remove"}</button>}
<a href="/docs/customer-deploy#cloudflare-token" className="text-xs text-subtle underline underline-offset-4 hover:text-fg">{th ? "วิธีสร้าง token (Workers Scripts: Edit + Account Settings: Read)" : "How to create a token (Workers Scripts: Edit + Account Settings: Read)"}</a>
<a href="/docs/user-guide/customer-deploy#cloudflare-token" className="text-xs text-subtle underline underline-offset-4 hover:text-fg">{th ? "วิธีสร้าง token (Workers Scripts: Edit + Account Settings: Read)" : "How to create a token (Workers Scripts: Edit + Account Settings: Read)"}</a>
</div>
</form>
</Card>
Expand All @@ -110,6 +120,24 @@ export function ConnectionsPanel() {
</form>
</Card>
</div>

<div className="mt-8">
<button type="button" onClick={loadAudit} aria-expanded={showAudit} className="text-sm text-subtle underline underline-offset-4 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg">
{th ? "ประวัติการใช้งาน (audit log)" : "Activity (audit log)"}
</button>
{showAudit && (
<ul className="mt-3 divide-y divide-border rounded-xl border border-border font-mono text-xs">
{events.length === 0 && <li className="px-3 py-2 text-subtle">—</li>}
{events.map((e, i) => (
<li key={`${e.at}-${i}`} className="flex gap-3 px-3 py-2">
<span className="text-subtle">{new Date(e.at).toLocaleString()}</span>
<span className="text-fg">{e.event}</span>
<span className="truncate text-subtle">{e.subject}</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}
Expand Down
Loading
Loading