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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uid>/<conversationId>/<messageId>.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 `<name>/` and `<name>-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.
Expand Down
5 changes: 3 additions & 2 deletions cloudflare-env.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 */;
}
Expand All @@ -58,7 +59,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "OPENAI_MODEL" | "OPENAI_MAX_OUTPUT_TOKENS" | "OPENAI_TIMEOUT_MS" | "CHAT_ENABLED" | "CHAT_MODEL" | "CHAT_AGENT_ID" | "CHAT_REASONING_EFFORT" | "CHAT_ENVIRONMENT" | "CHAT_CONTAINER_SIZE" | "CHAT_MAX_INPUT_CHARS" | "CHAT_MAX_OUTPUT_TOKENS" | "CHAT_TURN_TIMEOUT_MS" | "FREE_DAILY_MESSAGES" | "FREE_DAILY_IMAGES" | "FREE_IP_MULTIPLIER" | "MAX_ATTACHMENT_BYTES" | "CHAT_AGENT_TURNS_PER_DAY" | "CHAT_GLOBAL_TURNS_PER_DAY" | "CHAT_AGENT_IMAGES_PER_DAY" | "CHAT_GLOBAL_IMAGES_PER_DAY" | "CHAT_USER_GLOBAL_PER_DAY" | "CHAT_USER_GLOBAL_IMAGES_PER_DAY" | "CHAT_IMAGE_MODEL" | "CHAT_IMAGE_QUALITY" | "CHAT_DAILY_BUDGET_USD" | "WAI_MODEL" | "WAI_THAI_MODEL" | "WAI_CODE_MODEL" | "WAI_IMAGE_MODEL" | "WAI_MAX_TOKENS" | "HF_MODEL" | "HF_FAST_MODEL" | "OPENAI_FALLBACK" | "FACTORY_ENABLED" | "FACTORY_SESSIONS_PER_HOUR" | "FACTORY_TURNS_PER_HOUR" | "OPENAI_API_KEY" | "CHAT_COOKIE_SECRET" | "CREDENTIALS_KEY" | "HF_TOKEN">> {}
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "OPENAI_MODEL" | "OPENAI_MAX_OUTPUT_TOKENS" | "OPENAI_TIMEOUT_MS" | "CHAT_ENABLED" | "CHAT_MODEL" | "CHAT_AGENT_ID" | "CHAT_REASONING_EFFORT" | "CHAT_ENVIRONMENT" | "CHAT_CONTAINER_SIZE" | "CHAT_MAX_INPUT_CHARS" | "CHAT_MAX_OUTPUT_TOKENS" | "CHAT_TURN_TIMEOUT_MS" | "FREE_DAILY_MESSAGES" | "FREE_DAILY_IMAGES" | "FREE_IP_MULTIPLIER" | "MAX_ATTACHMENT_BYTES" | "CHAT_AGENT_TURNS_PER_DAY" | "CHAT_GLOBAL_TURNS_PER_DAY" | "CHAT_AGENT_IMAGES_PER_DAY" | "CHAT_GLOBAL_IMAGES_PER_DAY" | "CHAT_USER_GLOBAL_PER_DAY" | "CHAT_USER_GLOBAL_IMAGES_PER_DAY" | "CHAT_IMAGE_MODEL" | "CHAT_IMAGE_QUALITY" | "CHAT_DAILY_BUDGET_USD" | "WAI_MODEL" | "WAI_THAI_MODEL" | "WAI_CODE_MODEL" | "WAI_IMAGE_MODEL" | "WAI_MAX_TOKENS" | "HF_MODEL" | "HF_FAST_MODEL" | "OPENAI_FALLBACK" | "FACTORY_ENABLED" | "FACTORY_SESSIONS_PER_HOUR" | "FACTORY_TURNS_PER_HOUR" | "OPENAI_API_KEY" | "CHAT_COOKIE_SECRET" | "HF_TOKEN">> {}
}
declare module "*.txt" {
const value: string;
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/config-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uid>/<conversationId>/<messageId>.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
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/d1-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
23 changes: 23 additions & 0 deletions docs/user-guide/customer-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 19 additions & 0 deletions docs/verification/env-remember-rounds.md
Original file line number Diff line number Diff line change
@@ -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 <n>`. 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/<uid>/<conversationId>/<messageId>.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).
12 changes: 12 additions & 0 deletions migrations/0012_user_environment.sql
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion scripts/build-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading