From 065e88ee923bd7e6d4ebccbea47c8357cc5af0cf Mon Sep 17 00:00:00 2001 From: thanabartbb <326990420+thanabartbb@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:12:54 +0700 Subject: [PATCH] feat: CREDENTIALS_KEY via Secrets Store, Create token menu, Remember + /settings/environment, R2 chat stream archive - secrets_store_secrets binding CREDENTIALS_KEY (await env.CREDENTIALS_KEY.get(), cached), fallback to Worker secret then v1 - CreateTokenMenu (Cloudflare, GitHub fine-grained, OpenAI, Vercel, Hugging Face; URLs verified in provider docs) - Remember checkbox (default off = use once); migration 0012 user_env; /api/environment; /settings/environment (names + dates, delete) - direct deploy accepts one-off token, falls back to remembered CLOUDFLARE_API_TOKEN/ACCOUNT_ID - R2 binding CHAT_STREAMS: per-turn stream + thinking steps archive; D1 unchanged - fix: read endpoints no longer throttled by fixed 4/min limiter binding - verification: docs/verification/env-remember-rounds.md (3 rounds 17/17) --- CHANGELOG.md | 16 ++ cloudflare-env.d.ts | 5 +- docs/reference/config-env.md | 3 + docs/reference/d1-schema.md | 4 + docs/user-guide/customer-deploy.md | 23 +++ docs/verification/env-remember-rounds.md | 19 +++ migrations/0012_user_environment.sql | 12 ++ scripts/build-docs.mjs | 2 +- scripts/verify/env-remember-round.sh | 104 ++++++++++++ src/app/api/auth/github/callback/route.ts | 4 +- src/app/api/connections/[kind]/route.ts | 10 +- src/app/api/environment/[name]/route.ts | 17 ++ src/app/api/environment/route.ts | 40 +++++ src/app/api/github/contents/route.ts | 4 +- src/app/api/github/repos/route.ts | 4 +- src/app/api/projects/[id]/deploy/route.ts | 24 ++- src/app/settings/environment/page.tsx | 18 +++ src/components/agent-panel.tsx | 12 ++ src/components/connections-panel.tsx | 18 ++- src/components/deploy/deploy-status.tsx | 15 +- src/components/environment-panel.tsx | 148 ++++++++++++++++++ src/components/factory/factory-app.tsx | 30 ++-- .../projects/direct-deploy-panel.tsx | 20 ++- src/components/settings-nav.tsx | 1 + src/components/tokens/create-token-menu.tsx | 92 +++++++++++ src/components/tokens/remember-secret.ts | 11 ++ src/components/tokens/remember-toggle.tsx | 33 ++++ src/lib/chat/runtime.ts | 9 +- src/lib/chat/stream-archive.ts | 88 +++++++++++ src/lib/chat/turn-route.ts | 8 +- src/lib/chat/turn.ts | 4 +- src/lib/connections/audit.ts | 4 +- src/lib/connections/credentials.ts | 43 +++++ src/lib/connections/environment.ts | 68 ++++++++ src/lib/connections/route-kit.ts | 8 +- src/lib/connections/token-providers.ts | 54 +++++++ src/lib/deploy/service.ts | 16 +- src/lib/i18n.ts | 1 - src/lib/mcp/server.ts | 6 +- src/lib/server/rate-limit.ts | 4 +- tests/environment.test.ts | 134 ++++++++++++++++ wrangler.jsonc | 9 +- 42 files changed, 1080 insertions(+), 65 deletions(-) create mode 100644 docs/verification/env-remember-rounds.md create mode 100644 migrations/0012_user_environment.sql create mode 100755 scripts/verify/env-remember-round.sh create mode 100644 src/app/api/environment/[name]/route.ts create mode 100644 src/app/api/environment/route.ts create mode 100644 src/app/settings/environment/page.tsx create mode 100644 src/components/environment-panel.tsx create mode 100644 src/components/tokens/create-token-menu.tsx create mode 100644 src/components/tokens/remember-secret.ts create mode 100644 src/components/tokens/remember-toggle.tsx create mode 100644 src/lib/chat/stream-archive.ts create mode 100644 src/lib/connections/environment.ts create mode 100644 src/lib/connections/token-providers.ts create mode 100644 tests/environment.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff52da0..6cedfb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Create token menu** next to every token/key input (Connections, direct deploy, AI agent panel, Sandbox Factory, Environment): links to the official Cloudflare, GitHub fine-grained, OpenAI, Vercel and Hugging Face token pages (URLs verified against provider docs 2026-10-15). Monochrome, keyboard operable, our own component. +- **Remember checkbox** (default off) next to each token input. Off = used once for that request, never persisted. On = sealed with CREDENTIALS_KEY v2 into the new **/settings/environment** page (name + created/updated/last-used only, delete). D1 migration `0012_user_environment.sql`; API `/api/environment`. Direct deploy accepts a one-off Cloudflare token and falls back to remembered `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID`. +- **Chat stream archive to R2** (binding `CHAT_STREAMS`, bucket `nvx-chat-streams`): each turn's streamed reply and thinking steps are written to `chat///.json`. D1 history and the UI are unchanged. +- **CI builds the app** (`npm run build`) in the check job. + +### Changed + +- **CREDENTIALS_KEY moved to Cloudflare Secrets Store** (`secrets_store_secrets` binding, read with `await env.CREDENTIALS_KEY.get()`, cached per isolate). Falls back to a plain Worker secret, then legacy v1. Takes effect at the next deploy; existing v1 rows are re-sealed as v2 on first use. +- `PUT /api/connections/:kind` saves only when `remember: true`; otherwise it verifies and returns without storing. +- Sandbox Factory no longer keeps the OpenAI key in `sessionStorage`. + +### Fixed + +- `/api/connections`, `/api/projects`, `/api/deployments` and `/api/environment` reads were throttled by the fixed 4 / 60 s `NVX_CHAT_LIMITER` binding (deploy-status polling every 3 s returned 429). Reads now use the per-isolate limiter at each route's `perMinute`; writes keep the shared binding. +- Secrets Store `get()` result is wrapped in a native Promise (the dev platform proxy returns a non-native thenable). + - macOS arm64 one-command setup: `curl -fsSL https://devstack.bid/setup.sh | bash -s -- my-app` with a scoped `cli:setup` key from `/settings/cli` (hashed, shown once, revocable); outputs `/` and `-deploy.zip`. New routes `/setup.sh`, `/api/cli/whoami`, `/api/cli/scaffold`, `/api/cli/starter`, `/api/projects/:id/zip`. - One shared deploy-zip builder (`src/lib/deploy/bundle.ts`) for CLI and web "Download zip": wrangler.jsonc per `templates.ts`, `package.json` `deploy` script, `DEPLOY.txt` (env var names only), `NOTICE`; secret files are never included. - Zip upload → deploy in /projects ("Deploy from a zip"): validates bundle structure, size/file limits, paths and zip-slip, strips env files, rejects key files and secret-looking content; stores files in D1 (`project_files`, migration 0011) and deploys with the existing Cloudflare provider; Retry and chat self-heal (`apply_fix`) work for upload projects. diff --git a/cloudflare-env.d.ts b/cloudflare-env.d.ts index f1616a0..e88ccbf 100644 --- a/cloudflare-env.d.ts +++ b/cloudflare-env.d.ts @@ -1,6 +1,7 @@ /* eslint-disable */ // Generated by Wrangler by running `wrangler types --include-runtime=false --env-interface=CloudflareEnv cloudflare-env.d.ts` (hash: 5fdc045203ad32f41cc7a09dcb188c31) interface __BaseEnv_CloudflareEnv { + CHAT_STREAMS: R2Bucket; DB: D1Database; NVX_PLAN_AI_LIMITER: RateLimit; NVX_PLAN_LIMITER: RateLimit; @@ -46,7 +47,7 @@ interface __BaseEnv_CloudflareEnv { FACTORY_TURNS_PER_HOUR: "60"; OPENAI_API_KEY: string; CHAT_COOKIE_SECRET: string; - CREDENTIALS_KEY: string; + CREDENTIALS_KEY: SecretsStoreSecret; HF_TOKEN: string; WORKER_SELF_REFERENCE: Fetcher /* nvx-stack-builder */; } @@ -58,7 +59,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } declare module "*.txt" { const value: string; diff --git a/docs/reference/config-env.md b/docs/reference/config-env.md index 4535022..ea0afd1 100644 --- a/docs/reference/config-env.md +++ b/docs/reference/config-env.md @@ -104,6 +104,9 @@ Studio (`/studio`) ไม่มี secret ของตัวเอง ใช้ | `NVX_FACTORY_LIMITER` | ratelimit (namespace `4714`) | 10 / 60 วินาที | burst ของ `/api/factory/*` | | `NVX_STUDIO_LIMITER` | ratelimit (namespace `4715`) | 20 / 60 วินาที | burst ของ `/api/studio/llm/chat` และ `/api/studio/image` (เพิ่มใน v0.8.0-rc, ยังไม่เคย deploy) | +| `CHAT_STREAMS` | R2 bucket `nvx-chat-streams` | — | เก็บข้อความที่สตรีมและขั้นตอน thinking (ไม่รวม reasoning ดิบ) ของแต่ละ turn เป็น `chat///.json` ข้อมูลเดิมใน D1 ไม่เปลี่ยน ต้องสร้าง bucket ก่อน deploy: `wrangler r2 bucket create nvx-chat-streams` | +| `CREDENTIALS_KEY` | Secrets Store (`default_secrets_store` `b56dddabe7d74172932cf26f4a2ab03f`, secret `CREDENTIALS_KEY`) | 32 ไบต์ base64 | คีย์เข้ารหัส v2 ของ token ลูกค้า (connections, Environment) โค้ดอ่านด้วย `await env.CREDENTIALS_KEY.get()` ถ้าไม่มี binding จะใช้ Worker secret ชื่อเดียวกัน แล้วค่อยถอยไป v1 ไม่มีสำเนาสำรอง (เจ้าของตัดสินใจ 2026-10-15) ถ้าคีย์หาย ลูกค้าต้องผูกบัญชีใหม่ | + `ASSETS_BUCKET` (R2 bucket `nvx-assets`) **ยังไม่ได้เพิ่ม** เพราะบัญชียังไม่เปิด R2 ขั้นตอนอยู่ที่ [d1-database.md](../operations/d1-database.md) อย่าใส่ binding ก่อนมี bucket จริง เพราะ deploy จะล้ม ## การตั้งค่าอื่นใน wrangler.jsonc diff --git a/docs/reference/d1-schema.md b/docs/reference/d1-schema.md index 6637ff5..f0503d7 100644 --- a/docs/reference/d1-schema.md +++ b/docs/reference/d1-schema.md @@ -141,3 +141,7 @@ bucket ของ `usage_counters`: `d:YYYYMMDD` = วันตามเวลา ## ตารางของระบบ `d1_migrations` สร้างโดย wrangler เพื่อจำว่า migration ไหน apply ไปแล้ว ห้ามแก้ด้วยมือ + +## `user_env` (migration 0012) + +Per-user Environment: secrets the user ticked "Remember" for. PK `(user_id, name)`; `name` is UPPER_SNAKE_CASE (2–64); `secret_enc` is sealed with CREDENTIALS_KEY v2 (HKDF info `env:NAME:user_id`); `provider`, `created_at`, `updated_at`, `last_used_at`. At most 50 entries per user. Only name and dates leave the server (`GET /api/environment`). Code: `src/lib/connections/environment.ts`. diff --git a/docs/user-guide/customer-deploy.md b/docs/user-guide/customer-deploy.md index 1e1d7e2..41ea8b7 100644 --- a/docs/user-guide/customer-deploy.md +++ b/docs/user-guide/customer-deploy.md @@ -62,3 +62,26 @@ If a deploy fails, ask Dev Chat to fix it: - The audit log on `/settings/connections` records event types only (for example "credential.saved" or "deploy.live"), never secrets. คีย์ทั้งหมดเข้ารหัสเก็บฝั่งเซิร์ฟเวอร์ แสดงแค่ 4 ตัวท้าย ไม่บันทึกลง log และไม่ส่งให้โมเดล AI ลบได้ทุกเมื่อ และถูกลบทั้งหมดเมื่อลบบัญชี + + +## Create token, Remember and Environment · สร้าง token, จำไว้ และ Environment + +Every place that asks for a token or key (Connections, the direct-deploy panel, the AI agent panel, Sandbox Factory, Environment) has: + +- **Create token**: a menu of links to each provider's official token page. Verified against each provider's docs on 2026-10-15: + + | Provider | Page | Source | + |---|---|---| + | Cloudflare API token | https://dash.cloudflare.com/profile/api-tokens | developers.cloudflare.com/fundamentals/api/get-started/create-token/ | + | GitHub fine-grained token | https://github.com/settings/personal-access-tokens/new | docs.github.com, Managing your personal access tokens | + | OpenAI API key | https://platform.openai.com/api-keys | platform.openai.com/docs/quickstart | + | Vercel token | https://vercel.com/account/tokens | vercel.com/docs/rest-api | + | Hugging Face access token | https://huggingface.co/settings/tokens | huggingface.co/docs/hub/security-tokens | + +- **Remember** (off by default). Off: the token is used once for that request (verify, deploy, plan) and never stored. On: it is encrypted with `CREDENTIALS_KEY` v2 and kept in **/settings/environment**, which shows only the name, created, updated and last-used dates, with a delete button. Values never come back to the browser. + +ช่องใส่ token ทุกที่มีปุ่ม **สร้าง token** (ลิงก์ไปหน้าทางการของผู้ให้บริการ) และช่อง **จำไว้** ที่ปิดไว้ตั้งต้น ถ้าไม่ติ๊ก token ใช้ครั้งเดียวแล้วทิ้ง ถ้าติ๊ก จะเก็บแบบเข้ารหัสในหน้า Environment ซึ่งแสดงแค่ชื่อกับวันที่ และลบได้ + +The chat bot and deploys use remembered keys server-side: a direct deploy uses the Cloudflare connection, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` from Environment when there is no connection. + +The Sandbox Factory key used to be kept in `sessionStorage` when "Remember" was ticked. It is no longer stored in the browser; any old copy is removed on load. diff --git a/docs/verification/env-remember-rounds.md b/docs/verification/env-remember-rounds.md new file mode 100644 index 0000000..f322d4f --- /dev/null +++ b/docs/verification/env-remember-rounds.md @@ -0,0 +1,19 @@ +# Secrets Store key, Remember/Environment, R2 chat archive: verification rounds (2026-10-15, local only) + +Setup: `next dev` on 127.0.0.1:3100 (Node 22, OpenNext dev platform proxy) with local D1 migrations 0001–0012, a **local** Secrets Store secret `CREDENTIALS_KEY` (random, piped to `wrangler secrets-store secret create` without `--remote`, never printed), local R2 bucket `nvx-chat-streams`, and `scripts/verify/mock-cloudflare.mjs` on 127.0.0.1:18790 (`DEVSTACK_DEV_CF_API_BASE`, dev only). Runner: `scripts/verify/env-remember-round.sh `. No production deploy, no remote migration, no real customer token. The chat turn used Workers AI (remote binding, one short message per round). + +Each round (17 checks): register → `GET /api/environment` empty with `encryption: v2` (key read from the Secrets Store binding) → Connections PUT with Remember off returns `remembered:false` and stores nothing → upload zip → deploy with no token is 401 → deploy with a one-off token goes live and the token is not stored or echoed → save `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` to Environment; bad name 400; list shows names/dates only (no value, no ciphertext) → deploy with no token uses the remembered entries and stamps `lastUsedAt` → D1 rows are `v2.` ciphertext without plaintext → Connections PUT with Remember on saves and appears as a connection → deletes clear everything → cross-site POST 403, anonymous GET 401 → one chat turn: R2 object `chat///.json` exists (meta, status, delta, text, usage, done) and D1 still has both messages. + +| Round | Result | Notes | +| --- | --- | --- | +| 0 (dry start) | 3/17 | **Bug found and fixed:** under the dev platform proxy `env.CREDENTIALS_KEY.get()` returned a non-native thenable (`.then is not a function`); the read is now wrapped in `Promise.resolve().then(...)`. Runner mistake: mock base lacked `/client/v4`, so the fake token went to the real API and was rejected. | +| 0b | 12/17 | **Bug found and fixed (pre-existing since PR-A):** `gate()` passed `perMinute` but the Workers rate-limit binding `NVX_CHAT_LIMITER` is fixed at 4 / 60 s per key, so page loads and the 3-second deploy-status polling hit 429 quickly. Reads now use the per-isolate limiter at their `perMinute`; writes keep the shared 4/min binding. | +| 1 | **17/17** | — | +| 2 | **17/17** | — | +| 3 | **17/17** | — | + +Also (Node 20): eslint, `tsc --noEmit`, `npm test` (incl. `tests/environment.test.ts`: Secrets Store read/caching/fallback, Environment seal/list/load/delete and user+name binding, provider URLs, R2 recorder merge/drop rules), `npm run build`, `docs:check`. + +Token page URLs checked against provider docs on 2026-10-15: Cloudflare (developers.cloudflare.com create-token page links `dash.cloudflare.com/profile/api-tokens`), GitHub (docs link `github.com/settings/personal-access-tokens/new`), OpenAI (quickstart links `platform.openai.com/api-keys`), Vercel (REST API docs link `vercel.com/account/tokens`, which redirects to `/account/settings/tokens`), Hugging Face (security-tokens docs link `huggingface.co/settings/tokens`). + +Not covered: the production Secrets Store binding (goes live at the next deploy), a real R2 bucket (R2 API still answered "enable R2" for the account on 2026-10-15 20:47 ICT), UI clicked in a real browser (pages return 200; flows exercised through the API). diff --git a/migrations/0012_user_environment.sql b/migrations/0012_user_environment.sql new file mode 100644 index 0000000..0ad8bfa --- /dev/null +++ b/migrations/0012_user_environment.sql @@ -0,0 +1,12 @@ +-- Per-user Environment: secrets the customer chose to "Remember" (default off). Values are sealed with +-- CREDENTIALS_KEY v2 (HKDF info `env:NAME:user_id`); only name and dates are ever shown. +CREATE TABLE user_env ( + user_id TEXT NOT NULL, + name TEXT NOT NULL, + secret_enc TEXT NOT NULL, + provider TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch('subsec') * 1000), + updated_at INTEGER NOT NULL DEFAULT (unixepoch('subsec') * 1000), + last_used_at INTEGER, + PRIMARY KEY (user_id, name) +) STRICT; diff --git a/scripts/build-docs.mjs b/scripts/build-docs.mjs index ec8ece2..49ffd75 100644 --- a/scripts/build-docs.mjs +++ b/scripts/build-docs.mjs @@ -60,7 +60,7 @@ export const SECTIONS = [ { id: "operations", label: { th: "การดูแลระบบ", en: "Operations" }, - files: [...["local-dev", "env-secrets", "deploy-cloudflare", "rollback", "monitoring", "rate-limits-cost", "custom-domain", "d1-database"].map((f) => `operations/${f}.md`), "DEPLOY.md", "deploy-templates.md", "limits.md", "cli-setup.md", "verification/cli-zip-rounds.md"], + files: [...["local-dev", "env-secrets", "deploy-cloudflare", "rollback", "monitoring", "rate-limits-cost", "custom-domain", "d1-database"].map((f) => `operations/${f}.md`), "DEPLOY.md", "deploy-templates.md", "limits.md", "cli-setup.md", "verification/cli-zip-rounds.md", "verification/env-remember-rounds.md"], }, { id: "contributing", diff --git a/scripts/verify/env-remember-round.sh b/scripts/verify/env-remember-round.sh new file mode 100755 index 0000000..fbc926d --- /dev/null +++ b/scripts/verify/env-remember-round.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Local verification round: Secrets Store CREDENTIALS_KEY, Remember/Environment, one-off deploy token, R2 chat archive. +# Needs: next dev on $BASE (local D1 0001-0012, local Secrets Store CREDENTIALS_KEY), scripts/verify/mock-cloudflare.mjs on :18790. +# shellcheck disable=SC2016 +set -u +ROUND="${1:?round number}" +BASE="${BASE:-http://127.0.0.1:3100}" +W="$(mktemp -d)" +J="$W/cookies" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf 'PASS %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$1"; } +check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; } +api() { curl -sS -b "$J" -c "$J" -H "Origin: $BASE" "$@"; } +jget() { python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(eval(sys.argv[2],{"d":d}))' "$@" 2>/dev/null; } +d1() { npx wrangler d1 execute nvx-db --local --json --command "$1" 2>/dev/null; } + +email="env${ROUND}-$(date +%s)@example.test" +pw="Verify-$(openssl rand -hex 8)-Aa1!" +loc=$(curl -sS -c "$J" -H "Origin: $BASE" -o /dev/null -w '%{http_code} %{redirect_url}' -X POST --data-urlencode "action=register" --data-urlencode "email=$email" --data-urlencode "password=$pw" "$BASE/api/auth/password") +check "register account" 'case "$loc" in "303 "*auth=*) false;; "303 "*) true;; *) false;; esac' + +api -o "$W/env0.json" "$BASE/api/environment" +check "environment empty, encryption v2 (key from Secrets Store binding)" '[ "$(jget "$W/env0.json" "len(d[\"env\"])+len(d[\"connections\"])")" = 0 ] && [ "$(jget "$W/env0.json" "d[\"encryption\"]")" = v2 ]' + +TOKEN="mocktoken$(openssl rand -hex 20)" +ACCT="$(openssl rand -hex 16)" +code=$(api -o "$W/c1.json" -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data "{\"token\":\"$TOKEN\",\"accountId\":\"$ACCT\"}" "$BASE/api/connections/cloudflare") +check "connections PUT, Remember off: verified, remembered=false" '[ "$code" = 200 ] && [ "$(jget "$W/c1.json" "d[\"remembered\"]")" = False ]' +api -o "$W/env1.json" "$BASE/api/environment" +check "Remember off persisted nothing" '[ "$(jget "$W/env1.json" "len(d[\"env\"])+len(d[\"connections\"])")" = 0 ]' + +mkdir -p "$W/s/site-r$ROUND"; printf '

r%s

\n' "$ROUND" > "$W/s/site-r$ROUND/index.html" +python3 -c 'import os,sys,zipfile +with zipfile.ZipFile(sys.argv[1],"w") as z: + z.write(sys.argv[2]+"/index.html", os.path.basename(sys.argv[2])+"/index.html")' "$W/site.zip" "$W/s/site-r$ROUND" +api -o "$W/up.json" -X POST -F "file=@$W/site.zip;type=application/zip" "$BASE/api/projects/upload" +PID=$(jget "$W/up.json" 'd["project"]["id"]') +check "upload zip creates project" '[ -n "$PID" ]' + +wait_deploy() { + local i st + for i in $(seq 1 30); do + api "$BASE/api/projects/$1/deploy" > "$W/deps.json" + st=$(jget "$W/deps.json" 'd["deployments"][0]["status"]') + case "$st" in live|failed) printf '%s' "$st"; return;; esac + sleep 1 + done + printf 'timeout' +} +code=$(api -o "$W/d0.json" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{}' "$BASE/api/projects/$PID/deploy") +check "deploy without any token -> 401 reauth" '[ "$code" = 401 ]' + +code=$(api -o "$W/d1.json" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data "{\"token\":\"$TOKEN\",\"accountId\":\"$ACCT\"}" "$BASE/api/projects/$PID/deploy") +st=$(wait_deploy "$PID") +check "one-off token deploy (Remember off) goes live" '[ "$code" = 202 ] && [ "$st" = live ]' +api -o "$W/env2.json" "$BASE/api/environment" +check "one-off token not persisted" '[ "$(jget "$W/env2.json" "len(d[\"env\"])+len(d[\"connections\"])")" = 0 ] && ! grep -q "$TOKEN" "$W/deps.json"' + +api -o /dev/null -X POST -H 'Content-Type: application/json' --data "{\"name\":\"CLOUDFLARE_API_TOKEN\",\"value\":\"$TOKEN\",\"provider\":\"cloudflare\"}" "$BASE/api/environment" +api -o /dev/null -X POST -H 'Content-Type: application/json' --data "{\"name\":\"CLOUDFLARE_ACCOUNT_ID\",\"value\":\"$ACCT\"}" "$BASE/api/environment" +code=$(api -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"name":"bad name","value":"x"}' "$BASE/api/environment") +check "invalid env name rejected" '[ "$code" = 400 ]' +api -o "$W/env3.json" "$BASE/api/environment" +check "Environment lists names + dates, never values" '[ "$(jget "$W/env3.json" "sorted(e[\"name\"] for e in d[\"env\"])")" = "[%sCLOUDFLARE_ACCOUNT_ID%s, %sCLOUDFLARE_API_TOKEN%s]" ] 2>/dev/null || [ "$(jget "$W/env3.json" "\",\".join(sorted(e[\"name\"] for e in d[\"env\"]))")" = "CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN" ] && ! grep -q "$TOKEN" "$W/env3.json" && ! grep -q "$ACCT" "$W/env3.json" && ! grep -q "secret_enc\|v2\." "$W/env3.json"' + +sleep 1 +api -o /dev/null -X POST -H 'Content-Type: application/json' --data '{}' "$BASE/api/projects/$PID/deploy" +st=$(wait_deploy "$PID") +api -o "$W/env4.json" "$BASE/api/environment" +check "deploy uses remembered Environment token; lastUsedAt stamped" '[ "$st" = live ] && [ "$(jget "$W/env4.json" "all(e[\"lastUsedAt\"] for e in d[\"env\"])")" = True ]' + +UID_=$(d1 "SELECT user_id FROM user_env WHERE name='CLOUDFLARE_API_TOKEN' ORDER BY created_at DESC LIMIT 1" | jget /dev/stdin 'd[0]["results"][0]["user_id"]') +d1 "SELECT secret_enc FROM user_env WHERE user_id='$UID_'" > "$W/rows.json" +check "D1 rows are v2 ciphertext, no plaintext" 'grep -q "\"v2\." "$W/rows.json" && ! grep -q "$TOKEN" "$W/rows.json" && ! grep -q "$ACCT" "$W/rows.json"' + +code=$(api -o "$W/c2.json" -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data "{\"token\":\"$TOKEN\",\"accountId\":\"$ACCT\",\"remember\":true}" "$BASE/api/connections/cloudflare") +api -o "$W/env5.json" "$BASE/api/environment" +check "Remember on (connections) saves; shows as connection" '[ "$code" = 200 ] && [ "$(jget "$W/env5.json" "[c[\"kind\"] for c in d[\"connections\"]]")" = "[%s]" ] 2>/dev/null || [ "$(jget "$W/env5.json" "\",\".join(c[\"kind\"] for c in d[\"connections\"])")" = cloudflare ]' + +api -o /dev/null -X DELETE "$BASE/api/environment/CLOUDFLARE_API_TOKEN" +api -o /dev/null -X DELETE "$BASE/api/environment/CLOUDFLARE_ACCOUNT_ID" +api -o /dev/null -X DELETE "$BASE/api/connections/cloudflare" +api -o "$W/env6.json" "$BASE/api/environment" +check "delete removes entries and connection" '[ "$(jget "$W/env6.json" "len(d[\"env\"])+len(d[\"connections\"])")" = 0 ]' + +code=$(curl -sS -o /dev/null -w '%{http_code}' -H "Origin: https://evil.example" -X POST -H 'Content-Type: application/json' -b "$J" --data '{"name":"X_TOKEN","value":"y"}' "$BASE/api/environment") +check "cross-site POST blocked" '[ "$code" = 403 ]' +code=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/api/environment") +check "anonymous GET 401" '[ "$code" = 401 ]' + +api -o "$W/turn.txt" -X POST -H 'Content-Type: application/json' --data "{\"message\":\"hello round $ROUND\",\"mode\":\"general\"}" "$BASE/api/chat/turn" +CID=$(sed -n 's/.*"t":"meta","conversationId":"\([^"]*\)","messageId":"\([^"]*\)".*/\1/p' "$W/turn.txt" | head -1) +MID=$(sed -n 's/.*"t":"meta","conversationId":"\([^"]*\)","messageId":"\([^"]*\)".*/\2/p' "$W/turn.txt" | head -1) +UID2=$(d1 "SELECT user_id FROM conversations WHERE id='$CID'" | jget /dev/stdin 'd[0]["results"][0]["user_id"]') +sleep 2 +npx wrangler r2 object get "nvx-chat-streams/chat/$UID2/$CID/$MID.json" --local --pipe > "$W/arch.json" 2>/dev/null +MSGS=$(d1 "SELECT COUNT(*) AS n FROM messages WHERE conversation_id='$CID'" | jget /dev/stdin 'd[0]["results"][0]["n"]') +check "chat turn archived to R2 (meta first), D1 messages intact" '[ -n "$CID" ] && [ "$(jget "$W/arch.json" "d[\"events\"][0][\"t\"]")" = meta ] && [ "$MSGS" = 2 ]' +printf 'events: %s\n' "$(jget "$W/arch.json" '[e["t"] for e in d["events"]]')" + +printf '\nround %s: %s passed, %s failed\n' "$ROUND" "$PASS" "$FAIL" +[ "$FAIL" = 0 ] && rm -rf "$W" || printf "kept %s\n" "$W" +[ "$FAIL" = 0 ] diff --git a/src/app/api/auth/github/callback/route.ts b/src/app/api/auth/github/callback/route.ts index ed82a59..101546e 100644 --- a/src/app/api/auth/github/callback/route.ts +++ b/src/app/api/auth/github/callback/route.ts @@ -4,7 +4,7 @@ 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"; +import { seal, resolveSealKeys } from "@/lib/connections/credentials"; import { chatEnv } from "@/lib/chat/config"; import { OAUTH_STATE_COOKIE, readCookie } from "@/lib/chat/identity"; import { chatRuntime } from "@/lib/chat/runtime"; @@ -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: grantedScopes(scope, GITHUB_SCOPES) } + { tokenEnc: await seal(await resolveSealKeys(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/connections/[kind]/route.ts b/src/app/api/connections/[kind]/route.ts index 683d2bc..d3d34ea 100644 --- a/src/app/api/connections/[kind]/route.ts +++ b/src/app/api/connections/[kind]/route.ts @@ -10,14 +10,14 @@ import { HttpError, readJsonBody } from "@/lib/server/http"; type Ctx = { params: Promise<{ kind: string }> }; const SAVABLE = new Set(["cloudflare", "openai"]); -/** PUT /api/connections/cloudflare {token, accountId} | /api/connections/openai {key}: validate with the provider, seal, save. */ +/** PUT /api/connections/cloudflare {token, accountId, remember?} | /api/connections/openai {key, remember?}: validate with the provider; seal + save only when remember === true. */ export async function PUT(req: Request, ctx: Ctx) { await connection(); const { kind } = await ctx.params; if (!SAVABLE.has(kind as CredentialKind)) return json({ error: "Unknown connection. / ไม่รู้จักการเชื่อมต่อนี้", code: "not_found" }, 404); const a = await gate(req, { write: true, burstKey: "conn-put", perMinute: 6 }); if (isResponse(a)) return a; - let body: { token?: unknown; accountId?: unknown; key?: unknown }; + let body: { token?: unknown; accountId?: unknown; key?: unknown; remember?: unknown }; try { body = ((await readJsonBody(req, 4_000)) ?? {}) as typeof body; } catch (err) { @@ -27,10 +27,14 @@ export async function PUT(req: Request, ctx: Ctx) { const accountId = String(body.accountId ?? "").trim().toLowerCase(); const result = kind === "cloudflare" ? await validateCloudflare(secret, accountId) : await validateOpenAI(secret); if (!result.ok) return json({ error: ERROR_TEXT[result.code], code: result.code }, result.code === "provider_unavailable" ? 502 : 400); + if (body.remember !== true) { + // "Remember" off (default): verified for this request only, nothing persisted. + return json({ ok: true, remembered: false, meta: { accountId: result.meta.account_id ?? null, label: result.meta.label ?? null } }); + } await saveCredential(a.db, a.keys, a.uid, kind as CredentialKind, secret, result.meta); await audit(a.db, a.uid, "credential.saved", kind); console.warn(JSON.stringify({ at: "api/connections", event: "saved", kind })); - return json({ ok: true }); + return json({ ok: true, remembered: true }); } /** DELETE /api/connections/:kind — removes the row; for GitHub also revokes the OAuth grant. */ diff --git a/src/app/api/environment/[name]/route.ts b/src/app/api/environment/[name]/route.ts new file mode 100644 index 0000000..91a771c --- /dev/null +++ b/src/app/api/environment/[name]/route.ts @@ -0,0 +1,17 @@ +import { connection } from "next/server"; +import { audit } from "@/lib/connections/audit"; +import { deleteEnv, validEnvName } from "@/lib/connections/environment"; +import { gate, isResponse } from "@/lib/connections/route-kit"; +import { json } from "@/lib/chat/runtime"; + +/** DELETE /api/environment/:name */ +export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) { + await connection(); + const { name } = await ctx.params; + if (!validEnvName(name)) return json({ error: "Not found. / ไม่พบ", code: "not_found" }, 404); + const a = await gate(req, { write: true, burstKey: "env-del", perMinute: 20 }); + if (isResponse(a)) return a; + await deleteEnv(a.db, a.uid, name); + await audit(a.db, a.uid, "env.removed", name); + return json({ ok: true }); +} diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts new file mode 100644 index 0000000..78e5444 --- /dev/null +++ b/src/app/api/environment/route.ts @@ -0,0 +1,40 @@ +import { connection } from "next/server"; +import { audit } from "@/lib/connections/audit"; +import { listCredentials } from "@/lib/connections/credentials"; +import { listEnv, saveEnv, validEnvName } from "@/lib/connections/environment"; +import { gate, isResponse } from "@/lib/connections/route-kit"; +import { json } from "@/lib/chat/runtime"; +import { HttpError, readJsonBody } from "@/lib/server/http"; + +/** GET /api/environment — names and dates only (never values, never ciphertext). */ +export async function GET(req: Request) { + await connection(); + const a = await gate(req, { write: false, burstKey: "env-list", perMinute: 60 }); + if (isResponse(a)) return a; + const [env, conns] = await Promise.all([listEnv(a.db, a.uid).catch(() => []), listCredentials(a.db, a.uid).catch(() => [])]); + return json({ env, connections: conns.map((c) => ({ kind: c.kind, label: c.label, verifiedAt: c.verifiedAt })), encryption: a.keys.credentialsKey ? "v2" : "v1" }); +} + +/** POST /api/environment {name, value, provider?} — only called when the user ticked "Remember". */ +export async function POST(req: Request) { + await connection(); + const a = await gate(req, { write: true, burstKey: "env-put", perMinute: 10 }); + if (isResponse(a)) return a; + let body: { name?: unknown; value?: unknown; provider?: unknown }; + try { + body = ((await readJsonBody(req, 6_000)) ?? {}) as typeof body; + } catch (err) { + return json({ error: err instanceof HttpError ? err.message : "Bad request", code: "invalid" }, err instanceof HttpError ? err.status : 400); + } + if (!validEnvName(body.name)) return json({ error: "Name must be UPPER_SNAKE_CASE (2-64). / ชื่อต้องเป็นตัวพิมพ์ใหญ่และ _", code: "invalid" }, 400); + const value = typeof body.value === "string" ? body.value.trim() : ""; + const provider = typeof body.provider === "string" ? body.provider.slice(0, 32) : null; + try { + await saveEnv(a.db, a.keys, a.uid, body.name, value, provider); + } catch (err) { + const code = (err as Error).message; + return json({ error: code === "too_many" ? "Environment is full (50). / เต็มแล้ว (50)" : "Invalid value. / ค่าไม่ถูกต้อง", code }, 400); + } + await audit(a.db, a.uid, "env.saved", body.name); + return json({ ok: true }); +} diff --git a/src/app/api/github/contents/route.ts b/src/app/api/github/contents/route.ts index 2d4b949..05ec3b6 100644 --- a/src/app/api/github/contents/route.ts +++ b/src/app/api/github/contents/route.ts @@ -5,7 +5,7 @@ import { MAX_FILE_CHARS, parseRepo, safeBranch, safeCommitMessage, safeRepoPath, import { chatEnv } from "@/lib/chat/config"; import { chatRuntime, json } from "@/lib/chat/runtime"; -import { loadGithubToken, sealKeys } from "@/lib/connections/credentials"; +import { loadGithubToken, resolveSealKeys } from "@/lib/connections/credentials"; import { HttpError, clientIp, readJsonBody } from "@/lib/server/http"; import { checkRateLimit } from "@/lib/server/rate-limit"; @@ -41,7 +41,7 @@ export async function POST(req: Request) { } const { db } = await chatRuntime(); if (!db) return json({ error: "Storage unavailable. / ระบบจัดเก็บไม่พร้อม", code: "no_database" }, 503); - const token = await loadGithubToken(db, sealKeys(env.CHAT_COOKIE_SECRET), actor.uid); + const token = await loadGithubToken(db, await resolveSealKeys(env.CHAT_COOKIE_SECRET), actor.uid); if (!token) return json({ error: "Sign in with GitHub again to push code. / เข้าสู่ระบบ GitHub อีกครั้งเพื่อส่งโค้ด", code: "reauth" }, 401); try { const saved = await putFile(token, { owner: repo.owner, repo: repo.name, path, message, content, branch }); diff --git a/src/app/api/github/repos/route.ts b/src/app/api/github/repos/route.ts index 6a569d4..b2ae979 100644 --- a/src/app/api/github/repos/route.ts +++ b/src/app/api/github/repos/route.ts @@ -4,7 +4,7 @@ import { GithubApiError, listWritableRepos } from "@/lib/auth/github-api"; import { chatEnv } from "@/lib/chat/config"; import { chatRuntime, json } from "@/lib/chat/runtime"; -import { loadGithubToken, sealKeys } from "@/lib/connections/credentials"; +import { loadGithubToken, resolveSealKeys } from "@/lib/connections/credentials"; import { clientIp } from "@/lib/server/http"; import { checkRateLimit } from "@/lib/server/rate-limit"; @@ -19,7 +19,7 @@ export async function GET(req: Request) { if (!actor.authenticated || !actor.uid) return json({ error: "Sign in with GitHub first. / เข้าสู่ระบบด้วย GitHub ก่อน", code: "unauthorized" }, 401); const { db } = await chatRuntime(); if (!db) return json({ error: "Storage unavailable. / ระบบจัดเก็บไม่พร้อม", code: "no_database" }, 503); - const token = await loadGithubToken(db, sealKeys(env.CHAT_COOKIE_SECRET), actor.uid); + const token = await loadGithubToken(db, await resolveSealKeys(env.CHAT_COOKIE_SECRET), actor.uid); if (!token) return json({ error: "Sign in with GitHub again to push code. / เข้าสู่ระบบ GitHub อีกครั้งเพื่อส่งโค้ด", code: "reauth" }, 401); try { const repos = await listWritableRepos(token); diff --git a/src/app/api/projects/[id]/deploy/route.ts b/src/app/api/projects/[id]/deploy/route.ts index 26b8f30..ee821f3 100644 --- a/src/app/api/projects/[id]/deploy/route.ts +++ b/src/app/api/projects/[id]/deploy/route.ts @@ -2,7 +2,10 @@ import { connection } from "next/server"; import { chatRuntime, json } from "@/lib/chat/runtime"; import { serviceErrorBody } from "@/lib/connections/github-service"; import { getProject } from "@/lib/connections/projects"; -import { gate, isResponse } from "@/lib/connections/route-kit"; +import { audit } from "@/lib/connections/audit"; +import { saveCredential, type CredentialMeta } from "@/lib/connections/credentials"; +import { ERROR_TEXT, gate, isResponse } from "@/lib/connections/route-kit"; +import { validateCloudflare } from "@/lib/connections/validate"; import { deploymentView, listDeployments, startDeploy } from "@/lib/deploy/service"; import { HttpError, readJsonBody } from "@/lib/server/http"; @@ -18,22 +21,33 @@ export async function GET(req: Request, ctx: Ctx) { return json({ deployments: (await listDeployments(a.db, a.uid, p.id)).map(deploymentView) }, 200, { "Cache-Control": "no-store" }); } -/** POST /api/projects/:id/deploy {ref?} — direct deploy into the customer's Cloudflare account (capacity-derived daily quota). Always a user click. */ +/** POST /api/projects/:id/deploy {ref?, token?, accountId?, remember?} — direct deploy into the customer's Cloudflare account (capacity-derived daily quota). Always a user click. */ export async function POST(req: Request, ctx: Ctx) { await connection(); const a = await gate(req, { write: true, burstKey: "dep-new", perMinute: 4 }); if (isResponse(a)) return a; const p = await getProject(a.db, a.uid, (await ctx.params).id); if (!p) return json({ error: "Not found. / ไม่พบโปรเจกต์", code: "not_found" }, 404); - let body: { ref?: unknown }; + let body: { ref?: unknown; token?: unknown; accountId?: unknown; remember?: unknown }; try { - body = ((await readJsonBody(req, 2_000)) ?? {}) as typeof body; + body = ((await readJsonBody(req, 4_000)) ?? {}) as typeof body; } catch (err) { return json({ error: err instanceof HttpError ? err.message : "Bad request", code: "invalid" }, err instanceof HttpError ? err.status : 400); } + let once: { secret: string; meta: CredentialMeta } | null = null; + if (typeof body.token === "string" && body.token.trim()) { + const secret = body.token.trim(); + const v = await validateCloudflare(secret, String(body.accountId ?? "").trim().toLowerCase()); + if (!v.ok) return json({ error: ERROR_TEXT[v.code], code: v.code }, v.code === "provider_unavailable" ? 502 : 400); + if (body.remember === true) { + await saveCredential(a.db, a.keys, a.uid, "cloudflare", secret, v.meta); + await audit(a.db, a.uid, "credential.saved", "cloudflare"); + } + once = { secret, meta: v.meta }; + } const { waitUntil } = await chatRuntime(); try { - const d = await startDeploy(a.db, a.keys, a.uid, p, typeof body.ref === "string" ? body.ref : null, waitUntil); + const d = await startDeploy(a.db, a.keys, a.uid, p, typeof body.ref === "string" ? body.ref : null, waitUntil, fetch, once); return json({ ok: true, deployment: deploymentView(d) }, 202); } catch (err) { const e = serviceErrorBody(err); diff --git a/src/app/settings/environment/page.tsx b/src/app/settings/environment/page.tsx new file mode 100644 index 0000000..e45c033 --- /dev/null +++ b/src/app/settings/environment/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { EnvironmentPanel } from "@/components/environment-panel"; +import { SettingsNav } from "@/components/settings-nav"; + +export const metadata: Metadata = { + title: "Environment", + description: "Secrets you chose to remember. Names and dates only; values are never shown.", + robots: { index: false }, +}; + +export default function EnvironmentSettingsPage() { + return ( + <> + + + + ); +} diff --git a/src/components/agent-panel.tsx b/src/components/agent-panel.tsx index 08355a8..63ae2b6 100644 --- a/src/components/agent-panel.tsx +++ b/src/components/agent-panel.tsx @@ -4,6 +4,9 @@ import { useEffect, useMemo, useState } from "react"; import { getTemplate } from "@/data/templates"; import type { AgentPlan } from "@/lib/ai/types"; import { applyEdits, composeSteps, configForTemplate, flattenCommands } from "@/lib/composer"; +import { CreateTokenMenu } from "@/components/tokens/create-token-menu"; +import { RememberToggle } from "@/components/tokens/remember-toggle"; +import { rememberSecret } from "@/components/tokens/remember-secret"; import { Badge, Button, Card, Checkbox, EmptyState, Input, Skeleton, Textarea } from "@/components/ui"; import { CodeBlock } from "./copy-button"; import { useLang } from "./prefs"; @@ -37,6 +40,7 @@ export function AgentPanel({ onLoad }: { onLoad: (plan: AgentPlan) => void }) { const [error, setError] = useState(""); const [useOwnKey, setUseOwnKey] = useState(false); const [apiKey, setApiKey] = useState(""); + const [rememberKey, setRememberKey] = useState(false); const [lastMode, setLastMode] = useState<"auto" | "fallback">("auto"); const [server, setServer] = useState<{ serverKeyConfigured: boolean; model: string } | null>(null); @@ -63,6 +67,10 @@ export function AgentPanel({ onLoad }: { onLoad: (plan: AgentPlan) => void }) { const data = await res.json(); if (!res.ok) throw new Error(data.error ?? t("agent.error")); setPlan(data as AgentPlan); + if (mode === "auto" && useOwnKey && rememberKey && apiKey.trim()) { + const saved = await rememberSecret("OPENAI_API_KEY", apiKey.trim(), "openai"); + if (!saved.ok) setError(saved.error ?? ""); + } requestAnimationFrame(() => document.getElementById("agent-result")?.focus({ preventScroll: false })); } catch (e) { setError((e as Error).message || t("agent.error")); @@ -150,6 +158,10 @@ export function AgentPanel({ onLoad }: { onLoad: (plan: AgentPlan) => void }) { value={apiKey} onChange={(e) => setApiKey(e.target.value)} /> +
+ + +
)}
diff --git a/src/components/connections-panel.tsx b/src/components/connections-panel.tsx index cb63c5e..67ecc74 100644 --- a/src/components/connections-panel.tsx +++ b/src/components/connections-panel.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react"; import { Cloud, GitBranch, KeyRound, ShieldCheck } from "lucide-react"; import { buttonClasses, Card } from "@/components/ui"; import { useLang } from "@/components/prefs"; +import { CreateTokenMenu } from "@/components/tokens/create-token-menu"; +import { RememberToggle } from "@/components/tokens/remember-toggle"; type Conn = { kind: string; label: string; masked: string; last4: string; scopes: string[]; accountId?: string; verifiedAt: number | null }; @@ -24,6 +26,8 @@ export function ConnectionsPanel() { const [busy, setBusy] = useState(""); const [cf, setCf] = useState({ token: "", accountId: "" }); const [oai, setOai] = useState(""); + const [rememberCf, setRememberCf] = useState(false); + const [rememberOai, setRememberOai] = useState(false); const [events, setEvents] = useState<{ event: string; subject: string | null; at: number }[]>([]); const [showAudit, setShowAudit] = useState(false); @@ -54,7 +58,7 @@ export function ConnectionsPanel() { const r = await fetch(`/api/connections/${kind}`, { method, headers: body ? { "Content-Type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined }); const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.error ?? "Request failed"); - setNotice(method === "PUT" ? (th ? "ตรวจสอบและบันทึกแล้ว" : "Verified and saved") : (th ? "ยกเลิกการเชื่อมต่อแล้ว" : "Disconnected")); + setNotice(method === "DELETE" ? (th ? "ยกเลิกการเชื่อมต่อแล้ว" : "Disconnected") : b.remembered ? (th ? "ตรวจสอบและบันทึกแล้ว" : "Verified and saved") : (th ? "ตรวจสอบแล้ว ไม่ได้บันทึก (ติ๊ก “จำไว้” เพื่อเก็บ)" : "Verified, not saved (tick “Remember” to keep it)")); await refresh(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -100,11 +104,13 @@ export function ConnectionsPanel() { } title="Cloudflare" status={cfc ? `${cfc.label} · ${cfc.accountId ?? ""} · ${cfc.masked}` : th ? "ยังไม่เชื่อมต่อ" : "Not connected"} /> -
{ e.preventDefault(); call("cloudflare", "PUT", cf); }}> + { e.preventDefault(); call("cloudflare", "PUT", { ...cf, remember: rememberCf }); }}>
- + + + {cfc && } {th ? "วิธีสร้าง token (Workers Scripts: Edit + Account Settings: Read)" : "How to create a token (Workers Scripts: Edit + Account Settings: Read)"}
@@ -113,9 +119,11 @@ export function ConnectionsPanel() { } title="OpenAI" status={oc ? oc.masked : th ? "ไม่บังคับ" : "Optional"} /> - { e.preventDefault(); call("openai", "PUT", { key: oai }); }}> + { e.preventDefault(); call("openai", "PUT", { key: oai, remember: rememberOai }); }}> - + + + {oc && }
diff --git a/src/components/deploy/deploy-status.tsx b/src/components/deploy/deploy-status.tsx index 7fcaaac..7221703 100644 --- a/src/components/deploy/deploy-status.tsx +++ b/src/components/deploy/deploy-status.tsx @@ -62,19 +62,26 @@ export function LiveUrl({ url }: { url: string }) { } /** Start a deploy (always an explicit user click). Returns the new deployment id. */ -export async function requestDeploy(projectId: string, ref: string | null): Promise { - const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deploy`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(ref ? { ref } : {}) }); +export interface OnceToken { + token: string; + accountId: string; + remember: boolean; +} + +export async function requestDeploy(projectId: string, ref: string | null, once?: OnceToken | null): Promise { + const body = { ...(ref ? { ref } : {}), ...(once?.token ? once : {}) }; + const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deploy`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.error ?? "Deploy failed to start"); return b.deployment.id as string; } -export function RetryButton({ projectId, refName, onStarted, label = "Retry deploy" }: { projectId: string; refName: string | null; onStarted: (id: string) => void; label?: string }) { +export function RetryButton({ projectId, refName, onStarted, label = "Retry deploy", once }: { projectId: string; refName: string | null; onStarted: (id: string) => void; label?: string; once?: OnceToken | null }) { const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); async function go() { setBusy(true); setErr(""); - try { onStarted(await requestDeploy(projectId, refName)); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } + try { onStarted(await requestDeploy(projectId, refName, once)); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } } return ( diff --git a/src/components/environment-panel.tsx b/src/components/environment-panel.tsx new file mode 100644 index 0000000..e9650d3 --- /dev/null +++ b/src/components/environment-panel.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { ShieldCheck, Trash2 } from "lucide-react"; +import { buttonClasses, Card } from "@/components/ui"; +import { useLang } from "@/components/prefs"; +import { CreateTokenMenu } from "@/components/tokens/create-token-menu"; + +type EnvEntry = { name: string; provider: string | null; createdAt: number; updatedAt: number; lastUsedAt: number | null }; +type ConnEntry = { kind: string; label: string; verifiedAt: number | null }; + +const CONN_NAME: Record = { cloudflare: "CLOUDFLARE_API_TOKEN", openai: "OPENAI_API_KEY", github: "GITHUB_OAUTH_TOKEN" }; +const input = "mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm text-fg outline-none focus:border-fg"; + +/** + * /settings/environment — secrets the user ticked "Remember" for. Shows name + dates only. + * Values are write-only from the browser: they go to the server once and never come back. + */ +export function EnvironmentPanel() { + const { lang } = useLang(); + const th = lang === "th"; + const [env, setEnv] = useState([]); + const [conns, setConns] = useState([]); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [busy, setBusy] = useState(""); + const [draft, setDraft] = useState({ name: "", value: "" }); + const fmt = (t: number | null) => (t ? new Date(t).toLocaleString(th ? "th-TH" : "en-GB", { dateStyle: "medium", timeStyle: "short" }) : "—"); + + const load = useCallback(async () => { + const b = await fetchEnvironment(); + setEnv(b.env); + setConns(b.connections); + }, []); + + useEffect(() => { + let active = true; + fetchEnvironment() + .then((b) => { if (active) { setEnv(b.env); setConns(b.connections); } }) + .catch((e: unknown) => { if (active) setError(e instanceof Error ? e.message : String(e)); }); + return () => { active = false; }; + }, []); + + async function run(key: string, fn: () => Promise, ok: string) { + setBusy(key); setError(""); setNotice(""); + try { + const r = await fn(); + const b = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(b.error ?? "Request failed"); + setNotice(ok); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(""); + } + } + + const add = (e: React.FormEvent) => { + e.preventDefault(); + const body = JSON.stringify({ name: draft.name.trim().toUpperCase(), value: draft.value }); + setDraft({ name: draft.name, value: "" }); + void run("add", () => fetch("/api/environment", { method: "POST", headers: { "Content-Type": "application/json" }, body }), th ? "บันทึกแล้ว" : "Saved"); + }; + + return ( +
+

Environment

+

+ {th ? "คีย์ที่คุณเลือก “จำไว้” บอทและการ deploy ใช้ได้ ไม่แสดงค่าจริงที่นี่" : "Keys you chose to “Remember”. The chat bot and deploys can use them. Values are never shown here."} +

+

+ + {th ? "เข้ารหัส AES-GCM ด้วยคีย์ที่เก็บใน Cloudflare Secrets Store ลบได้ทุกเมื่อ" : "Encrypted with AES-GCM using a key held in Cloudflare Secrets Store. Delete any time."} +

+ +
+ {error &&

{error}

} + {notice && !error &&

{notice}

} +
+ + + + + + + + + + + + + + + {conns.map((c) => ( + + + + + + + + ))} + {env.map((e) => ( + + + + + + + + ))} + {env.length + conns.length === 0 && ( + + )} + +
{th ? "รายการ Environment" : "Environment entries"}
{th ? "ชื่อ" : "Name"}{th ? "สร้าง" : "Created"}{th ? "แก้ไข" : "Updated"}{th ? "ใช้ล่าสุด" : "Last used"}{th ? "ลบ" : "Delete"}
{CONN_NAME[c.kind] ?? c.kind.toUpperCase()} ({th ? "การเชื่อมต่อ" : "connection"})—{fmt(c.verifiedAt)}— + +
{e.name}{fmt(e.createdAt)}{fmt(e.updatedAt)}{fmt(e.lastUsedAt)} + +
{th ? "ยังไม่มีคีย์ที่จำไว้" : "Nothing remembered yet."}
+
+ + +
+

{th ? "เพิ่มคีย์" : "Add a key"}

+ +
+
+ + + +
+
+
+ ); +} + +async function fetchEnvironment(): Promise<{ env: EnvEntry[]; connections: ConnEntry[] }> { + const r = await fetch("/api/environment", { cache: "no-store" }); + const b = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(b.error ?? "Could not load environment"); + return { env: b.env ?? [], connections: b.connections ?? [] }; +} diff --git a/src/components/factory/factory-app.tsx b/src/components/factory/factory-app.tsx index 0053aa3..d848730 100644 --- a/src/components/factory/factory-app.tsx +++ b/src/components/factory/factory-app.tsx @@ -1,8 +1,12 @@ "use client"; +import { CreateTokenMenu } from "@/components/tokens/create-token-menu"; +import { RememberToggle } from "@/components/tokens/remember-toggle"; +import { rememberSecret } from "@/components/tokens/remember-secret"; + import { AlertTriangle, ArrowLeft, Box, Check, Cloud, FlaskConical, KeyRound, Laptop, Plus, ShieldCheck } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { Badge, Button, Card, Checkbox, cn, Field, Input, Spinner, Textarea } from "@/components/ui"; +import { Badge, Button, Card, cn, Field, Input, Spinner, Textarea } from "@/components/ui"; import { ChatApp } from "../chat/chat-app"; import type { ChatInfo } from "../chat/types"; import { CodeBlock } from "../copy-button"; @@ -76,14 +80,7 @@ export function FactoryApp() { useEffect(() => { let live = true; try { - const saved = sessionStorage.getItem(KEY_STORE); - if (saved) { - queueMicrotask(() => { - if (!live) return; - setKey(saved); - setRemember(true); - }); - } + sessionStorage.removeItem(KEY_STORE); // legacy: keys are no longer kept in the browser } catch {} fetch("/api/factory", { cache: "no-store" }) .then((r) => r.json() as Promise) @@ -94,12 +91,6 @@ export function FactoryApp() { }; }, []); - useEffect(() => { - try { - if (remember && key) sessionStorage.setItem(KEY_STORE, key); - else sessionStorage.removeItem(KEY_STORE); - } catch {} - }, [remember, key]); const keyValid = /^sk-[A-Za-z0-9_-]{20,400}$/.test(key.trim()); @@ -152,6 +143,10 @@ export function FactoryApp() { return; } setCreated(data); + if (remember) { + const saved = await rememberSecret("OPENAI_API_KEY", key.trim(), "openai"); + if (!saved.ok) setError(saved.error ?? null); + } setInfo((i) => (i ? { ...i, sessions: [{ id: data.conversationId, title: data.title, model: data.model, environment: data.environment, updatedAt: Date.now() }, ...i.sessions] } : i)); if (data.environment === "self_hosted") void watch(data.conversationId); } catch { @@ -187,7 +182,10 @@ export function FactoryApp() { {(p) => setKey(e.target.value)} invalid={key.length > 0 && !keyValid} />}
- +
+ + +
); diff --git a/src/components/projects/direct-deploy-panel.tsx b/src/components/projects/direct-deploy-panel.tsx index b7faebc..7ae4ecf 100644 --- a/src/components/projects/direct-deploy-panel.tsx +++ b/src/components/projects/direct-deploy-panel.tsx @@ -5,13 +5,18 @@ import { Rocket } from "lucide-react"; import { Card, copyText } from "@/components/ui"; import { useLang } from "@/components/prefs"; import type { DeployProfile } from "@/lib/deploy/templates"; +import { CreateTokenMenu } from "@/components/tokens/create-token-menu"; +import { RememberToggle } from "@/components/tokens/remember-toggle"; import { LiveUrl, RetryButton, StatusPill, type DeploymentView } from "../deploy/deploy-status"; +const input = "mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm text-fg outline-none focus:border-fg"; + /** Option A: direct deploy into the customer's Cloudflare account + deployments timeline. */ export function DirectDeployPanel({ projectId, profile }: { projectId: string; profile: DeployProfile }) { const { lang } = useLang(); const th = lang === "th"; const [items, setItems] = useState([]); + const [once, setOnce] = useState({ token: "", accountId: "", remember: false }); const load = useCallback(async () => { const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deploy`, { cache: "no-store" }); const b = await r.json().catch(() => ({})); @@ -41,7 +46,20 @@ export function DirectDeployPanel({ projectId, profile }: { projectId: string; p

{th ? "Deploy ตรงเข้าบัญชี Cloudflare ของคุณ" : "Direct deploy to your Cloudflare account"}

{direct ? ( -
+
+
+ {th ? "ใช้ token อื่นสำหรับรอบนี้ (ไม่บังคับ)" : "Use a different token for this deploy (optional)"} +
+ + +
+ setOnce({ ...once, remember })} /> + +
+
+
+ { setOnce({ token: "", accountId: once.accountId, remember: false }); refresh(); void id; }} label="Deploy" /> +
) : (

{th ? "โปรเจกต์นี้ต้อง build ก่อน จึงใช้ปุ่ม Deploy to Cloudflare / Workers Builds ด้านบน" : "This project needs a build step — use Deploy to Cloudflare / Workers Builds above."}

)} diff --git a/src/components/settings-nav.tsx b/src/components/settings-nav.tsx index 2fd4e79..b971968 100644 --- a/src/components/settings-nav.tsx +++ b/src/components/settings-nav.tsx @@ -6,6 +6,7 @@ import { cn } from "@/components/ui/cn"; const ITEMS = [ { href: "/settings/mcp", label: "MCP" }, { href: "/settings/connections", label: "Connections" }, + { href: "/settings/environment", label: "Environment" }, { href: "/settings/cli", label: "CLI" }, { href: "/projects", label: "Projects" }, ]; diff --git a/src/components/tokens/create-token-menu.tsx b/src/components/tokens/create-token-menu.tsx new file mode 100644 index 0000000..c8aa9d6 --- /dev/null +++ b/src/components/tokens/create-token-menu.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { ChevronDown, ExternalLink, KeyRound } from "lucide-react"; +import { buttonClasses, cn } from "@/components/ui"; +import { useLang } from "@/components/prefs"; +import { TOKEN_PROVIDERS, type TokenProvider } from "@/lib/connections/token-providers"; + +/** + * "Create token" button: opens a small menu of links to each provider's official token-creation page. + * `primary` puts that provider first. Links open in a new tab with noopener/noreferrer. Monochrome, + * keyboard operable (Enter/Space opens, Arrow keys move, Escape closes and returns focus). + */ +export function CreateTokenMenu({ primary, className }: { primary?: TokenProvider; className?: string }) { + const { lang } = useLang(); + const th = lang === "th"; + const [open, setOpen] = useState(false); + const menuId = useId(); + const root = useRef(null); + const button = useRef(null); + const items = primary ? [...TOKEN_PROVIDERS].sort((a, b) => Number(b.id === primary) - Number(a.id === primary)) : [...TOKEN_PROVIDERS]; + + useEffect(() => { + if (!open) return; + const first = root.current?.querySelector("[role=menuitem]"); + first?.focus(); + const onDown = (e: MouseEvent) => { + if (!root.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open]); + + function onKey(e: React.KeyboardEvent) { + if (!open) return; + const links = [...(root.current?.querySelectorAll("[role=menuitem]") ?? [])]; + const i = links.indexOf(document.activeElement as HTMLAnchorElement); + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + button.current?.focus(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + links[(i + 1) % links.length]?.focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + links[(i - 1 + links.length) % links.length]?.focus(); + } else if (e.key === "Tab") { + setOpen(false); + } + } + + return ( + + ); +} diff --git a/src/components/tokens/remember-secret.ts b/src/components/tokens/remember-secret.ts new file mode 100644 index 0000000..37684a9 --- /dev/null +++ b/src/components/tokens/remember-secret.ts @@ -0,0 +1,11 @@ +/** Saves a token the user ticked "Remember" for into /settings/environment. Requires sign-in. */ +export async function rememberSecret(name: string, value: string, provider?: string): Promise<{ ok: boolean; error?: string }> { + try { + const r = await fetch("/api/environment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, value, provider }) }); + if (r.ok) return { ok: true }; + const b = (await r.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: b.error ?? "Could not remember the key" }; + } catch { + return { ok: false, error: "Network error" }; + } +} diff --git a/src/components/tokens/remember-toggle.tsx b/src/components/tokens/remember-toggle.tsx new file mode 100644 index 0000000..d5eb59a --- /dev/null +++ b/src/components/tokens/remember-toggle.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Checkbox } from "@/components/ui"; +import { useLang } from "@/components/prefs"; + +/** + * "Remember" checkbox next to a token input. Default off: the token is used once for this request and + * never stored. On: it is encrypted (CREDENTIALS_KEY v2) into the user's Environment (/settings/environment). + */ +export function RememberToggle({ checked, onCheckedChange, disabled }: { checked: boolean; onCheckedChange: (v: boolean) => void; disabled?: boolean }) { + const { lang } = useLang(); + const th = lang === "th"; + return ( + + {th ? "เก็บแบบเข้ารหัสใน " : "Stored encrypted in "} + Environment + + ) : th ? ( + "ใช้ครั้งเดียว ไม่บันทึก" + ) : ( + "Used once, not saved" + ) + } + checked={checked} + onCheckedChange={onCheckedChange} + disabled={disabled} + /> + ); +} diff --git a/src/lib/chat/runtime.ts b/src/lib/chat/runtime.ts index bfca6f6..f44caf8 100644 --- a/src/lib/chat/runtime.ts +++ b/src/lib/chat/runtime.ts @@ -2,17 +2,18 @@ import "server-only"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { chatEnv, type ChatEnv } from "./config"; import type { D1Like } from "./store"; +import type { R2Like } from "./stream-archive"; import { NO_STORE } from "@/lib/server/http"; /** Cloudflare bindings for /chat (D1 + waitUntil). Returns nulls outside Workers. */ -export async function chatRuntime(): Promise<{ db: D1Like | null; waitUntil: (p: Promise) => void }> { +export async function chatRuntime(): Promise<{ db: D1Like | null; streams: R2Like | null; waitUntil: (p: Promise) => void }> { try { const cf = await getCloudflareContext({ async: true }); - const env = cf.env as unknown as { DB?: D1Like }; - return { db: env.DB ?? null, waitUntil: (p) => cf.ctx.waitUntil(p) }; + const env = cf.env as unknown as { DB?: D1Like; CHAT_STREAMS?: R2Like }; + return { db: env.DB ?? null, streams: env.CHAT_STREAMS ?? null, waitUntil: (p) => cf.ctx.waitUntil(p) }; } catch (err) { console.warn("[chat] no Cloudflare context:", (err as Error)?.name); - return { db: null, waitUntil: () => undefined }; + return { db: null, streams: null, waitUntil: () => undefined }; } } diff --git a/src/lib/chat/stream-archive.ts b/src/lib/chat/stream-archive.ts new file mode 100644 index 0000000..fcc6c24 --- /dev/null +++ b/src/lib/chat/stream-archive.ts @@ -0,0 +1,88 @@ +import type { UiEvent } from "./events"; + +/** + * Chat stream persistence to R2 (binding CHAT_STREAMS). D1 keeps the existing messages exactly as before; + * this adds the streamed reply and thinking steps of each turn as one JSON object: + * chat///.json + * Consecutive `delta` events are merged so the object stays small; `reasoning` activities are dropped + * (same secret-free vocabulary the browser already receives). Archiving never breaks a turn. + */ + +export interface R2Like { + put(key: string, value: string, opts?: { httpMetadata?: { contentType?: string }; customMetadata?: Record }): Promise; + get(key: string): Promise<{ text(): Promise } | null>; +} + +export interface ArchivedTurn { + v: 1; + conversationId: string; + messageId: string; + startedAt: number; + finishedAt: number; + events: (UiEvent & { at: number })[]; +} + +export const MAX_ARCHIVE_EVENTS = 2_000; +export const MAX_ARCHIVE_BYTES = 1_000_000; + +export const archiveKey = (uid: string, conversationId: string, messageId: string) => `chat/${uid}/${conversationId}/${messageId}.json`; + +export function createRecorder(now: () => number = Date.now) { + const events: (UiEvent & { at: number })[] = []; + const startedAt = now(); + let bytes = 0; + return { + record(e: UiEvent) { + if (e.t === "quota") return; + if (e.t === "activity" && e.a.kind === "reasoning") return; + const last = events[events.length - 1]; + if (e.t === "delta" && last?.t === "delta" && last.item === e.item) { + if (bytes + e.text.length > MAX_ARCHIVE_BYTES) return; + last.text += e.text; + bytes += e.text.length; + return; + } + if (e.t === "activity") { + const i = events.findIndex((x) => x.t === "activity" && x.a.id === e.a.id); + if (i >= 0) { + events[i] = { ...e, at: now() }; + return; + } + } + if (events.length >= MAX_ARCHIVE_EVENTS) return; + const size = JSON.stringify(e).length; + if (bytes + size > MAX_ARCHIVE_BYTES) return; + bytes += size; + events.push({ ...e, at: now() }); + }, + snapshot(conversationId: string, messageId: string): ArchivedTurn { + return { v: 1, conversationId, messageId, startedAt, finishedAt: now(), events: events.map((e) => ({ ...e })) }; + }, + }; +} + +export async function saveArchive(bucket: R2Like | null | undefined, uid: string, turn: ArchivedTurn): Promise { + if (!bucket) return false; + try { + await bucket.put(archiveKey(uid, turn.conversationId, turn.messageId), JSON.stringify(turn), { + httpMetadata: { contentType: "application/json" }, + customMetadata: { v: "1", events: String(turn.events.length) }, + }); + return true; + } catch (err) { + console.warn(JSON.stringify({ at: "chat/archive", event: "put_failed", name: (err as Error)?.name })); + return false; + } +} + +export async function loadArchive(bucket: R2Like | null | undefined, uid: string, conversationId: string, messageId: string): Promise { + if (!bucket) return null; + const obj = await bucket.get(archiveKey(uid, conversationId, messageId)).catch(() => null); + if (!obj) return null; + try { + const t = JSON.parse(await obj.text()) as ArchivedTurn; + return t?.v === 1 ? t : null; + } catch { + return null; + } +} diff --git a/src/lib/chat/turn-route.ts b/src/lib/chat/turn-route.ts index c966835..0959035 100644 --- a/src/lib/chat/turn-route.ts +++ b/src/lib/chat/turn-route.ts @@ -9,6 +9,7 @@ import { clientIp, HttpError, readJsonBody } from "@/lib/server/http"; import { checkRateLimit } from "@/lib/server/rate-limit"; import { AgentsApi } from "./agents-api"; import { quotaConfig } from "./config"; +import { createRecorder, saveArchive } from "./stream-archive"; import type { UiEvent } from "./events"; import { agentRules, consume, imageRules, messageRules, pruneCounters, refund, spentToday, type LimitResult } from "./limits"; import { runImageTurn, runPlainTurn, USER_MODES, type UserMode } from "./plain-turn"; @@ -70,7 +71,7 @@ const min = (...v: (number | undefined)[]) => Math.min(...v.map((x) => x ?? 0)); export async function handleTurn(req: Request, channel: "user" | "dev"): Promise { const env = chatEnv(); const server = serverEnv(); - const { db, waitUntil } = await chatRuntime(); + const { db, streams, waitUntil } = await chatRuntime(); if (channel === "dev") { const why = unavailable(env, db); @@ -163,7 +164,9 @@ export async function handleTurn(req: Request, channel: "user" | "dev"): Promise const stream = new TransformStream(); const writer = stream.writable.getWriter(); let open = true; + const recorder = createRecorder(); const emit = (e: UiEvent) => { + recorder.record(e); if (!open) return; writer.write(encoder.encode(`data: ${JSON.stringify(e)}\n\n`)).catch(() => (open = false)); }; @@ -185,9 +188,10 @@ export async function handleTurn(req: Request, channel: "user" | "dev"): Promise log("turn_crashed", req, { channel, mode, name: (err as Error)?.name }); emit({ t: "error", code: "internal", message: "Something went wrong. / เกิดข้อผิดพลาด" }); }) - .finally(() => { + .finally(async () => { open = false; writer.close().catch(() => undefined); + await saveArchive(streams, uid, recorder.snapshot(conversation.id, assistantMessageId)); }); const base = { db: database, env, server, ai, userText: message, assistantMessageId, conversationId: conversation.id, emit }; diff --git a/src/lib/chat/turn.ts b/src/lib/chat/turn.ts index b0eb014..1eb8d52 100644 --- a/src/lib/chat/turn.ts +++ b/src/lib/chat/turn.ts @@ -8,7 +8,7 @@ import { generateSitePlan } from "./site-plan"; import { finishMessage, saveInlineImage, setAgentSession, type ConversationRow, type D1Like, type MessageRow } from "./store"; import type { SearchEntry } from "@/lib/docs-search"; import { GithubApiError } from "@/lib/auth/github-api"; -import { sealKeys } from "@/lib/connections/credentials"; +import { resolveSealKeys } from "@/lib/connections/credentials"; import { ServiceError } from "@/lib/connections/github-service"; import { CUSTOMER_TOOL_NAMES, CustomerToolError, runCustomerTool } from "./customer-tools"; @@ -94,7 +94,7 @@ export async function runTurn(d: TurnDeps): Promise<{ outcome: string; usage?: { if (CUSTOMER_TOOL_NAMES.has(name)) { if (d.byok || !d.env.CHAT_COOKIE_SECRET) throw new ToolFailure("GitHub tools are only available in Dev Chat for signed-in accounts."); try { - return await runCustomerTool(name, args, { db: d.db, uid: d.uid, keys: sealKeys(d.env.CHAT_COOKIE_SECRET), emit }); + return await runCustomerTool(name, args, { db: d.db, uid: d.uid, keys: await resolveSealKeys(d.env.CHAT_COOKIE_SECRET), emit }); } catch (err) { if (err instanceof CustomerToolError || err instanceof ServiceError) throw new ToolFailure(err.message); if (err instanceof GithubApiError) throw new ToolFailure(`GitHub ${err.status}: ${err.message}`); diff --git a/src/lib/connections/audit.ts b/src/lib/connections/audit.ts index c13746c..481cf48 100644 --- a/src/lib/connections/audit.ts +++ b/src/lib/connections/audit.ts @@ -11,7 +11,9 @@ export type AuditEvent = | "deploy.live" | "deploy.failed" | "fix.committed" - | "cli.scaffold"; + | "cli.scaffold" + | "env.saved" + | "env.removed"; export async function audit(db: D1Like, userId: string, event: AuditEvent, subject?: string): Promise { try { diff --git a/src/lib/connections/credentials.ts b/src/lib/connections/credentials.ts index 787cafa..dda1629 100644 --- a/src/lib/connections/credentials.ts +++ b/src/lib/connections/credentials.ts @@ -22,6 +22,49 @@ export function sealKeys(cookieSecret: string, source: Record; +} + +let storeKey: Promise | null = null; + +/** Reads CREDENTIALS_KEY from the Secrets Store binding (`await env.CREDENTIALS_KEY.get()`), cached per isolate. */ +export async function storeCredentialsKey(binding: unknown): Promise { + if (!binding || typeof (binding as SecretsStoreBinding).get !== "function") return undefined; + // Wrap: the binding (and the dev platform proxy's RPC stub) may return a non-native thenable. + storeKey ??= Promise.resolve() + .then(() => (binding as SecretsStoreBinding).get()) + .then((v) => v?.trim() || undefined) + .catch(() => { + storeKey = null; + return undefined; + }); + return storeKey; +} + +export function resetStoreKeyCache(): void { + storeKey = null; +} + +/** + * Seal keys for the current request. Prefers the Secrets Store binding CREDENTIALS_KEY; falls back to a + * plain Worker secret of the same name (process.env), then to legacy v1 only. + */ +export async function resolveSealKeys(cookieSecret: string, bindings?: Record): Promise { + let env = bindings; + if (!env) { + try { + const { getCloudflareContext } = await import("@opennextjs/cloudflare"); + env = (await getCloudflareContext({ async: true })).env as unknown as Record; + } catch { + env = {}; + } + } + const fromStore = await storeCredentialsKey(env.CREDENTIALS_KEY); + if (fromStore && validCredentialsKey(fromStore)) return { cookieSecret, credentialsKey: fromStore }; + return sealKeys(cookieSecret); +} + /** 32 random bytes, base64 (`openssl rand -base64 32`). */ export function validCredentialsKey(raw: string): boolean { if (!/^[A-Za-z0-9+/]{43}=$/.test(raw)) return false; diff --git a/src/lib/connections/environment.ts b/src/lib/connections/environment.ts new file mode 100644 index 0000000..6aa504c --- /dev/null +++ b/src/lib/connections/environment.ts @@ -0,0 +1,68 @@ +import "server-only"; +import type { D1Like } from "@/lib/chat/store"; +import { open, seal, type SealKeys } from "./credentials"; + +/** + * Per-user Environment (migration 0012 user_env). Written only when the user ticks "Remember" + * next to a token input; otherwise a token is used once for that request and never persisted. + * Values are sealed (CREDENTIALS_KEY v2 when available) and never leave the server. + */ + +export const ENV_NAME = /^[A-Z][A-Z0-9_]{1,63}$/; +export const MAX_ENV_VALUE = 4_000; +export const MAX_ENV_PER_USER = 50; + +export interface EnvEntryView { + name: string; + provider: string | null; + createdAt: number; + updatedAt: number; + lastUsedAt: number | null; +} + +const kindOf = (name: string) => `env:${name}`; + +export function validEnvName(name: unknown): name is string { + return typeof name === "string" && ENV_NAME.test(name); +} + +export async function listEnv(db: D1Like, userId: string): Promise { + const rows = await db + .prepare("SELECT name, provider, created_at, updated_at, last_used_at FROM user_env WHERE user_id = ?1 ORDER BY name") + .bind(userId) + .all<{ name: string; provider: string | null; created_at: number; updated_at: number; last_used_at: number | null }>(); + return (rows.results ?? []).map((r) => ({ name: r.name, provider: r.provider ?? null, createdAt: r.created_at, updatedAt: r.updated_at, lastUsedAt: r.last_used_at ?? null })); +} + +export async function saveEnv(db: D1Like, keys: SealKeys, userId: string, name: string, value: string, provider: string | null = null, now = Date.now()): Promise { + if (!validEnvName(name)) throw new Error("invalid_name"); + if (!value || value.length > MAX_ENV_VALUE) throw new Error("invalid_value"); + const count = await db.prepare("SELECT COUNT(*) AS n FROM user_env WHERE user_id = ?1 AND name != ?2").bind(userId, name).first<{ n: number }>(); + if ((count?.n ?? 0) >= MAX_ENV_PER_USER) throw new Error("too_many"); + const enc = await seal(keys, kindOf(name), userId, value); + await db + .prepare( + `INSERT INTO user_env (user_id, name, secret_enc, provider, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5) + ON CONFLICT (user_id, name) DO UPDATE SET secret_enc = excluded.secret_enc, provider = excluded.provider, updated_at = excluded.updated_at` + ) + .bind(userId, name, enc, provider, now) + .run(); +} + +/** Server-side only (bot tools, deploy). Re-seals legacy v1 rows as v2 and stamps last_used_at. */ +export async function loadEnv(db: D1Like, keys: SealKeys, userId: string, name: string, now = Date.now()): Promise { + if (!validEnvName(name)) return null; + const row = await db.prepare("SELECT secret_enc FROM user_env WHERE user_id = ?1 AND name = ?2").bind(userId, name).first<{ secret_enc: string }>(); + if (!row) return null; + const opened = await open(keys, kindOf(name), userId, row.secret_enc); + if (!opened) return null; + const enc = opened.stale ? await seal(keys, kindOf(name), userId, opened.value) : row.secret_enc; + await db.prepare("UPDATE user_env SET secret_enc = ?3, last_used_at = ?4 WHERE user_id = ?1 AND name = ?2").bind(userId, name, enc, now).run(); + return opened.value; +} + +export async function deleteEnv(db: D1Like, userId: string, name: string): Promise { + if (!validEnvName(name)) return false; + await db.prepare("DELETE FROM user_env WHERE user_id = ?1 AND name = ?2").bind(userId, name).run(); + return true; +} diff --git a/src/lib/connections/route-kit.ts b/src/lib/connections/route-kit.ts index 4750ea1..8f87f65 100644 --- a/src/lib/connections/route-kit.ts +++ b/src/lib/connections/route-kit.ts @@ -6,7 +6,7 @@ import { chatRuntime, json } from "@/lib/chat/runtime"; import { getAccount, type D1Like } from "@/lib/chat/store"; import { clientIp } from "@/lib/server/http"; import { checkRateLimit } from "@/lib/server/rate-limit"; -import { sealKeys, type SealKeys } from "./credentials"; +import { resolveSealKeys, type SealKeys } from "./credentials"; export interface Actor { uid: string; @@ -24,7 +24,9 @@ export async function gate(req: Request, opts: { write: boolean; burstKey: strin if (opts.write && !sameOrigin(req)) return json({ error: "Cross-site request blocked. / คำขอข้ามไซต์ถูกปฏิเสธ", code: "forbidden" }, 403); const env = chatEnv(); if (!env.CHAT_COOKIE_SECRET) return json({ error: "Not signed in. / ยังไม่ได้เข้าสู่ระบบ", code: "unauthorized" }, 401); - const burst = await checkRateLimit("NVX_CHAT_LIMITER", `${opts.burstKey}:${clientIp(req)}`, opts.perMinute); + // Writes use the shared Workers limiter (4 / 60 s per key). Reads (page loads, deploy status polling every + // 3 s) use the per-isolate limiter at `perMinute`: the binding's fixed 4/min would throttle normal use. + const burst = await checkRateLimit("NVX_CHAT_LIMITER", `${opts.burstKey}:${clientIp(req)}`, opts.perMinute, { memoryOnly: !opts.write }); if (!burst.ok) return json({ error: "Too many requests. / ส่งคำขอถี่เกินไป", code: "rate_limited" }, 429, { "Retry-After": "60" }); const actor = await resolveSignedIn(req, env.CHAT_COOKIE_SECRET); if (!actor.authenticated || !actor.uid) return json({ error: "Sign in first. / กรุณาเข้าสู่ระบบก่อน", code: "unauthorized" }, 401); @@ -32,7 +34,7 @@ export async function gate(req: Request, opts: { write: boolean; burstKey: strin if (!db) return json({ error: "Storage unavailable. / ระบบจัดเก็บไม่พร้อม", code: "no_database" }, 503); const account = await getAccount(db, actor.uid).catch(() => null); if (!account) return json({ error: "Sign in with an account (not a guest browser). / กรุณาเข้าสู่ระบบด้วยบัญชี", code: "account_required" }, 401); - return { uid: actor.uid, db, keys: sealKeys(env.CHAT_COOKIE_SECRET), provider: account.provider, login: account.login }; + return { uid: actor.uid, db, keys: await resolveSealKeys(env.CHAT_COOKIE_SECRET), provider: account.provider, login: account.login }; } export const isResponse = (x: unknown): x is Response => x instanceof Response; diff --git a/src/lib/connections/token-providers.ts b/src/lib/connections/token-providers.ts new file mode 100644 index 0000000..3f134a7 --- /dev/null +++ b/src/lib/connections/token-providers.ts @@ -0,0 +1,54 @@ +/** + * Official token-creation console pages, one per provider the app accepts a token/key for. + * Verified against each provider's docs on 2026-10-15 (see docs/user-guide/create-token.md). + */ +export type TokenProvider = "cloudflare" | "github" | "openai" | "vercel" | "huggingface"; + +export interface TokenProviderInfo { + id: TokenProvider; + name: string; + url: string; + /** Suggested Environment variable name when the user ticks "Remember". */ + envName: string; + hint: { en: string; th: string }; +} + +export const TOKEN_PROVIDERS: readonly TokenProviderInfo[] = [ + { + id: "cloudflare", + name: "Cloudflare API token", + url: "https://dash.cloudflare.com/profile/api-tokens", + envName: "CLOUDFLARE_API_TOKEN", + hint: { en: "Workers Scripts: Edit + Account Settings: Read", th: "Workers Scripts: Edit + Account Settings: Read" }, + }, + { + id: "github", + name: "GitHub fine-grained token", + url: "https://github.com/settings/personal-access-tokens/new", + envName: "GITHUB_TOKEN", + hint: { en: "Contents + Pull requests: Read and write", th: "Contents + Pull requests: Read and write" }, + }, + { + id: "openai", + name: "OpenAI API key", + url: "https://platform.openai.com/api-keys", + envName: "OPENAI_API_KEY", + hint: { en: "Project key, restricted if possible", th: "ใช้คีย์ระดับโปรเจกต์ และจำกัดสิทธิ์ถ้าทำได้" }, + }, + { + id: "vercel", + name: "Vercel token", + url: "https://vercel.com/account/tokens", + envName: "VERCEL_TOKEN", + hint: { en: "Scope it to one team, set an expiry", th: "จำกัดให้ทีมเดียว และตั้งวันหมดอายุ" }, + }, + { + id: "huggingface", + name: "Hugging Face access token", + url: "https://huggingface.co/settings/tokens", + envName: "HF_TOKEN", + hint: { en: "Fine-grained, Inference only", th: "แบบ fine-grained เฉพาะ Inference" }, + }, +]; + +export const tokenProvider = (id: TokenProvider) => TOKEN_PROVIDERS.find((p) => p.id === id)!; diff --git a/src/lib/deploy/service.ts b/src/lib/deploy/service.ts index ebef5b7..9c13959 100644 --- a/src/lib/deploy/service.ts +++ b/src/lib/deploy/service.ts @@ -3,7 +3,8 @@ import { readBlob, readTree } from "@/lib/auth/github-api"; import { safeBranch } from "@/lib/auth/github-policy"; import type { D1Like } from "@/lib/chat/store"; import { audit } from "@/lib/connections/audit"; -import { loadCredential, loadGithubToken, type SealKeys } from "@/lib/connections/credentials"; +import { loadCredential, loadGithubToken, type CredentialMeta, type SealKeys } from "@/lib/connections/credentials"; +import { loadEnv } from "@/lib/connections/environment"; import { ServiceError, takeQuota } from "@/lib/connections/github-service"; import { LIMITS, safeWorkerName } from "@/lib/connections/policy"; import type { ProjectRow } from "@/lib/connections/projects"; @@ -106,6 +107,13 @@ export function selectFiles(profile: DeployProfile, entries: { path: string; typ return chosen; } +/** Remembered Environment entries CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID (fallback when no connection row). */ +async function rememberedCloudflare(db: D1Like, keys: SealKeys, uid: string): Promise<{ secret: string; meta: CredentialMeta } | null> { + const secret = await loadEnv(db, keys, uid, "CLOUDFLARE_API_TOKEN").catch(() => null); + const account = await loadEnv(db, keys, uid, "CLOUDFLARE_ACCOUNT_ID").catch(() => null); + return secret && account && /^[a-f0-9]{32}$/.test(account) ? { secret, meta: { account_id: account } } : null; +} + /** * POST /api/projects/:id/deploy. Validates synchronously (quota, one in flight, credentials, profile), * records a `queued` deployment and runs the upload in the background. @@ -117,7 +125,9 @@ export async function startDeploy( project: ProjectRow, rawRef: string | null, waitUntil: (p: Promise) => void, - f: typeof fetch = fetch + f: typeof fetch = fetch, + /** Token entered with "Remember" off: used for this deploy only, never persisted. */ + once?: { secret: string; meta: CredentialMeta } | null ): Promise { const upload = !project.repo_full_name; const ref = upload ? "upload" : safeBranch(rawRef || project.default_branch || "main"); @@ -126,7 +136,7 @@ export async function startDeploy( if (inflight) throw new ServiceError("conflict", "A deploy is already running for this project. / มี deploy กำลังทำงานอยู่", 409); const gh = upload ? null : await loadGithubToken(db, keys, uid); if (!upload && !gh) throw new ServiceError("reauth", "Connect GitHub first. / เชื่อมต่อ GitHub ก่อน", 401); - const cred = await loadCredential(db, keys, uid, "cloudflare"); + const cred = once ?? (await loadCredential(db, keys, uid, "cloudflare")) ?? (await rememberedCloudflare(db, keys, uid)); if (!cred) throw new ServiceError("reauth", "Connect Cloudflare in /settings/connections first. / เชื่อมต่อ Cloudflare ก่อน", 401); const [owner, repo] = upload ? ["", ""] : project.repo_full_name!.split("/"); const tree = upload ? null : await readTree(gh!, owner, repo, ref); diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 6388e41..5c4b049 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -309,7 +309,6 @@ const dict = { en: "Sent over HTTPS with each request only. Never stored or logged on our server; calls are billed to your account.", th: "ส่งผ่าน HTTPS เฉพาะตอนส่งคำขอแต่ละครั้ง ไม่ถูกเก็บหรือบันทึก log บนเซิร์ฟเวอร์ของเรา ค่าใช้จ่ายคิดกับบัญชีของคุณ", }, - "factory.remember": { en: "Remember for this tab (sessionStorage)", th: "จำไว้เฉพาะแท็บนี้ (sessionStorage)" }, "factory.scopes": { en: "Required key permissions", th: "สิทธิ์ที่ key ต้องมี" }, "factory.envKeyNote": { en: "Self-hosted sandboxes also need an environment key (Agents tab in the OpenAI dashboard, same project, other permissions None). It goes only to your machine as CODEX_API_KEY — never paste it here.", diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index fc0fa7b..568f2f6 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -9,7 +9,7 @@ import { chatRuntime } from "@/lib/chat/runtime"; import { AUTH_COOKIE, signUid } from "@/lib/chat/identity"; import { getAccount, getConversation, listConversations, listMessages, loadInlineImage } from "@/lib/chat/store"; import { chatEnv as loadChatEnv } from "@/lib/chat/config"; -import { loadGithubToken, sealKeys } from "@/lib/connections/credentials"; +import { loadGithubToken, resolveSealKeys } from "@/lib/connections/credentials"; import { listWritableRepos, putFile } from "@/lib/auth/github-api"; import { parseRepo, safeBranch, safeCommitMessage, safeRepoPath, MAX_FILE_CHARS } from "@/lib/auth/github-policy"; import { handleTurn } from "@/lib/chat/turn-route"; @@ -333,7 +333,7 @@ export async function callTool(req: Request, name: string, rawArgs: unknown) { if (!client || !db) return toolError("unavailable", "Storage is not available."); const account = await getAccount(db, client.userId); if (account?.provider !== "github") return toolError("github_required", "Sign in to DevStack with GitHub to use GitHub tools."); - const token = env.CHAT_COOKIE_SECRET ? await loadGithubToken(db, sealKeys(env.CHAT_COOKIE_SECRET), client.userId) : null; + const token = env.CHAT_COOKIE_SECRET ? await loadGithubToken(db, await resolveSealKeys(env.CHAT_COOKIE_SECRET), client.userId) : null; if (!token) return toolError("reauth", "Reconnect GitHub in DevStack before using this tool."); return result(await listWritableRepos(token)); } @@ -347,7 +347,7 @@ export async function callTool(req: Request, name: string, rawArgs: unknown) { const branch = typeof args.branch === "string" && args.branch.trim() ? safeBranch(args.branch) : "main"; const content = typeof args.content === "string" ? args.content : ""; if (!repo || !path || !message || !branch || !content.trim() || content.length > MAX_FILE_CHARS) return toolError("invalid", "Repository, path, branch, commit message, or file content is invalid."); - const token = env.CHAT_COOKIE_SECRET ? await loadGithubToken(db, sealKeys(env.CHAT_COOKIE_SECRET), client.userId) : null; + const token = env.CHAT_COOKIE_SECRET ? await loadGithubToken(db, await resolveSealKeys(env.CHAT_COOKIE_SECRET), client.userId) : null; if (!token) return toolError("reauth", "Reconnect GitHub in DevStack before committing."); return result({ ...(await putFile(token, { owner: repo.owner, repo: repo.name, path, message, content, branch })), repo: `${repo.owner}/${repo.name}`, branch, path }); } diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts index 0e7af35..4be3f82 100644 --- a/src/lib/server/rate-limit.ts +++ b/src/lib/server/rate-limit.ts @@ -29,8 +29,8 @@ async function binding(name: LimiterName): Promise { } } -export async function checkRateLimit(name: LimiterName, key: string, fallbackPerMinute: number): Promise<{ ok: boolean; backend: "workers" | "memory" }> { - const b = await binding(name); +export async function checkRateLimit(name: LimiterName, key: string, fallbackPerMinute: number, opts: { memoryOnly?: boolean } = {}): Promise<{ ok: boolean; backend: "workers" | "memory" }> { + const b = opts.memoryOnly ? null : await binding(name); if (b) { try { const { success } = await b.limit({ key }); diff --git a/tests/environment.test.ts b/tests/environment.test.ts new file mode 100644 index 0000000..8a92586 --- /dev/null +++ b/tests/environment.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { resetStoreKeyCache, resolveSealKeys } from "@/lib/connections/credentials"; +import { deleteEnv, listEnv, loadEnv, saveEnv, validEnvName } from "@/lib/connections/environment"; +import { TOKEN_PROVIDERS } from "@/lib/connections/token-providers"; +import { archiveKey, createRecorder, loadArchive, saveArchive, type R2Like } from "@/lib/chat/stream-archive"; +import type { D1Like } from "@/lib/chat/store"; + +const COOKIE = "test-cookie-secret-at-least-32-characters"; +const KEY = btoa(String.fromCharCode(...new Uint8Array(32).map((_, i) => i + 7))); + +/** Fake for user_env keyed by user|name. */ +function fakeEnvDb(): D1Like & { rows: Map> } { + const rows = new Map>(); + const stmt = (sql: string, a: unknown[] = []) => ({ + bind: (...x: unknown[]) => stmt(sql, x), + async all() { + const k = `${a[0]}|${a[1]}`; + if (sql.startsWith("SELECT COUNT")) return { results: [{ n: [...rows.keys()].filter((r) => r.startsWith(`${a[0]}|`) && r !== k).length }] }; + if (sql.startsWith("INSERT INTO user_env")) rows.set(k, { name: a[1], secret_enc: a[2], provider: a[3], created_at: rows.get(k)?.created_at ?? a[4], updated_at: a[4], last_used_at: rows.get(k)?.last_used_at ?? null }); + if (sql.startsWith("SELECT name")) return { results: [...rows.entries()].filter(([r]) => r.startsWith(`${a[0]}|`)).map(([, v]) => v) }; + if (sql.startsWith("SELECT secret_enc")) return { results: rows.has(k) ? [rows.get(k)] : [] }; + if (sql.startsWith("UPDATE user_env")) rows.set(k, { ...rows.get(k), secret_enc: a[2], last_used_at: a[3] }); + if (sql.startsWith("DELETE FROM user_env")) rows.delete(k); + return { results: [] }; + }, + async run() { return this.all(); }, + async first() { return ((await this.all()).results?.[0] ?? null) as never; }, + }); + return { rows, prepare: (s: string) => stmt(s) as never, batch: async () => [] as never }; +} + +describe("CREDENTIALS_KEY from Secrets Store", () => { + it("reads the binding with get() and caches it", async () => { + resetStoreKeyCache(); + let calls = 0; + const binding = { get: async () => { calls++; return KEY; } }; + expect((await resolveSealKeys(COOKIE, { CREDENTIALS_KEY: binding })).credentialsKey).toBe(KEY); + expect((await resolveSealKeys(COOKIE, { CREDENTIALS_KEY: binding })).credentialsKey).toBe(KEY); + expect(calls).toBe(1); + }); + it("ignores an invalid store value and falls back to v1", async () => { + resetStoreKeyCache(); + const keys = await resolveSealKeys(COOKIE, { CREDENTIALS_KEY: { get: async () => "short" } }); + expect(keys.credentialsKey).toBeUndefined(); + }); + it("retries after a failing get()", async () => { + resetStoreKeyCache(); + expect((await resolveSealKeys(COOKIE, { CREDENTIALS_KEY: { get: async () => { throw new Error("x"); } } })).credentialsKey).toBeUndefined(); + expect((await resolveSealKeys(COOKIE, { CREDENTIALS_KEY: { get: async () => KEY } })).credentialsKey).toBe(KEY); + resetStoreKeyCache(); + }); +}); + +describe("Environment (Remember)", () => { + const keys = { cookieSecret: COOKIE, credentialsKey: KEY }; + it("validates names", () => { + expect(validEnvName("VERCEL_TOKEN")).toBe(true); + expect(validEnvName("vercel")).toBe(false); + expect(validEnvName("A")).toBe(false); + expect(validEnvName("X; DROP")).toBe(false); + }); + it("stores sealed v2, lists names and dates only, loads server-side, deletes", async () => { + const db = fakeEnvDb(); + await saveEnv(db, keys, "u1", "VERCEL_TOKEN", "vercel-secret-value-123", "vercel", 1000); + const row = db.rows.get("u1|VERCEL_TOKEN")!; + expect(String(row.secret_enc).startsWith("v2.")).toBe(true); + expect(String(row.secret_enc)).not.toContain("vercel-secret"); + const list = await listEnv(db, "u1"); + expect(list).toEqual([{ name: "VERCEL_TOKEN", provider: "vercel", createdAt: 1000, updatedAt: 1000, lastUsedAt: null }]); + expect(JSON.stringify(list)).not.toContain("secret"); + expect(await loadEnv(db, keys, "u1", "VERCEL_TOKEN", 2000)).toBe("vercel-secret-value-123"); + expect((await listEnv(db, "u1"))[0].lastUsedAt).toBe(2000); + expect(await loadEnv(db, keys, "u2", "VERCEL_TOKEN")).toBeNull(); + await deleteEnv(db, "u1", "VERCEL_TOKEN"); + expect(await listEnv(db, "u1")).toEqual([]); + }); + it("is bound to user and name (a copied ciphertext does not open)", async () => { + const db = fakeEnvDb(); + await saveEnv(db, keys, "u1", "A_TOKEN", "value-aaaaaaaa"); + db.rows.set("u2|A_TOKEN", { ...db.rows.get("u1|A_TOKEN") }); + db.rows.set("u1|B_TOKEN", { ...db.rows.get("u1|A_TOKEN") }); + expect(await loadEnv(db, keys, "u2", "A_TOKEN")).toBeNull(); + expect(await loadEnv(db, keys, "u1", "B_TOKEN")).toBeNull(); + }); + it("rejects empty values", async () => { + await expect(saveEnv(fakeEnvDb(), keys, "u1", "A_TOKEN", "")).rejects.toThrow("invalid_value"); + }); +}); + +describe("Create token providers", () => { + it("lists official https pages for every provider", () => { + const ids = TOKEN_PROVIDERS.map((p) => p.id); + expect(ids).toEqual(["cloudflare", "github", "openai", "vercel", "huggingface"]); + for (const p of TOKEN_PROVIDERS) { + const u = new URL(p.url); + expect(u.protocol).toBe("https:"); + expect(["dash.cloudflare.com", "github.com", "platform.openai.com", "vercel.com", "huggingface.co"]).toContain(u.hostname); + expect(validEnvName(p.envName)).toBe(true); + } + }); +}); + +describe("chat stream archive (R2)", () => { + function fakeR2(): R2Like & { objects: Map } { + const objects = new Map(); + return { objects, async put(k, v) { objects.set(k, v); }, async get(k) { const v = objects.get(k); return v === undefined ? null : { text: async () => v }; } }; + } + it("merges deltas, keeps step updates, drops reasoning and quota", async () => { + let t = 0; + const r = createRecorder(() => ++t); + r.record({ t: "meta", conversationId: "c1", messageId: "m1", title: "" }); + r.record({ t: "quota", messagesLeft: 3 }); + r.record({ t: "activity", a: { id: "a1", kind: "search", label: "Search", status: "running", at: 1 } }); + r.record({ t: "activity", a: { id: "r1", kind: "reasoning", label: "secret thoughts", status: "done", at: 1 } }); + r.record({ t: "delta", item: "i", text: "สวัส" }); + r.record({ t: "delta", item: "i", text: "ดี" }); + r.record({ t: "activity", a: { id: "a1", kind: "search", label: "Search", status: "done", at: 2 } }); + r.record({ t: "done", messageId: "m1" }); + const snap = r.snapshot("c1", "m1"); + expect(snap.events.map((e) => e.t)).toEqual(["meta", "activity", "delta", "done"]); + expect(snap.events.find((e) => e.t === "delta")).toMatchObject({ text: "สวัสดี" }); + expect(snap.events.find((e) => e.t === "activity")).toMatchObject({ a: { status: "done" } }); + expect(JSON.stringify(snap)).not.toContain("secret thoughts"); + const bucket = fakeR2(); + expect(await saveArchive(bucket, "u1", snap)).toBe(true); + expect([...bucket.objects.keys()]).toEqual([archiveKey("u1", "c1", "m1")]); + expect((await loadArchive(bucket, "u1", "c1", "m1"))?.events.length).toBe(4); + }); + it("never throws when the bucket is missing or failing", async () => { + const snap = createRecorder().snapshot("c", "m"); + expect(await saveArchive(null, "u", snap)).toBe(false); + expect(await saveArchive({ put: async () => { throw new Error("down"); }, get: async () => null }, "u", snap)).toBe(false); + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 48d4ef0..efa5dc7 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -26,7 +26,14 @@ ], // D1 (SQLite) for chat history. Created 2026-10-11 (region WNAM). Schema: migrations/ and // docs/reference/d1-schema.md. Apply with `npm run db:migrate:remote` (approval required). - // R2 binding ASSETS_BUCKET (bucket nvx-assets) is NOT added yet: R2 is not enabled on the account. + // R2 bucket for chat stream persistence (streamed text + thinking steps per turn). Owner enabled R2 + // 2026-10-15; the bucket must exist before the next deploy (`wrangler r2 bucket create nvx-chat-streams`). + "r2_buckets": [{ "binding": "CHAT_STREAMS", "bucket_name": "nvx-chat-streams" }], + // CREDENTIALS_KEY (32 random bytes, base64) lives in Cloudflare Secrets Store, not as a Worker secret. + // Code reads it with `await env.CREDENTIALS_KEY.get()` (src/lib/connections/credentials.ts). + "secrets_store_secrets": [ + { "binding": "CREDENTIALS_KEY", "store_id": "b56dddabe7d74172932cf26f4a2ab03f", "secret_name": "CREDENTIALS_KEY" } + ], // Workers AI (User Chat on /chat). Always runs remotely, also in `wrangler dev`/preview. "ai": { "binding": "AI" }, "d1_databases": [