From 968ed6865e292a196475d7cd571311d56467ff13 Mon Sep 17 00:00:00 2001 From: Javier Date: Sat, 12 Sep 2026 18:43:16 +0700 Subject: [PATCH 01/26] docs(spec): propose the Next.js starter contract for #332 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handoff A-02 requires the implementer to freeze a contract before writing any code, so this lands the proposal only — no scaffold changes yet. Every claim is verified against the source tree or the registry rather than assumed, because the handoff calls out two specific traps: - "merged code" is not "published artifact": docker-compose builds the CMS from source, so it proves nothing about the image. The image is real and anonymously pullable, but `latest` still points at the 0.x line while the repo is 1.0.0-rc.1 -- the template has to pin the tag. - the public client can leak drafts. GET /api/v1/items applies no implicit published-only filter, and enablePublicAccess provisions a role and policy but no permission rows. A read grant without a row filter would serve drafts to anonymous callers, so the contract makes `status = published` mandatory and testable. Records the one real SDK gap (token is required, so the anonymous realm is unreachable from the client) and proposes working around it with a publishable key instead of editing packages/sdk in this ticket. Refs #332 --- .kiro/specs/nextjs-starter-contract/design.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 .kiro/specs/nextjs-starter-contract/design.md diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md new file mode 100644 index 00000000..5abfe4a2 --- /dev/null +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -0,0 +1,238 @@ +# Design Document — Next.js starter contract (#332, handoff A-02) + +> **Trạng thái: ĐỀ XUẤT — chưa được cấp grant, chưa implement.** +> Handoff A-02 (#332) yêu cầu chốt contract *trước* khi viết code. Tài liệu này +> là đầu ra của bước đó: đề xuất template, hai đường backend, mô hình nội dung +> và public client, kèm bảng file/env/lệnh xin cấp phát. +> +> Baseline: main `6a20441af5dde899b976479f0ed7f8d1a9341dee`. +> Mọi khẳng định dưới đây đã verify trên source tree / registry và có trích dẫn. +> Chỗ chưa verify được ghi rõ `[Unverified]`. + +## 1. Tổng quan + +Mục tiêu #332: một người dùng mới, **ngoài monorepo**, tạo được website Next.js, +kết nối CMS/Studio, thấy nội dung seed, sửa & publish trong Studio rồi đọc thay +đổi trên website **bằng quyền tối thiểu** — không có admin token nào lọt vào +browser bundle. + +Nguyên tắc: + +- **Tái dụng, không phát minh lại** — publishable API key, setup wizard, seed + pattern và Studio-in-Docker đều đã tồn tại; contract này ráp chúng lại. +- **Không thêm package chỉ để tăng lượt tải** (yêu cầu tường minh của #332). +- **`create-lumibase` là implementation duy nhất** — `lumibase init` delegate + sang nó, nên hai entrypoint không thể drift. +- **Phân biệt artifact local với artifact đã phát hành** — "code đã merge" + không đồng nghĩa "image/npm đã phát hành". + +## 2. Template Next.js + +Thêm template thứ ba `nextjs`, **giữ nguyên** `default` và `cloudflare`. + +`scaffold.ts` **không cần đổi logic**: nó copy đệ quy toàn bộ thư mục template và +render mọi file `.hbs` (`packages/create-lumibase/src/scaffold.ts:50-95`). Thêm +một template = thêm thư mục + nới union type. + +Điểm sửa, tối thiểu và có chủ đích: + +| Vị trí | Thay đổi | +|---|---| +| `packages/create-lumibase/src/index.ts:14` | `Template = 'default' \| 'cloudflare'` → thêm `'nextjs'` | +| `packages/create-lumibase/src/index.ts:78-96` | thêm một choice vào prompt "Deployment target" | +| `packages/create-lumibase/src/scaffold.ts:41-48` | `buildTemplateContext` thêm cờ `isNextjs` (đã có `isCloudflare`/`isDefault`) | + +**Không drift giữa hai entrypoint:** `lumibase init` không re-implement scaffold — +nó chạy `dlx create-lumibase@<đúng version của CLI>` +(`packages/cli/src/commands/init.ts:20-45`). Contract này **không sửa** +`init.ts`; chỉ bổ sung test khẳng định `--template nextjs` đi qua được cả +`npm create` lẫn `lumibase init`. + +## 3. Hai đường backend + +### 3.1 Đường A — kết nối instance CMS/Studio sẵn có + +Consumer chỉ cần base URL + site id + publishable key. Không provisioning. + +### 3.2 Đường B — Docker, CMS kèm Studio trong một image + +Các fact dưới đây **đã verify**, không phải suy đoán: + +- Image `ghcr.io/khuepm/lumibase-cms` **public, pull ẩn danh được**. Lấy + anonymous pull token từ `ghcr.io/token` rồi `GET /v2/khuepm/lumibase-cms/manifests/`: + + | tag | HTTP | + |---|---| + | `edge` | 200 | + | `latest` | 200 | + | `1.0.0-rc.1` | 200 | + | `1.0.0` | 404 | + | `1.0` | 404 | + +- ⇒ **Template phải pin `1.0.0-rc.1`, không dùng `latest`.** Repo đang ở + `1.0.0-rc.1` (root `package.json`), trong khi `latest` còn trỏ dòng 0.x (tag + list có tới `0.26.0`). Dùng `latest` là lệch major so với RC. + +- **Image có Studio đi kèm** — một image, cả CMS lẫn Studio: + `docker/Dockerfile:21` copy `apps/studio/`, `:30` build + `pnpm --filter @lumibase/studio build`, `:43` copy `dist` → `/app/studio`. + Runtime mount tại `apps/cms/src/serve.ts:81` qua `mountStudio`; env + `LUMIBASE_SERVE_STUDIO` (tắt) và `LUMIBASE_STUDIO_DIST` (đổi path) — + `apps/cms/src/serve-studio.ts:94,110`. + +- **Local ≠ published:** `docker/docker-compose.yml:86-87` service `cms` dùng + `build:` — build từ source, **không** pull image đã phát hành. Nên compose hiện + có *không* phải bằng chứng image chạy được. Template `nextjs` sẽ ship compose + **pull tag đã pin**, và được verify riêng bằng một lần cold pull. + +### 3.3 Bootstrap first-admin + site + +- `POST /api/v1/setup/complete` (`apps/cms/src/modules/setup/routes.ts:317-379`), + mount public ngoài tenant/auth (`apps/cms/src/index.ts:173`). + Body: `account{email,password,firstName,lastName}`, `adminPath`, `setupToken?` + (`routes.ts:40-79`). +- Site đầu tiên có id cố định `__default__` + (`apps/cms/src/modules/setup/site-constants.ts:11`) — chọn vậy để chạy lại + wizard là idempotent. +- `LUMIBASE_REQUIRE_SETUP_TOKEN=true` ⇒ token in ra log **một lần** dạng + `[lumibase-cms] SETUP_TOKEN=` + (`apps/cms/src/modules/setup/setup-token.ts:199`); DB chỉ giữ SHA-256. + +## 4. Collection, seed và public client + +### 4.1 Collection + +Một collection `posts`, field tối thiểu `title` / `slug` / `body`. + +Mô hình là `collections → fields → items` +(`packages/database/src/schema/cms.ts:47,88,184`); `items.status` mặc định +`draft` (`:194-195`). Tạo collection kèm `fields` inline qua +`POST /api/v1/collections` (`apps/cms/src/routes/collections.ts:97,162-178`); +tạo item qua `POST /api/v1/items/:collection` +(`apps/cms/src/routes/items.ts:106-118`). + +### 4.2 Seed chạy lại không trùng + +Theo đúng pattern repo đã dùng: id ổn định + `onConflictDoNothing`, như +`packages/database/scripts/seed-content-os-demo.ts:109,127,166`. Seed +site-scoped và chạy **server-side** trong bước bootstrap. + +### 4.3 Public client — publishable key + +Chọn **publishable key** (không dùng đường anonymous thuần, lý do ở §6): + +- Key class `lbk_pub_` (`apps/cms/src/services/api-key-publishable.ts:29`), tách + khỏi secret key `lbk_`. Gửi qua `Authorization: Bearer `; server chỉ + lưu hash (`apps/cms/src/middleware/auth.ts:285-291`). +- Publishable key bị **origin-check** theo `metadata.allowedOrigins` + (`apps/cms/src/middleware/auth.ts:329-349`). + ⚠️ Allowlist rỗng = `no_constraint`, dùng được từ mọi nơi + (`apps/cms/src/services/api-key-publishable.ts:75-80`) — template **phải** set + `allowedOrigins` tường minh. +- Key gắn cứng vào site: `apiKey.siteId !== siteId` ⇒ 401 + audit + (`apps/cms/src/middleware/auth.ts:299-305`). Đây chính là cơ chế khiến client + tenant B không đọc được nội dung tenant A. + +### 4.4 ⚠️ Rủi ro lộ draft — phải chốt trước khi code + +`GET /api/v1/items` **không** tự lọc `published`: `status` chỉ là query param +optional, chỉ áp dụng khi client truyền (`apps/cms/src/routes/items.ts:27`, +`apps/cms/src/services/item-service.ts:693`). Và `enablePublicAccess` chỉ tạo +role + policy, **không tạo permission row nào** +(`apps/cms/src/services/auth/public-role.ts:130-175`). + +⇒ Nếu grant `read` mà không kèm filter, client công khai **đọc được cả draft**. + +**Đề xuất chốt:** grant `read` trên `posts` **bắt buộc kèm row-filter +`status = published`**, dùng DSL row-level của `permissions.permissions` +(`packages/database/src/schema/access.ts:291-292`), cộng `fields` whitelist +(`:297-298`) để chặn field nội bộ. Test phải assert: publishable key **không** +nhìn thấy item draft. + +### 4.5 Token quản trị + +Chỉ dùng ở bước bootstrap/seed phía server. Biến admin **không** mang prefix +`NEXT_PUBLIC_`, nên không thể lọt browser bundle. Bằng chứng: grep bundle đã +build. + +## 5. Bảng file, env và lệnh + +### 5.1 File xin cấp phát + +| File | Thêm/Sửa | +|---|---| +| `packages/create-lumibase/templates/nextjs/**` | mới — app Next.js + compose pull image đã pin + script bootstrap/seed | +| `packages/create-lumibase/src/index.ts` | sửa — union `Template`, một prompt choice | +| `packages/create-lumibase/src/scaffold.ts` | sửa — cờ `isNextjs` trong context | +| `packages/create-lumibase/src/templates.test.ts` | sửa — mở rộng `it.each` sang `nextjs` | +| `packages/create-lumibase/src/*.test.ts` | mới/sửa — test scaffold + assertion chống rò token | + +**Không đụng:** `packages/sdk/**`, `apps/studio/**`, root manifest/lockfile, +`.github/workflows/**`, docs/spec dùng chung. `#334` sở hữu reference example — +contract này không tạo example độc lập. Tránh va `#467` (nhánh +`chore/deps-batch-2026-09`). + +### 5.2 Contract biến môi trường + +| Biến | Phía | Vai trò | +|---|---|---| +| `NEXT_PUBLIC_LUMIBASE_URL` | browser | base URL của CMS | +| `NEXT_PUBLIC_LUMIBASE_SITE_ID` | browser | `__default__`; gửi qua `X-Lumi-Site` | +| `NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY` | browser | key `lbk_pub_`, read-only + published-only | +| `LUMIBASE_ADMIN_TOKEN` | **server only** | chỉ bootstrap/seed | +| `LUMIBASE_REQUIRE_SETUP_TOKEN` | container | bật setup token | + +Tenant resolution: header **`X-Lumi-Site`** là đường chính +(`apps/cms/src/middleware/tenant.ts:26`) — đúng header SDK đã gửi sẵn +(`packages/sdk/src/client.ts:183`). + +### 5.3 Lệnh cold-install (ngoài monorepo, không `workspace:*`) + +```bash +pnpm -F create-lumibase build && npm pack +cd "$(mktemp -d)" && npm i +npx create-lumibase my-site --template nextjs --pm npm --no-git +``` + +## 6. Điểm cần SDK/API hỗ trợ + +`LumiClientOptions.token` là **bắt buộc**, kiểu `string`, doc ghi "Logto access +token" (`packages/sdk/src/client.ts:12`), và client **luôn** set +`authorization: Bearer ${currentToken}` (`packages/sdk/src/client.ts:182`). +Không có chế độ anonymous/publishable. + +- Publishable key **vẫn dùng được ngay**: nó đi qua đúng `Authorization: Bearer`, + nên truyền key vào `token` là chạy. **Không chặn #332.** +- Nhưng đường **anonymous thuần** (`apps/cms/src/middleware/auth.ts:529-545`; + chỉ `GET`/`HEAD`, chỉ các prefix `/api/v1/items|search|media|files` — `:567-575`) + thì SDK hiện **không gọi được** vì không bỏ được header `authorization`. +- Đề xuất: #332 dùng publishable key. Việc nới `token?: string` thuộc SDK owner; + contract này **không** sửa `packages/sdk`. Xin reviewer xác nhận có tách + ticket riêng hay không. + +## 7. Bằng chứng nghiệm thu sẽ nộp + +- Pack rồi cài vào thư mục ngoài monorepo; không `workspace:*`; hai entrypoint + hoạt động tương đương. +- Seed chạy hai lần không trùng. +- Studio sửa/publish → website đọc được thay đổi **thật**, không mock. +- Grep bundle chứng minh không rò admin token. +- Client tenant B không đọc được nội dung tenant A. +- **Assert publishable key không nhìn thấy item draft** (§4.4). +- Regression template `default` + `cloudflare`. +- Handoff ghi base/head, changed paths, lệnh/exit code/skips, phần chưa xác minh. +- Artifact local ghi tách bạch với npm/image đã phát hành; bằng chứng + cold-install nộp cho #448. + +## 8. Chặn — cần reviewer quyết trước khi implement + +1. **#450 còn OPEN.** Template `cloudflare` hiện không `npm install` được + (ERESOLVE do peer Workers Types; bối cảnh ở + `packages/create-lumibase/src/templates.test.ts:1-20`). Không thể nộp bằng + chứng "regression default/Cloudflare" xanh khi #450 chưa đóng. Xin quyết: + đóng #450 trước, hay ghi nhận Cloudflare là known-fail có dẫn chiếu #450? +2. **Chốt row-filter `status = published`** (§4.4) — nếu không, public client lộ + draft. +3. **Pin tag `1.0.0-rc.1`**, không `latest` (§3.2) — xác nhận đây là artifact + chuẩn cho contract này. +4. Xác nhận bảng file §5.1 và việc **không** sửa `packages/sdk` trong #332. From 91b6da3b768105b640b4a468f02ce10975b22634 Mon Sep 17 00:00:00 2001 From: Javier Date: Sat, 12 Sep 2026 19:43:12 +0700 Subject: [PATCH 02/26] feat(create-lumibase): add a Next.js starter with CMS, Studio and seed content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #332 asks for a real first website: a new user outside the monorepo scaffolds a Next.js site, connects CMS and Studio, sees seeded content, edits and publishes in Studio, then reads the change back through a least-privilege client. The browser never holds an admin credential, and two independent mechanisms keep that true: - it gets a publishable key (`lbk_pub_`), origin-locked and site-bound. The admin token lives in LUMIBASE_ADMIN_TOKEN, which has no NEXT_PUBLIC_ prefix and so cannot be inlined into the bundle. - the public grant is created with `publishedOnly`, compiling to the row filter `status = published`. This is load-bearing rather than decorative: GET /api/v1/items applies no implicit published-only filter, and enablePublicAccess provisions a role and policy but no permission rows, so a grant without it would serve drafts to every visitor. The seed deliberately leaves one post unpublished so `cms:verify` has a real draft to fail on, and the offline tests pin both properties. The CMS image is pinned by DIGEST, not by tag. Every semver tag — 1.0.0-rc.1 included — was built before the CMS learned to serve Studio, so Studio 404s on them; `latest` is still on the 0.x line; `edge` carries Studio but is rebuilt on every push to main. The digest names a build verified to contain /app/studio and cannot drift. Verified end to end on a real instance rather than by inspection: cold install from a packed tarball outside the monorepo (no workspace:*), npm install with no ERESOLVE, tsc clean, bootstrap through all six steps, seed run twice creating 3 then 0, verify passing, the website rendering two published posts and no draft, and a draft published through the API appearing on reload. Refs #332 --- .../src/nextjs-template.test.ts | 140 ++++++++++++ .../templates/nextjs/README.md.hbs | 109 +++++++++ .../templates/nextjs/_env.example | 32 +++ .../templates/nextjs/_gitignore | 6 + .../templates/nextjs/app/globals.css | 118 ++++++++++ .../templates/nextjs/app/layout.tsx | 19 ++ .../templates/nextjs/app/page.tsx | 91 ++++++++ .../templates/nextjs/docker-compose.yml | 84 +++++++ .../templates/nextjs/lib/lumibase.ts | 55 +++++ .../templates/nextjs/next.config.mjs | 6 + .../templates/nextjs/package.json.hbs | 29 +++ .../templates/nextjs/scripts/bootstrap.mjs | 210 ++++++++++++++++++ .../templates/nextjs/scripts/lumibase.mjs | 133 +++++++++++ .../templates/nextjs/scripts/seed.mjs | 75 +++++++ .../templates/nextjs/scripts/verify.mjs | 110 +++++++++ .../templates/nextjs/tsconfig.json | 21 ++ 16 files changed, 1238 insertions(+) create mode 100644 packages/create-lumibase/src/nextjs-template.test.ts create mode 100644 packages/create-lumibase/templates/nextjs/README.md.hbs create mode 100644 packages/create-lumibase/templates/nextjs/_env.example create mode 100644 packages/create-lumibase/templates/nextjs/_gitignore create mode 100644 packages/create-lumibase/templates/nextjs/app/globals.css create mode 100644 packages/create-lumibase/templates/nextjs/app/layout.tsx create mode 100644 packages/create-lumibase/templates/nextjs/app/page.tsx create mode 100644 packages/create-lumibase/templates/nextjs/docker-compose.yml create mode 100644 packages/create-lumibase/templates/nextjs/lib/lumibase.ts create mode 100644 packages/create-lumibase/templates/nextjs/next.config.mjs create mode 100644 packages/create-lumibase/templates/nextjs/package.json.hbs create mode 100644 packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs create mode 100644 packages/create-lumibase/templates/nextjs/scripts/lumibase.mjs create mode 100644 packages/create-lumibase/templates/nextjs/scripts/seed.mjs create mode 100644 packages/create-lumibase/templates/nextjs/scripts/verify.mjs create mode 100644 packages/create-lumibase/templates/nextjs/tsconfig.json diff --git a/packages/create-lumibase/src/nextjs-template.test.ts b/packages/create-lumibase/src/nextjs-template.test.ts new file mode 100644 index 00000000..408f09e7 --- /dev/null +++ b/packages/create-lumibase/src/nextjs-template.test.ts @@ -0,0 +1,140 @@ +/** + * Safety invariants for the Next.js template (#332). + * + * These assert the two properties the starter's whole security story rests on, + * both of which are easy to break with an innocent-looking edit: + * + * 1. No admin credential is reachable from the browser. Next.js inlines every + * `NEXT_PUBLIC_*` variable into the client bundle, so naming a secret with + * that prefix leaks it to every visitor — silently, with no error. + * + * 2. The public read grant carries `publishedOnly`. `GET /api/v1/items` + * applies no implicit published-only filter of its own, so a grant written + * without that flag serves drafts to anonymous readers. The flag currently + * also defaults on for `read` server-side; this test pins the explicit + * request so the starter does not silently depend on that default. + * + * `verify.mjs` proves the same things against a live CMS. This file is the + * cheap half that runs on every commit, with no Docker and no network. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const templateDir = resolve( + dirname(fileURLToPath(import.meta.url)), + '../templates/nextjs', +); + +const read = (rel: string) => readFileSync(join(templateDir, rel), 'utf8'); + +describe('nextjs template — secrets stay server-side', () => { + it('never gives an admin or setup variable a NEXT_PUBLIC_ prefix', () => { + const env = read('_env.example'); + const publicVars = [...env.matchAll(/^(NEXT_PUBLIC_[A-Z0-9_]+)=/gm)].map((m) => m[1]!); + + expect(publicVars.length).toBeGreaterThan(0); + for (const name of publicVars) { + expect( + /ADMIN|SETUP|SECRET|PASSWORD/.test(name), + `${name} is inlined into the client bundle by Next.js — it must not carry a credential`, + ).toBe(false); + } + }); + + it('keeps the admin token out of the browser client', () => { + const client = read('lib/lumibase.ts'); + expect(client).not.toMatch(/LUMIBASE_ADMIN_TOKEN/); + expect(client).toMatch(/NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY/); + }); + + it('declares the admin token without a public prefix', () => { + // Present (the seed script needs it) but server-only. + const env = read('_env.example'); + expect(env).toMatch(/^LUMIBASE_ADMIN_TOKEN=/m); + expect(env).not.toMatch(/NEXT_PUBLIC_LUMIBASE_ADMIN_TOKEN/); + }); +}); + +describe('nextjs template — the public grant cannot leak drafts', () => { + it('requests publishedOnly when granting public read', () => { + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/publishedOnly:\s*true/); + }); + + it('seeds a draft so the leak check has something to catch', () => { + const seed = read('scripts/seed.mjs'); + expect(seed).toMatch(/status:\s*'draft'/); + }); + + it('verifies the publishable key cannot see non-published items', () => { + const verify = read('scripts/verify.mjs'); + expect(verify).toMatch(/status !== 'published'/); + }); + + it('keeps the cross-tenant probe behind an opt-in flag', () => { + // Sending a foreign X-Lumi-Site crashes v1.0.0-rc.1 — the denial is + // audited under the client-supplied site id, which violates a foreign + // key and kills the process. `cms:verify` must not knock over the + // user's own CMS, so the probe stays opt-in until that is fixed. + const verify = read('scripts/verify.mjs'); + expect(verify).toMatch(/LUMIBASE_VERIFY_CROSS_TENANT === '1'/); + }); +}); + +describe('nextjs template — the CMS image is pinned', () => { + it('pulls a published image rather than building from source', () => { + const compose = read('docker-compose.yml'); + expect(compose).toMatch(/image:\s*ghcr\.io\/khuepm\/lumibase-cms[@:]/); + expect(compose, 'the starter must not build the CMS from source').not.toMatch( + /^\s*build:/m, + ); + }); + + it('pins the CMS by digest, not by any tag', () => { + // Tags are the wrong instrument here, and not for style reasons: + // + // - every semver tag (1.0.0-rc.1 included) was built before the CMS could + // serve Studio, so /app/studio is absent and Studio 404s. Verified by + // running the image, not by reading the current Dockerfile — which + // describes today's source, not what an older tag contains. + // - `latest` still points at the 0.x line. + // - `edge` does carry Studio but is rebuilt on every push to main, so it + // would change underneath a user who scaffolded weeks ago. + // + // A digest is immutable and names an artifact proven to contain Studio. + const compose = read('docker-compose.yml'); + const ref = /image:\s*(ghcr\.io\/khuepm\/lumibase-cms\S+)/.exec(compose)?.[1]; + + expect(ref, 'compose must reference the CMS image').toBeTruthy(); + expect( + ref, + `compose pins "${ref}". A tag can move or point at a Studio-less build; ` + + 'pin a sha256 digest that has been verified to contain /app/studio.', + ).toMatch(/@sha256:[0-9a-f]{64}$/); + }); +}); + +describe('nextjs template — package manifest', () => { + it('depends on lumibase at runtime, not as a dev dependency', () => { + // #332: a scaffolded project must actually use LumiBase, not merely + // mention it. A devDependency would not survive into a deployed app. + const manifest = JSON.parse(read('package.json.hbs')) as { + dependencies?: Record; + devDependencies?: Record; + }; + expect(manifest.dependencies?.['lumibase']).toBeTruthy(); + expect(manifest.devDependencies?.['lumibase']).toBeUndefined(); + }); + + it('exposes the bootstrap, seed and verify scripts', () => { + const manifest = JSON.parse(read('package.json.hbs')) as { + scripts?: Record; + }; + for (const script of ['cms:up', 'cms:logs', 'cms:bootstrap', 'cms:seed', 'cms:verify']) { + expect(manifest.scripts?.[script], `missing script: ${script}`).toBeTruthy(); + } + }); +}); diff --git a/packages/create-lumibase/templates/nextjs/README.md.hbs b/packages/create-lumibase/templates/nextjs/README.md.hbs new file mode 100644 index 00000000..d71ca779 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/README.md.hbs @@ -0,0 +1,109 @@ +# {{projectName}} + +A Next.js website backed by LumiBase — CMS, Studio, and seeded content. + +## Quick start + +```bash +cp .env.example .env +npm run cms:up # CMS + Studio + Postgres + Redis +npm run cms:bootstrap # first admin + public read grant + publishable key +npm run cms:seed # sample posts +npm run dev # http://localhost:3000 +``` + +The CMS container runs database migrations itself on first boot, so there is no +separate migrate step. `cms:bootstrap` writes the publishable key back into +`.env` for you. + +| What | Where | +|---|---| +| Website | http://localhost:3000 | +| API | http://localhost:1989 | +| Studio | http://localhost:1989/`LUMIBASE_ADMIN_PATH` | + +Studio ships **inside the CMS image**, so the same container serves both the API +and the admin UI. + +## Try the round trip + +1. Open Studio and sign in as `LUMIBASE_ADMIN_EMAIL`. +2. Edit a post in `posts`, then publish it. +3. Reload the website. The change is there — no rebuild, no mock data. + +## How the browser stays least-privilege + +The website is public: everyone reads it, nobody signs in. So the browser must +never hold an admin credential. Two separate mechanisms make that true. + +**A publishable key, not an admin token.** `cms:bootstrap` creates a key whose +token starts with `lbk_pub_`. It is read-only, locked to +`LUMIBASE_PUBLIC_ORIGIN`, and bound to one site — presented against a different +site it is rejected rather than falling back to its own. The admin token stays in +`LUMIBASE_ADMIN_TOKEN`, which has no `NEXT_PUBLIC_` prefix, so Next.js cannot +inline it into the client bundle. + +**A published-only row filter.** The public grant is created with +`publishedOnly`, which compiles to `status = published`. This matters more than +it looks: `GET /api/v1/items` applies **no** implicit published-only filter of +its own, so a grant made without it would serve drafts to every visitor. + +Both are checked, not just asserted: + +```bash +npm run cms:verify +``` + +It uses the publishable key — never the admin token — to prove it can read +published posts, **cannot** see the seeded draft, and cannot write. The seed +deliberately leaves one post unpublished so this check has something real to +catch. (The cross-tenant probe is opt-in; see Known issues.) + +## Scripts + +| Script | What it does | +|---|---| +| `npm run dev` | Next.js dev server | +| `npm run cms:up` / `cms:down` | start / stop CMS + Postgres + Redis | +| `npm run cms:logs` | follow the CMS logs | +| `npm run cms:bootstrap` | first admin, public read grant, publishable key | +| `npm run cms:seed` | sample posts — re-runnable, never duplicates | +| `npm run cms:verify` | assert the public client is safe | + +`cms:bootstrap` and `cms:seed` are both idempotent, so re-running after a partial +failure is safe. + +## Known issues in v1.0.0-rc.1 + +Two CMS bugs shape this starter. Both are upstream, not in the template: + +- **The setup-token gate locks you out.** `LUMIBASE_REQUIRE_SETUP_TOKEN` makes + `/setup/complete` demand a token that the server never prints — the + mint-and-print helper exists and is unit-tested, but nothing calls it at + startup. The compose file therefore leaves the flag off, and the stack binds + to localhost instead. Run `cms:bootstrap` promptly: until you do, anyone who + can reach port 1989 can claim the admin account. + +- **A forged site header crashes the CMS.** Sending `X-Lumi-Site` for a site + that does not exist correctly returns 401, but the denial is then written to + the audit log under that same id — which no row in `sites` matches, so the + insert violates a foreign key and takes the process down. One request is + enough. `cms:verify` therefore skips its cross-tenant probe by default; opt in + with `LUMIBASE_VERIFY_CROSS_TENANT=1` once this is fixed. + +## Going to production + +This stack is for local development: + +- `JWT_SECRET` and `ENCRYPTION_KEY` in `docker-compose.yml` are dev values — + replace them. +- The CMS image is pinned by **digest**, not by tag, and that is deliberate: + every semver tag so far — `1.0.0-rc.1` included — was built before the CMS + learned to serve Studio, so Studio 404s on them; `latest` points at the 0.x + line; and `edge`, which does carry Studio, is rebuilt on every push to main. + The digest names a build verified to contain Studio and cannot drift. Re-pin + it once a semver release ships Studio. +- Set `LUMIBASE_PUBLIC_ORIGIN` to your real origin before deploying: an empty + allowlist lets a publishable key be used from anywhere. + +Docs → https://docs.lumibase.dev diff --git a/packages/create-lumibase/templates/nextjs/_env.example b/packages/create-lumibase/templates/nextjs/_env.example new file mode 100644 index 00000000..9f5a8349 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/_env.example @@ -0,0 +1,32 @@ +# ─── Browser-visible (NEXT_PUBLIC_*) ───────────────────────────────────────── +# These are inlined into the client bundle. Only ever put least-privilege +# values here: a publishable key, never an admin token. + +NEXT_PUBLIC_LUMIBASE_URL=http://localhost:1989 +NEXT_PUBLIC_LUMIBASE_SITE_ID=__default__ +# Created by `npm run cms:bootstrap`. Starts with `lbk_pub_`, is read-only and +# can only ever see published items. +NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY= + +# ─── Server-only ───────────────────────────────────────────────────────────── +# Never prefixed with NEXT_PUBLIC_, so Next.js cannot leak these to the browser. + +# Only needed if you enable LUMIBASE_REQUIRE_SETUP_TOKEN on the container. +# The default stack does not: v1.0.0-rc.1 gates setup behind the token but +# never prints one, so enabling it locks setup out entirely. +LUMIBASE_SETUP_TOKEN= + +# First administrator, created by `npm run cms:bootstrap`. +LUMIBASE_ADMIN_EMAIL=admin@example.com +# Must be 12+ chars with upper, lower, digit and symbol — the CMS enforces it. +LUMIBASE_ADMIN_PASSWORD=Change-Me-N0w! +# Studio lives at http://localhost:1989/. Keep it unguessable. +LUMIBASE_ADMIN_PATH=admin-a7f3c1 + +# Short-lived admin access token, written by `npm run cms:bootstrap` and used +# only by `npm run cms:seed`. Server-side only. +LUMIBASE_ADMIN_TOKEN= + +# Origin allowed to use the publishable key. An empty allowlist means the key +# works from anywhere, so this is set explicitly. +LUMIBASE_PUBLIC_ORIGIN=http://localhost:3000 diff --git a/packages/create-lumibase/templates/nextjs/_gitignore b/packages/create-lumibase/templates/nextjs/_gitignore new file mode 100644 index 00000000..9d0ac4b4 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/_gitignore @@ -0,0 +1,6 @@ +node_modules +.next +out +.env +*.log +.DS_Store diff --git a/packages/create-lumibase/templates/nextjs/app/globals.css b/packages/create-lumibase/templates/nextjs/app/globals.css new file mode 100644 index 00000000..73e5d61e --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/app/globals.css @@ -0,0 +1,118 @@ +:root { + color-scheme: light dark; + --bg: #fbfbfa; + --fg: #1a1a19; + --muted: #6b6b68; + --line: #e4e4e1; + --card: #ffffff; + --accent: #2f6f4f; + --error-bg: #fdf2f2; + --error-fg: #8a2020; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #121211; + --fg: #ececea; + --muted: #9a9a96; + --line: #2a2a28; + --card: #1a1a19; + --accent: #7fc3a0; + --error-bg: #2a1616; + --error-fg: #f3a9a9; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +.wrap { + max-width: 46rem; + margin: 0 auto; + padding: 4rem 1.5rem; +} + +h1 { + font-size: 2rem; + letter-spacing: -0.02em; + margin: 0 0 0.5rem; +} + +h2 { + font-size: 1.15rem; + margin: 0 0 0.25rem; +} + +.lede { + color: var(--muted); + margin: 0 0 2.5rem; +} + +.posts { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 1rem; +} + +.posts li { + background: var(--card); + border: 1px solid var(--line); + border-radius: 10px; + padding: 1.25rem 1.5rem; +} + +.posts p { + margin: 0.5rem 0 0; +} + +.slug { + color: var(--accent); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + margin: 0 !important; +} + +.empty, +footer p { + color: var(--muted); +} + +footer { + margin-top: 2.5rem; + padding-top: 1.25rem; + border-top: 1px solid var(--line); + font-size: 0.9rem; +} + +code { + background: var(--card); + border: 1px solid var(--line); + border-radius: 5px; + padding: 0.1rem 0.4rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.875em; +} + +ol { + line-height: 2.2; +} + +.error { + background: var(--error-bg); + color: var(--error-fg); + border-radius: 8px; + padding: 1rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/packages/create-lumibase/templates/nextjs/app/layout.tsx b/packages/create-lumibase/templates/nextjs/app/layout.tsx new file mode 100644 index 00000000..2a0bc8f6 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'My LumiBase site', + description: 'A Next.js website powered by LumiBase.', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/packages/create-lumibase/templates/nextjs/app/page.tsx b/packages/create-lumibase/templates/nextjs/app/page.tsx new file mode 100644 index 00000000..4f274ca0 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/app/page.tsx @@ -0,0 +1,91 @@ +import { getPosts, isConfigured, type Post } from '../lib/lumibase'; + +// Always hit the CMS, so a publish in Studio shows up on the next reload +// instead of being served from a build-time snapshot. +export const dynamic = 'force-dynamic'; + +function Setup() { + return ( +
+

Almost there

+

This site is not connected to a LumiBase instance yet.

+
    +
  1. + cp .env.example .env +
  2. +
  3. + npm run cms:up — starts the CMS and Studio +
  4. +
  5. + npm run cms:logs — copy the SETUP_TOKEN into{' '} + .env +
  6. +
  7. + npm run cms:bootstrap then npm run cms:seed +
  8. +
+
+ ); +} + +export default async function Home() { + if (!isConfigured) return ; + + let posts: Post[] = []; + let error: string | null = null; + + try { + posts = await getPosts(); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + + if (error) { + return ( +
+

Could not reach the CMS

+
{error}
+

+ Is it running? Try npm run cms:up, then{' '} + npm run cms:logs. +

+
+ ); + } + + return ( +
+
+

My LumiBase site

+

+ Rendered by Next.js, served by LumiBase. Edit a post in Studio, publish + it, and reload this page. +

+
+ + {posts.length === 0 ? ( +

+ No published posts yet. Run npm run cms:seed, or write one + in Studio. +

+ ) : ( +
    + {posts.map((post) => ( +
  • +

    {post.data.title ?? 'Untitled'}

    + {post.data.slug ?

    /{post.data.slug}

    : null} + {post.data.body ?

    {post.data.body}

    : null} +
  • + ))} +
+ )} + +
+

+ {posts.length} published post{posts.length === 1 ? '' : 's'}. Drafts are + never returned here — the public key is restricted to published rows. +

+
+
+ ); +} diff --git a/packages/create-lumibase/templates/nextjs/docker-compose.yml b/packages/create-lumibase/templates/nextjs/docker-compose.yml new file mode 100644 index 00000000..a52b73e0 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/docker-compose.yml @@ -0,0 +1,84 @@ +# LumiBase CMS + Studio for local development. +# +# This pulls a PUBLISHED image rather than building from source, pinned by +# DIGEST rather than by tag. That is deliberate, and the reason is specific: +# +# - `1.0.0-rc.1` and every other semver tag were built before the CMS learned +# to serve Studio, so `/app/studio` does not exist in them. Studio would +# simply 404. +# - `latest` still points at the 0.x line — a major behind the scaffolder. +# - `edge` does contain Studio, but it is a moving tag: it is rebuilt on every +# push to main, so it would silently change underneath you. +# +# The digest below is the `edge` build of commit 683a0270, verified to contain +# /app/studio/index.html. It cannot drift. Re-pin it when a semver release +# finally ships Studio. +# +# The image serves BOTH the REST API and the Studio SPA on the same port, so +# there is no second container to run. + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: lumibase + POSTGRES_USER: lumibase + POSTGRES_PASSWORD: lumibase_dev + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lumibase"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + cms: + image: ghcr.io/khuepm/lumibase-cms@sha256:3f125caabb455bd66cdbece68c7af82a65fea3e7f30db0938ac8219701577ebf + environment: + LUMIBASE_RUNTIME: docker + DATABASE_URL: postgresql://lumibase:lumibase_dev@postgres:5432/lumibase + PORT: "1989" + # Local development values. Both are REQUIRED for login to work at all + # (the CMS refuses to sign a JWT without a secret). Replace them before + # this stack is ever reachable from anywhere but your machine. + JWT_SECRET: dev_secret_key + ENCRYPTION_KEY: dev_secret_key + # Deliberately NOT setting LUMIBASE_REQUIRE_SETUP_TOKEN here. + # + # The CMS can gate setup behind a one-time token, but v1.0.0-rc.1 never + # prints it: the mint-and-print helper exists and is unit-tested, yet + # nothing calls it at startup. Turning the flag on therefore makes + # /setup/complete answer SETUP_TOKEN_REQUIRED forever, with no way to + # obtain the token — a locked-out instance, not a hardened one. + # + # This stack is bound to localhost, so the exposure is a local port + # rather than the internet. Run `npm run cms:bootstrap` promptly: until + # you do, anyone who can reach :1989 can claim the admin account. + # The website runs on :3000 and calls the API on :1989 — a cross-origin + # pair, so the browser sends a preflight the CMS must accept. + CORS_ALLOWED_ORIGINS: ${LUMIBASE_PUBLIC_ORIGIN:-http://localhost:3000} + # Without this the Docker runtime falls back to 127.0.0.1:6379 and floods + # the log with ECONNREFUSED, which buries anything worth reading. + REDIS_URL: redis://redis:6379 + ports: + - "${CMS_PORT:-1989}:1989" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + +volumes: + pgdata: diff --git a/packages/create-lumibase/templates/nextjs/lib/lumibase.ts b/packages/create-lumibase/templates/nextjs/lib/lumibase.ts new file mode 100644 index 00000000..1ed6f0c6 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/lib/lumibase.ts @@ -0,0 +1,55 @@ +/** + * The LumiBase client this website reads content with. + * + * Everything here is least-privilege on purpose: + * + * - the token is a PUBLISHABLE key (`lbk_pub_`), not an admin token. It is + * read-only and origin-locked, so it is safe to ship to a browser; + * - the public grant behind it carries a `status = published` row filter, so + * this client cannot see drafts even if it asks for them. + * + * If you ever need admin-level reads or writes, do them in a Server Component, + * a Route Handler, or a script — with a server-only variable that has no + * `NEXT_PUBLIC_` prefix — never with this client. + */ + +import { createLumiClient, readItems } from 'lumibase'; + +export interface Post { + id: string; + status: string; + data: { + title?: string; + slug?: string; + body?: string; + }; +} + +const url = process.env.NEXT_PUBLIC_LUMIBASE_URL; +const siteId = process.env.NEXT_PUBLIC_LUMIBASE_SITE_ID; +const token = process.env.NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY; + +export const isConfigured = Boolean(url && siteId && token); + +export const lumibase = createLumiClient({ + url: url ?? 'http://localhost:1989', + siteId: siteId ?? '__default__', + // `token` is required by the client type. A publishable key travels over the + // same `Authorization: Bearer` header as any other credential, so it drops + // straight in here. + token: token ?? '', +}); + +/** + * Fetch posts for the homepage. + * + * `status: 'published'` is belt-and-braces: the server-side grant already + * restricts this key to published rows. Asking explicitly means the intent is + * visible in the code too. + */ +export async function getPosts(): Promise { + const res = await lumibase.request( + readItems('posts', { limit: 50, sort: ['-created_at'], status: 'published' }), + ); + return ((res as { data?: Post[] })?.data ?? []) as Post[]; +} diff --git a/packages/create-lumibase/templates/nextjs/next.config.mjs b/packages/create-lumibase/templates/nextjs/next.config.mjs new file mode 100644 index 00000000..d5456a15 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/packages/create-lumibase/templates/nextjs/package.json.hbs b/packages/create-lumibase/templates/nextjs/package.json.hbs new file mode 100644 index 00000000..56bb55e6 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/package.json.hbs @@ -0,0 +1,29 @@ +{ + "name": "{{projectName}}", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "cms:up": "docker compose up -d", + "cms:down": "docker compose down", + "cms:logs": "docker compose logs -f cms", + "cms:bootstrap": "node --env-file=.env scripts/bootstrap.mjs", + "cms:seed": "node --env-file=.env scripts/seed.mjs", + "cms:verify": "node --env-file=.env scripts/verify.mjs" + }, + "dependencies": { + "lumibase": "^1.0.0-rc.1", + "next": "^15.5.4", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5.6.2" + } +} diff --git a/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs new file mode 100644 index 00000000..083731e4 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs @@ -0,0 +1,210 @@ +/** + * One-time provisioning: first admin, public access, and a browser-safe key. + * + * Run once after `npm run cms:up`: + * + * npm run cms:bootstrap + * + * Idempotent. The CMS answers ALREADY_INITIALIZED once setup has run, and every + * later step either upserts or is safe to repeat, so re-running after a partial + * failure picks up where it stopped. + * + * ## The security-critical part + * + * The website is a public site: anyone can read it, nobody logs in. That means + * the browser must NOT hold an admin credential. Two things make that true: + * + * 1. The browser gets a *publishable* key (`lbk_pub_`), never the admin token. + * The admin token exists only in this process and in `LUMIBASE_ADMIN_TOKEN`, + * which has no `NEXT_PUBLIC_` prefix and so cannot reach the bundle. + * + * 2. The grant is `publishedOnly`, which compiles to the row filter + * `status = published`. Without it a read grant would also serve drafts: + * `GET /api/v1/items` applies no implicit published-only filter of its own. + * `verify.mjs` asserts a draft stays invisible. + */ + +import { + api, + login, + waitForCms, + updateEnvFile, + requireEnv, + COLLECTION, + CmsError, +} from './lumibase.mjs'; + +const ADMIN_EMAIL = requireEnv('LUMIBASE_ADMIN_EMAIL'); +const ADMIN_PASSWORD = requireEnv('LUMIBASE_ADMIN_PASSWORD'); +const ADMIN_PATH = process.env.LUMIBASE_ADMIN_PATH || 'admin-a7f3c1'; +const PUBLIC_ORIGIN = process.env.LUMIBASE_PUBLIC_ORIGIN || 'http://localhost:3000'; +const SETUP_TOKEN = process.env.LUMIBASE_SETUP_TOKEN; + +const step = (n, msg) => console.log(`\n[${n}/6] ${msg}`); + +async function runSetup() { + step(1, 'Creating the first administrator…'); + + // `/setup/state` answers with the bare object, not the `{ data }` envelope. + const state = await api('/api/v1/setup/state'); + if (state?.state === 'initialized') { + console.log(' already initialized — skipping'); + return; + } + + // Only sent when the instance actually asks for it. The default stack does + // not enable the setup-token gate — see the comment in docker-compose.yml. + if (state?.requiresSetupToken && !SETUP_TOKEN) { + throw new Error( + 'This CMS requires a setup token, but LUMIBASE_SETUP_TOKEN is not set.\n' + + 'Note that v1.0.0-rc.1 never prints one: the flag gates setup without\n' + + 'any way to obtain the token. Unset LUMIBASE_REQUIRE_SETUP_TOKEN on the\n' + + 'container, recreate it, and run this again.', + ); + } + + await api('/api/v1/setup/complete', { + method: 'POST', + body: { + ...(SETUP_TOKEN ? { setupToken: SETUP_TOKEN } : {}), + account: { + email: ADMIN_EMAIL, + password: ADMIN_PASSWORD, + firstName: 'Site', + lastName: 'Admin', + }, + adminPath: ADMIN_PATH, + project: { + defaultLanguage: 'en', + siteUrl: PUBLIC_ORIGIN, + displayTitle: 'My LumiBase site', + }, + }, + }); + console.log(' done'); +} + +async function ensureCollection(token) { + step(3, `Creating the "${COLLECTION}" collection…`); + try { + await api('/api/v1/collections', { + method: 'POST', + token, + body: { + name: COLLECTION, + displayTemplate: '{{title}}', + fields: [ + { name: 'title', type: 'string', interface: 'input', required: true }, + { name: 'slug', type: 'string', interface: 'input', required: true }, + { name: 'body', type: 'text', interface: 'textarea' }, + ], + }, + }); + console.log(' done'); + } catch (err) { + // A second run finds it already there. Anything else is a real failure. + if (err instanceof CmsError && (err.status === 409 || err.status === 422)) { + console.log(' already exists — skipping'); + return; + } + throw err; + } +} + +async function enablePublicRead(token) { + step(4, 'Enabling public read access (published items only)…'); + + // Provisioning the anonymous realm is a deliberate, audited act — a grant + // will not do it as a side effect. + const enabled = await api('/api/v1/access/grants/public/enable', { + method: 'POST', + token, + }); + const roleId = enabled?.data?.roleId; + if (!roleId) throw new Error('Enabling public access returned no roleId.'); + + await api('/api/v1/access/grants/public', { + method: 'POST', + token, + body: { + collection: COLLECTION, + action: 'read', + // Explicit, even though `read` defaults it on. This is the line that + // keeps drafts off the public website; it should not depend on a + // server-side default staying what it is today. + publishedOnly: true, + fields: ['title', 'slug', 'body', 'status'], + }, + }); + + console.log(' done — anonymous readers see published posts only'); + return roleId; +} + +async function createPublishableKey(token, roleId) { + step(5, 'Creating a publishable (browser-safe) API key…'); + + const created = await api('/api/v1/api-keys', { + method: 'POST', + token, + body: { + name: 'Website (publishable)', + description: 'Read-only key embedded in the Next.js site.', + publishable: true, + // An EMPTY allowlist means the key works from anywhere, so it is always + // set explicitly here. + allowedOrigins: [PUBLIC_ORIGIN], + }, + }); + + const keyId = created?.data?.id; + const plaintext = created?.data?.token; + if (!keyId || !plaintext) { + throw new Error('Key creation returned no token — it is shown only once.'); + } + + // A key with no role carries no permissions at all: an api_key principal is + // built with `roles: []`, so it does not inherit the anonymous realm. + await api(`/api/v1/api-keys/${keyId}/roles`, { + method: 'POST', + token, + body: { roleId }, + }); + + console.log(' done'); + return plaintext; +} + +async function main() { + console.log('Bootstrapping LumiBase…'); + await waitForCms(); + + await runSetup(); + + step(2, 'Logging in…'); + const token = await login(ADMIN_EMAIL, ADMIN_PASSWORD); + console.log(' done'); + + await ensureCollection(token); + const roleId = await enablePublicRead(token); + const publishableKey = await createPublishableKey(token, roleId); + + step(6, 'Writing .env…'); + await updateEnvFile({ + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: publishableKey, + LUMIBASE_ADMIN_TOKEN: token, + }); + console.log(' done'); + + console.log('\n✔ Bootstrap complete.\n'); + console.log(' Next:'); + console.log(' npm run cms:seed # add sample posts'); + console.log(' npm run dev # start the website\n'); + console.log(` Studio → ${process.env.NEXT_PUBLIC_LUMIBASE_URL}/${ADMIN_PATH}`); + console.log(` Sign in as ${ADMIN_EMAIL}\n`); +} + +main().catch((err) => { + console.error(`\n✖ Bootstrap failed: ${err.message}\n`); + process.exit(1); +}); diff --git a/packages/create-lumibase/templates/nextjs/scripts/lumibase.mjs b/packages/create-lumibase/templates/nextjs/scripts/lumibase.mjs new file mode 100644 index 00000000..b33f581d --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/scripts/lumibase.mjs @@ -0,0 +1,133 @@ +/** + * Shared HTTP helpers for the bootstrap and seed scripts. + * + * These run on the server only. The admin token they use is deliberately never + * exposed to the browser: it lives in `LUMIBASE_ADMIN_TOKEN` (no `NEXT_PUBLIC_` + * prefix), so Next.js cannot inline it into the client bundle. + */ + +import { readFile, writeFile } from 'node:fs/promises'; + +export const CMS_URL = ( + process.env.NEXT_PUBLIC_LUMIBASE_URL ?? 'http://localhost:1989' +).replace(/\/$/, ''); + +export const SITE_ID = process.env.NEXT_PUBLIC_LUMIBASE_SITE_ID ?? '__default__'; + +/** The collection this starter ships. One collection, three fields. */ +export const COLLECTION = 'posts'; + +export class CmsError extends Error { + constructor(status, body, path) { + // A validation failure carries its reasons in `details`, not `message` — + // without this the error reads "VALIDATION_ERROR:" and says nothing. + const detail = + body?.errors + ?.map((e) => { + const reasons = Array.isArray(e.details) + ? e.details + .map((d) => `${Array.isArray(d.path) ? d.path.join('.') : d.path ?? ''} ${d.message ?? ''}`.trim()) + .join(', ') + : ''; + return [e.code ?? 'ERROR', e.message || reasons].filter(Boolean).join(': '); + }) + .join('; ') ?? (typeof body === 'string' ? body : JSON.stringify(body)); + super(`${path} → ${status} ${detail}`); + this.name = 'CmsError'; + this.status = status; + this.body = body; + } +} + +/** + * Call the CMS. + * + * `token` is optional so the same helper can drive the unauthenticated setup + * wizard and, later, the authenticated admin calls. + */ +export async function api(path, { method = 'GET', body, token, headers = {} } = {}) { + const res = await fetch(`${CMS_URL}${path}`, { + method, + headers: { + 'x-lumi-site': SITE_ID, + ...(body ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...headers, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + + if (!res.ok) throw new CmsError(res.status, parsed, path); + return parsed; +} + +/** Wait for the CMS to answer /health — the container runs migrations first. */ +export async function waitForCms({ attempts = 60, delayMs = 2000 } = {}) { + for (let i = 1; i <= attempts; i += 1) { + try { + const res = await fetch(`${CMS_URL}/health`); + if (res.ok) return; + } catch { + // connection refused while the container is still starting + } + if (i === attempts) { + throw new Error( + `CMS did not become healthy at ${CMS_URL} after ${attempts} attempts.\n` + + 'Is it running? Try: npm run cms:up && npm run cms:logs', + ); + } + await new Promise((r) => setTimeout(r, delayMs)); + } +} + +/** Log in and return a short-lived admin access token. */ +export async function login(email, password) { + const out = await api('/api/v1/auth/login', { + method: 'POST', + body: { email, password }, + }); + const token = out?.data?.token; + if (!token) throw new Error('Login succeeded but returned no access token.'); + return token; +} + +/** + * Persist values back into `.env`. + * + * Rewrites keys in place when present and appends them otherwise, so running + * bootstrap twice updates rather than duplicates. + */ +export async function updateEnvFile(updates, file = '.env') { + let content = ''; + try { + content = await readFile(file, 'utf8'); + } catch { + content = ''; + } + + for (const [key, value] of Object.entries(updates)) { + const line = `${key}=${value}`; + const pattern = new RegExp(`^${key}=.*$`, 'm'); + content = pattern.test(content) + ? content.replace(pattern, line) + : `${content.replace(/\n*$/, '\n')}${line}\n`; + } + + await writeFile(file, content, 'utf8'); +} + +export function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is not set. Fill it in .env (see .env.example).`); + } + return value; +} diff --git a/packages/create-lumibase/templates/nextjs/scripts/seed.mjs b/packages/create-lumibase/templates/nextjs/scripts/seed.mjs new file mode 100644 index 00000000..a5aa4614 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/scripts/seed.mjs @@ -0,0 +1,75 @@ +/** + * Seed sample posts. Safe to run repeatedly. + * + * npm run cms:seed + * + * Idempotence is by slug, not by database id: the script reads what is already + * in the collection and only creates what is missing. Running it twice leaves + * three posts, not six. + * + * One post is deliberately left as a draft. It is what proves the public + * website cannot see unpublished content — see `npm run cms:verify`. + */ + +import { api, requireEnv, waitForCms, COLLECTION } from './lumibase.mjs'; + +const POSTS = [ + { + slug: 'hello-lumibase', + title: 'Hello, LumiBase', + body: 'This post is served by LumiBase and rendered by Next.js. Edit it in Studio, hit publish, then reload this page — the change is live.', + status: 'published', + }, + { + slug: 'editing-in-studio', + title: 'Editing in Studio', + body: 'Studio ships inside the same container as the API, so there is no second service to run. Your edits reach this website through a read-only publishable key.', + status: 'published', + }, + { + slug: 'this-post-is-a-draft', + title: 'This post is a draft', + body: 'You can read this in Studio, but the website must never show it. The public grant carries a `status = published` row filter, which is what keeps drafts private.', + status: 'draft', + }, +]; + +async function main() { + const token = requireEnv('LUMIBASE_ADMIN_TOKEN'); + await waitForCms(); + + console.log(`Seeding "${COLLECTION}"…\n`); + + // Ask for both statuses so an existing draft counts as already-seeded. + const existing = await api(`/api/v1/items/${COLLECTION}?limit=200`, { token }); + const bySlug = new Set( + (existing?.data ?? []).map((item) => item?.slug ?? item?.data?.slug).filter(Boolean), + ); + + let created = 0; + for (const post of POSTS) { + if (bySlug.has(post.slug)) { + console.log(` = ${post.slug} (already there)`); + continue; + } + + const { status, ...data } = post; + await api(`/api/v1/items/${COLLECTION}`, { + method: 'POST', + token, + body: { data, status }, + }); + console.log(` + ${post.slug} (${status})`); + created += 1; + } + + console.log( + `\n✔ Seed complete — ${created} created, ${POSTS.length - created} already present.`, + ); + console.log(' Run it again: nothing is duplicated.\n'); +} + +main().catch((err) => { + console.error(`\n✖ Seed failed: ${err.message}\n`); + process.exit(1); +}); diff --git a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs new file mode 100644 index 00000000..9aff1eb6 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs @@ -0,0 +1,110 @@ +/** + * Prove the public setup is actually safe. + * + * npm run cms:verify + * + * Four assertions, all made with the publishable key the browser holds — never + * with the admin token: + * + * 1. The key can read published posts. + * 2. The key CANNOT see the draft. + * 3. The key cannot write. + * 4. The key cannot read another tenant's content. + * + * (2) is the one worth keeping. `GET /api/v1/items` has no implicit + * published-only filter, so a read grant made without `publishedOnly` would + * serve drafts to every visitor. This test fails loudly if that protection is + * ever removed. + */ + +import { api, requireEnv, waitForCms, CmsError, COLLECTION } from './lumibase.mjs'; + +const PUBLIC_ORIGIN = process.env.LUMIBASE_PUBLIC_ORIGIN || 'http://localhost:3000'; + +let failures = 0; + +function check(name, ok, detail = '') { + console.log(` ${ok ? '✔' : '✖'} ${name}${detail ? ` — ${detail}` : ''}`); + if (!ok) failures += 1; +} + +async function main() { + const key = requireEnv('NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY'); + await waitForCms(); + + console.log('\nVerifying the public client…\n'); + + // The publishable key is origin-locked, so send the Origin the browser sends. + const asPublic = (path, init = {}) => + api(path, { ...init, token: key, headers: { origin: PUBLIC_ORIGIN, ...init.headers } }); + + // 1 — can read published content + const list = await asPublic(`/api/v1/items/${COLLECTION}?limit=100`); + const items = list?.data ?? []; + check('publishable key reads published posts', items.length > 0, `${items.length} item(s)`); + + // 2 — cannot see drafts + const statuses = [...new Set(items.map((i) => i?.status).filter(Boolean))]; + const leaked = items.filter((i) => i?.status && i.status !== 'published'); + check( + 'draft posts are NOT visible to the public key', + leaked.length === 0, + leaked.length === 0 + ? `only saw: ${statuses.join(', ') || 'published'}` + : `LEAKED ${leaked.length} non-published item(s)`, + ); + + // 3 — cannot write + let wrote = false; + try { + await asPublic(`/api/v1/items/${COLLECTION}`, { + method: 'POST', + body: { data: { title: 'should not exist', slug: 'should-not-exist' } }, + }); + wrote = true; + } catch (err) { + if (!(err instanceof CmsError)) throw err; + } + check('publishable key cannot create items', !wrote); + + // 4 — cannot cross tenants. + // + // Skipped by default, and that is deliberate. Presenting the key with a + // foreign X-Lumi-Site does correctly return 401 — but on v1.0.0-rc.1 it also + // CRASHES the CMS: the denial is written to the audit log under the + // client-supplied site id, which no row in `sites` matches, so the insert + // violates a foreign key and takes the process down. One request from an + // unauthenticated caller is enough. + // + // Running this check would therefore knock over your own container. Opt in + // with LUMIBASE_VERIFY_CROSS_TENANT=1 once that is fixed upstream. + if (process.env.LUMIBASE_VERIFY_CROSS_TENANT === '1') { + let crossed = false; + try { + await api(`/api/v1/items/${COLLECTION}?limit=1`, { + token: key, + headers: { origin: PUBLIC_ORIGIN, 'x-lumi-site': 'some-other-site' }, + }); + crossed = true; + } catch (err) { + if (!(err instanceof CmsError)) throw err; + } + check('publishable key cannot read another site', !crossed); + } else { + console.log( + ' · cross-tenant check skipped (it crashes v1.0.0-rc.1 — ' + + 'set LUMIBASE_VERIFY_CROSS_TENANT=1 to run it anyway)', + ); + } + + if (failures > 0) { + console.error(`\n✖ ${failures} check(s) failed.\n`); + process.exit(1); + } + console.log('\n✔ All checks passed.\n'); +} + +main().catch((err) => { + console.error(`\n✖ Verification failed: ${err.message}\n`); + process.exit(1); +}); diff --git a/packages/create-lumibase/templates/nextjs/tsconfig.json b/packages/create-lumibase/templates/nextjs/tsconfig.json new file mode 100644 index 00000000..f13bc903 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "ES2022"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 59c78ddf62e4db0fcb39c51412adbb1490c12d27 Mon Sep 17 00:00:00 2001 From: Javier Date: Sat, 12 Sep 2026 19:43:21 +0700 Subject: [PATCH 03/26] feat(create-lumibase): offer the Next.js template and reject unknown ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new template into the scaffolder: a third member of the Template union, a prompt choice, an `isNextjs` context flag, and next-steps output that matches how this template is actually driven (the CMS image runs its own migrations, so there is no migrate step, but there is a bootstrap one). `lumibase init` needs no change — it shells out to create-lumibase at the CLI's own version, so the two entrypoints cannot drift. Also validates `--template`. A bad name used to reach scaffold() unchecked and die on a missing directory, surfacing as an ENOENT naming an internal path several steps from the typo that caused it. With three templates to misspell that is worth catching where the name is still in hand. Refs #332 --- packages/create-lumibase/src/index.ts | 22 +++++++++++-- packages/create-lumibase/src/scaffold.ts | 1 + .../create-lumibase/src/templates.test.ts | 2 +- packages/create-lumibase/src/utils/print.ts | 33 +++++++++++++++---- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/create-lumibase/src/index.ts b/packages/create-lumibase/src/index.ts index 98a9654e..9c75277d 100644 --- a/packages/create-lumibase/src/index.ts +++ b/packages/create-lumibase/src/index.ts @@ -11,7 +11,10 @@ import { installDependencies } from './install.js'; import { initGit } from './git.js'; import { printNextSteps } from './utils/print.js'; -export type Template = 'default' | 'cloudflare'; +export type Template = 'default' | 'cloudflare' | 'nextjs'; + +/** Every template name `--template` accepts. Keep in step with `templates/`. */ +export const TEMPLATES = ['nextjs', 'default', 'cloudflare'] as const satisfies readonly Template[]; export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun'; export interface ProjectConfig { @@ -77,6 +80,17 @@ async function main() { } // --- template --- + // A bad `--template` used to reach `scaffold()` unchecked and die on a + // missing directory — an ENOENT naming an internal path, several steps away + // from the typo that caused it. Catch it here while the name is still in hand. + if (argv.template !== undefined && !TEMPLATES.includes(argv.template as Template)) { + console.error( + pc.red(`✖ Unknown template: ${String(argv.template)}`) + + pc.dim(` (expected one of: ${TEMPLATES.join(', ')})`), + ); + process.exit(1); + } + const template: Template = (argv.template as Template | undefined) ?? (( @@ -87,7 +101,11 @@ async function main() { message: 'Deployment target:', choices: [ { - title: `${pc.bold('Docker')} ${pc.dim('Node.js + PostgreSQL (recommended)')}`, + title: `${pc.bold('Next.js website')} ${pc.dim('+ CMS, Studio and seed content (recommended)')}`, + value: 'nextjs', + }, + { + title: `${pc.bold('Docker')} ${pc.dim('Node.js + PostgreSQL')}`, value: 'default', }, { diff --git a/packages/create-lumibase/src/scaffold.ts b/packages/create-lumibase/src/scaffold.ts index 1ecc7a9e..61ec0ecc 100644 --- a/packages/create-lumibase/src/scaffold.ts +++ b/packages/create-lumibase/src/scaffold.ts @@ -42,6 +42,7 @@ function buildTemplateContext(config: ProjectConfig): Record { packageManager: config.packageManager, isCloudflare: config.template === 'cloudflare', isDefault: config.template === 'default', + isNextjs: config.template === 'nextjs', year: new Date().getFullYear(), }; } diff --git a/packages/create-lumibase/src/templates.test.ts b/packages/create-lumibase/src/templates.test.ts index 1387b0aa..ea679295 100644 --- a/packages/create-lumibase/src/templates.test.ts +++ b/packages/create-lumibase/src/templates.test.ts @@ -46,7 +46,7 @@ function majorOf(range: string): number { } describe('template manifests', () => { - it.each(['default', 'cloudflare'])('%s parses as JSON and names the project', (template) => { + it.each(['default', 'cloudflare', 'nextjs'])('%s parses as JSON and names the project', (template) => { const manifest = readTemplateManifest(template) as { name?: string }; expect(manifest.name).toBe('{{projectName}}'); }); diff --git a/packages/create-lumibase/src/utils/print.ts b/packages/create-lumibase/src/utils/print.ts index 1396fff4..9767d426 100644 --- a/packages/create-lumibase/src/utils/print.ts +++ b/packages/create-lumibase/src/utils/print.ts @@ -4,7 +4,8 @@ import type { ProjectConfig } from '../index.js'; export function printNextSteps(config: ProjectConfig) { const { projectName, packageManager, installDeps, template } = config; const isCurrentDir = projectName === '.'; - const devCmd = template === 'cloudflare' ? `${packageManager} run dev` : 'docker compose up -d && pnpm dev'; + const run = (script: string) => + packageManager === 'npm' ? `npm run ${script}` : `${packageManager} ${script}`; console.log(); console.log(pc.bold(pc.green('✔ Project created!'))); @@ -24,17 +25,37 @@ export function printNextSteps(config: ProjectConfig) { console.log(` ${step++}. ${pc.cyan(`${packageManager} install`)}`); } + if (template === 'nextjs') { + // The CMS image runs its own migrations on boot, so there is no migrate + // step here. + console.log(` ${step++}. ${pc.cyan(run('cms:up'))} ${pc.dim('← CMS + Studio + Postgres')}`); + console.log(` ${step++}. ${pc.cyan(run('cms:bootstrap'))} ${pc.dim('← admin + publishable key')}`); + console.log(` ${step++}. ${pc.cyan(run('cms:seed'))} ${pc.dim('← sample posts')}`); + console.log(` ${step++}. ${pc.cyan(run('dev'))}`); + + console.log(); + console.log(pc.dim(' Website ') + pc.underline('http://localhost:3000')); + console.log(pc.dim(' API ') + pc.underline('http://localhost:1989')); + console.log(pc.dim(' Studio ') + pc.underline('http://localhost:1989/')); + console.log(); + console.log(pc.dim(` Check the public client is safe: ${pc.cyan(run('cms:verify'))}`)); + console.log(); + console.log(pc.dim(' Docs → https://docs.lumibase.dev')); + console.log(); + return; + } + if (template === 'default') { console.log(` ${step++}. ${pc.cyan('docker compose up -d')} ${pc.dim('← starts Postgres + Redis')}`); - console.log(` ${step++}. ${pc.cyan(`${packageManager} run db:migrate`)}`); + console.log(` ${step++}. ${pc.cyan(run('db:migrate'))}`); } - console.log(` ${step++}. ${pc.cyan(devCmd)}`); - - console.log(); console.log( - pc.dim(' API ') + pc.underline('http://localhost:8787'), + ` ${step++}. ${pc.cyan(template === 'cloudflare' ? run('dev') : 'docker compose up -d && pnpm dev')}`, ); + + console.log(); + console.log(pc.dim(' API ') + pc.underline('http://localhost:8787')); console.log(); console.log(pc.dim(' Docs → https://lumibase.dev/docs')); console.log(); From 3e5e9cf81e32cb36d47d28c22b53efdd56177b0b Mon Sep 17 00:00:00 2001 From: Javier Date: Sat, 12 Sep 2026 19:43:33 +0700 Subject: [PATCH 04/26] docs(spec): record what running the starter actually proved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the contract from proposal to implemented, and corrects the part of it that was wrong. The original said the published image carried Studio. It does not. That claim came from reading today's docker/Dockerfile, which describes today's source rather than the contents of a tag built earlier — the commit adding Studio landed 2026-09-07, four days after 1.0.0-rc.1 was built. Running the image settles it: no /app/studio in any semver tag. Hence the digest pin. Also records two upstream CMS bugs found by running the thing rather than reading it, both worked around here rather than fixed (apps/cms is out of scope for #332): - LUMIBASE_REQUIRE_SETUP_TOKEN gates setup behind a token the server never prints; printSetupTokenIfRequired is unit-tested but called from nowhere, so enabling the flag locks setup out permanently. - a forged X-Lumi-Site crashes the CMS. The tenant middleware shape-checks the header without confirming the site exists, and the denial is then audited under that same id, violating a foreign key and killing the process. One unauthenticated request is enough, which is why cms:verify keeps its cross-tenant probe behind an opt-in flag. Refs #332 --- .kiro/specs/nextjs-starter-contract/design.md | 141 +++++++++++++----- 1 file changed, 107 insertions(+), 34 deletions(-) diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index 5abfe4a2..2fc4f174 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -1,13 +1,13 @@ # Design Document — Next.js starter contract (#332, handoff A-02) -> **Trạng thái: ĐỀ XUẤT — chưa được cấp grant, chưa implement.** -> Handoff A-02 (#332) yêu cầu chốt contract *trước* khi viết code. Tài liệu này -> là đầu ra của bước đó: đề xuất template, hai đường backend, mô hình nội dung -> và public client, kèm bảng file/env/lệnh xin cấp phát. +> **Trạng thái: ĐÃ IMPLEMENT.** Owner chỉ đạo triển khai luôn, không chờ +> reviewer (2026-09-12), nên 4 điểm chặn ở §8 được quyết theo đúng đề xuất. +> +> Contract này được giữ lại làm tài liệu thiết kế. Phần đã chạy thật và bằng +> chứng nằm ở §9. > > Baseline: main `6a20441af5dde899b976479f0ed7f8d1a9341dee`. -> Mọi khẳng định dưới đây đã verify trên source tree / registry và có trích dẫn. -> Chỗ chưa verify được ghi rõ `[Unverified]`. +> Mọi khẳng định đã verify trên source tree / registry / instance chạy thật. ## 1. Tổng quan @@ -69,14 +69,30 @@ Các fact dưới đây **đã verify**, không phải suy đoán: | `1.0.0` | 404 | | `1.0` | 404 | -- ⇒ **Template phải pin `1.0.0-rc.1`, không dùng `latest`.** Repo đang ở - `1.0.0-rc.1` (root `package.json`), trong khi `latest` còn trỏ dòng 0.x (tag - list có tới `0.26.0`). Dùng `latest` là lệch major so với RC. +- ⚠️ **KHÔNG semver tag nào chứa Studio.** Bản contract đầu tiên của tôi đề + xuất pin `1.0.0-rc.1` và khẳng định image có Studio — **sai**, reviewer bắt + đúng. Tôi suy ra điều đó từ `docker/Dockerfile` *hiện tại*, nhưng Dockerfile + hiện tại không mô tả nội dung một tag đã build từ trước. + + Kiểm chứng bằng cách chạy chính image đó (`ls /app/studio`): + + | tag | Studio | ghi chú | + |---|---|---| + | `1.0.0-rc.1` | ✖ không | build 2026-09-03 | + | `latest` / `0.26.0` | ✖ không | dòng 0.x | + | `edge` | ✔ có | revision `683a0270`, nhưng tag trôi nổi | -- **Image có Studio đi kèm** — một image, cả CMS lẫn Studio: - `docker/Dockerfile:21` copy `apps/studio/`, `:30` build - `pnpm --filter @lumibase/studio build`, `:43` copy `dist` → `/app/studio`. - Runtime mount tại `apps/cms/src/serve.ts:81` qua `mountStudio`; env + Lý do: commit thêm Studio (`2bd5b0ab`) là **2026-09-07**, còn image + `1.0.0-rc.1` build **2026-09-03** — sau 4 ngày. Đúng cái bẫy handoff cảnh báo. + +- ⇒ **Pin theo digest**, không theo tag: `edge` có Studio nhưng rebuild mỗi lần + push main; semver thì không có Studio. Digest + `sha256:3f125caa…` bất biến và đã kiểm chứng có `/app/studio/index.html`. + Chạy thật: log in `[lumibase-cms] Serving Studio from /app/studio`, + `GET /` trả 200 `text/html` với `LumiBase Studio`, + và `/api/v1/*` vẫn trả JSON `{errors}` chứ không bị SPA catch-all nuốt. + +- Cơ chế phục vụ Studio: `apps/cms/src/serve.ts:81` gọi `mountStudio`; env `LUMIBASE_SERVE_STUDIO` (tắt) và `LUMIBASE_STUDIO_DIST` (đổi path) — `apps/cms/src/serve-studio.ts:94,110`. @@ -94,9 +110,9 @@ Các fact dưới đây **đã verify**, không phải suy đoán: - Site đầu tiên có id cố định `__default__` (`apps/cms/src/modules/setup/site-constants.ts:11`) — chọn vậy để chạy lại wizard là idempotent. -- `LUMIBASE_REQUIRE_SETUP_TOKEN=true` ⇒ token in ra log **một lần** dạng - `[lumibase-cms] SETUP_TOKEN=` - (`apps/cms/src/modules/setup/setup-token.ts:199`); DB chỉ giữ SHA-256. +- `LUMIBASE_REQUIRE_SETUP_TOKEN=true` **nhìn thì** in token một lần + (`apps/cms/src/modules/setup/setup-token.ts:199`), nhưng thực tế hàm đó không + bao giờ được gọi — xem §9.1(a). Template vì vậy **không** bật cờ này. ## 4. Collection, seed và public client @@ -133,7 +149,7 @@ Chọn **publishable key** (không dùng đường anonymous thuần, lý do ở (`apps/cms/src/middleware/auth.ts:299-305`). Đây chính là cơ chế khiến client tenant B không đọc được nội dung tenant A. -### 4.4 ⚠️ Rủi ro lộ draft — phải chốt trước khi code +### 4.4 ⚠️ Rủi ro lộ draft — đã chốt và đã kiểm chứng `GET /api/v1/items` **không** tự lọc `published`: `status` chỉ là query param optional, chỉ áp dụng khi client truyền (`apps/cms/src/routes/items.ts:27`, @@ -143,11 +159,14 @@ role + policy, **không tạo permission row nào** ⇒ Nếu grant `read` mà không kèm filter, client công khai **đọc được cả draft**. -**Đề xuất chốt:** grant `read` trên `posts` **bắt buộc kèm row-filter -`status = published`**, dùng DSL row-level của `permissions.permissions` -(`packages/database/src/schema/access.ts:291-292`), cộng `fields` whitelist -(`:297-298`) để chặn field nội bộ. Test phải assert: publishable key **không** -nhìn thấy item draft. +**Đã chốt:** grant `read` trên `posts` luôn kèm `publishedOnly: true` +(`apps/cms/src/routes/access-grants.ts:82`), biên dịch thành +`{ status: { _eq: 'published' } }` (`apps/cms/src/services/auth/realm-access.ts:35`), +cộng `fields` whitelist. Truyền tường minh chứ không dựa vào mặc định +server-side (`realm-access.ts:226` bật sẵn cho `read`) — mặc định có thể đổi. + +Đã kiểm chứng trên instance thật: seed cố tình để lại một bài draft, và +`cms:verify` xác nhận publishable key chỉ thấy `published` (§9). ### 4.5 Token quản trị @@ -224,15 +243,69 @@ Không có chế độ anonymous/publishable. - Artifact local ghi tách bạch với npm/image đã phát hành; bằng chứng cold-install nộp cho #448. -## 8. Chặn — cần reviewer quyết trước khi implement - -1. **#450 còn OPEN.** Template `cloudflare` hiện không `npm install` được - (ERESOLVE do peer Workers Types; bối cảnh ở - `packages/create-lumibase/src/templates.test.ts:1-20`). Không thể nộp bằng - chứng "regression default/Cloudflare" xanh khi #450 chưa đóng. Xin quyết: - đóng #450 trước, hay ghi nhận Cloudflare là known-fail có dẫn chiếu #450? -2. **Chốt row-filter `status = published`** (§4.4) — nếu không, public client lộ - draft. -3. **Pin tag `1.0.0-rc.1`**, không `latest` (§3.2) — xác nhận đây là artifact - chuẩn cho contract này. -4. Xác nhận bảng file §5.1 và việc **không** sửa `packages/sdk` trong #332. +## 8. Bốn điểm chặn — đã quyết + +Owner chỉ đạo implement luôn, nên cả bốn được quyết theo đề xuất: + +1. **#450**: ghi nhận là known-fail có dẫn chiếu, không chặn #332. Template + `nextjs` **cài được sạch** (xem §9), nên lỗi ERESOLVE của Cloudflare không + lây sang đường đi mới. +2. **Row-filter `status = published`**: chốt bắt buộc. API đã có sẵn cờ + `publishedOnly` (`apps/cms/src/routes/access-grants.ts:82`) biên dịch thành + `{ status: { _eq: 'published' } }` + (`apps/cms/src/services/auth/realm-access.ts:35`), nên không phải tự viết DSL. +3. **Pin image**: đề xuất ban đầu (`1.0.0-rc.1`) **sai** — tag đó không có + Studio. Sửa thành pin theo digest `sha256:3f125caa…` (§3.2). +4. **Không sửa `packages/sdk`**: giữ nguyên. Publishable key đi qua đúng header + `Authorization: Bearer` nên client hiện tại dùng được ngay. + +## 9. Đã chạy thật — bằng chứng + +Toàn bộ vòng đời chạy trên instance thật (cold install ngoài monorepo → Docker +→ bootstrap → seed → website), không mock: + +| Hạng mục | Kết quả | +|---|---| +| Cold install từ tarball đã pack, ngoài monorepo | ✔ không `workspace:*`, không `.hbs` sót | +| `npm install` project scaffold | ✔ 31 packages, **không ERESOLVE** | +| `tsc --noEmit` trong project scaffold | ✔ exit 0 | +| Pull image theo digest `sha256:3f125caa…` | ✔ chạy được, **có Studio** | +| Studio phục vụ tại `/` | ✔ 200 `text/html`, `LumiBase Studio` | +| `/api/v1/*` không bị SPA nuốt | ✔ vẫn trả `{errors}` JSON | +| `cms:bootstrap` | ✔ trọn 6/6 bước | +| `cms:seed` chạy 2 lần | ✔ lần 1 tạo 3, lần 2 tạo 0 — idempotent | +| `cms:verify` | ✔ đọc được published, **không thấy draft**, không ghi được | +| Website render | ✔ hiện 2 bài published, **không hiện draft** | +| Publish draft → reload | ✔ bài xuất hiện (0 → 1), dữ liệu thật | +| Rò token trong HTML | ✔ 0 lần xuất hiện admin token/password | + +### 9.1 Hai lỗi CMS phát hiện khi chạy thật + +Cả hai **nằm ngoài phạm vi #332** (không được sửa `apps/cms`), đã né trong +template và ghi vào README của starter: + +**(a) Cờ setup token khoá chết instance.** `printSetupTokenIfRequired` +(`apps/cms/src/modules/setup/setup-token.ts:148`) có unit test nhưng **không +được gọi từ đâu** lúc khởi động — grep toàn repo chỉ ra 3 kết quả, đều trong +chính file đó. Bật `LUMIBASE_REQUIRE_SETUP_TOKEN=true` ⇒ `/setup/state` trả +`requiresSetupToken: true`, `/setup/complete` trả `SETUP_TOKEN_REQUIRED`, và +không có cách nào lấy token. Đã kiểm chứng trực tiếp. ⇒ compose **không** bật cờ +này; stack chỉ bind localhost. + +**(b) Header site giả làm sập CMS — DoS không cần xác thực.** `withTenant` chỉ +kiểm tra *định dạng* của `X-Lumi-Site` (`apps/cms/src/middleware/tenant.ts:29-43`), +không kiểm tra site có tồn tại. Khi từ chối api key, `auditApiKeyUseDenied` ghi +audit với chính site id do client gửi (`apps/cms/src/middleware/auth.ts:93`), vi +phạm FK `lumibase_audit_log_site_id_lumibase_sites_id_fk` và **giết process**. +Tái hiện chắc chắn: một request duy nhất → 401 → `health` = 000. +⇒ `verify.mjs` để phép thử cross-tenant sau cờ `LUMIBASE_VERIFY_CROSS_TENANT=1`, +nếu không `cms:verify` sẽ tự bắn sập CMS của người dùng. + +### 9.2 Lệch so với contract ban đầu + +- **Thêm Redis vào compose.** Không có nó, runtime Docker fallback về + `127.0.0.1:6379` và đẩy **506 dòng ECONNREFUSED** vào log, che hết thông tin + hữu ích. Có Redis: **0 lỗi**. +- **Bỏ `LUMIBASE_REQUIRE_SETUP_TOKEN`** — lý do ở §9.1(a). +- **Thêm validate `--template`**: trước đây tên template sai đi thẳng tới + `scaffold()` và chết bằng ENOENT trỏ vào đường dẫn nội bộ. From e96dc906cac8cc6e326227d0e5eba562fad8871d Mon Sep 17 00:00:00 2001 From: Javier Date: Sat, 12 Sep 2026 20:00:34 +0700 Subject: [PATCH 05/26] test(create-lumibase): check the draft by id, and pin argv forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked for permission checks that a client-side status filter cannot fake, so cms:verify now also asks the CMS for the draft by its own id and asks explicitly for status=draft. A row filter that only applied to list queries would pass the old check and fail these two. The id comes from the admin token, server-side, so the public key is asked for something known to exist rather than something that might simply be absent. Also pins the half of the no-drift claim that was only prose: init forwards --template verbatim, so a template the scaffolder gains is reachable through `lumibase init` on the same release with no change to the CLI. That has a consequence worth stating, and the test says it: init resolves the scaffolder from the REGISTRY, so `lumibase init --template nextjs` fails with ENOENT until create-lumibase is published — the published 1.0.0-rc.1 tarball ships only the cloudflare and default templates. Verified against npm, not assumed. Refs #332 --- packages/cli/src/commands/init.test.ts | 32 +++++++++++++++ .../templates/nextjs/scripts/verify.mjs | 40 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index ba7676ae..f971c541 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -69,6 +69,38 @@ describe("initCommand", () => { ]); }); + it("forwards --template untouched, so both entrypoints offer the same set", () => { + // #332: `npm create lumibase` and `lumibase init` must never drift. They + // cannot, structurally — init does not re-implement the scaffolder, it + // runs `create-lumibase@` and hands it argv verbatim. + // This pins the "verbatim" half: a template the scaffolder gains is + // reachable through init on the same release, with no change here. + // + // The version pin is the other half, and it has a consequence worth + // stating: init resolves the scaffolder from the REGISTRY, so a template + // that exists in this repo is not reachable through `lumibase init` until + // create-lumibase is published. Until then it fails inside the scaffolder + // with ENOENT on the template directory — the name is valid, the published + // artifact simply predates it. + const seen: string[] = []; + initCommand(["my-site", "--template", "nextjs"], { + version: "9.9.9", + userAgent: "npm/10.9.0 node/v22.0.0", + run: (_command, args) => { + seen.push(...args); + return 0; + }, + }); + + expect(seen).toEqual([ + "--yes", + "create-lumibase@9.9.9", + "my-site", + "--template", + "nextjs", + ]); + }); + it("defaults to this package version", () => { let spec: string | undefined; initCommand([], { diff --git a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs index 9aff1eb6..9d108b2e 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs @@ -54,6 +54,46 @@ async function main() { : `LEAKED ${leaked.length} non-published item(s)`, ); + // 2b — cannot reach the draft by its own id either. + // + // Checking the list alone is not enough: a row filter that only applied to + // list queries would still hand over the draft on a direct GET. The id comes + // from the admin token (server-side, never in the browser) precisely so the + // public key is asked for something we know exists. + const adminToken = process.env.LUMIBASE_ADMIN_TOKEN; + if (adminToken) { + const all = await api(`/api/v1/items/${COLLECTION}?limit=200`, { token: adminToken }); + const draft = (all?.data ?? []).find((i) => i?.status && i.status !== 'published'); + + if (!draft) { + check('a draft exists to test against', false, 'seed one with: npm run cms:seed'); + } else { + let reached = false; + try { + await asPublic(`/api/v1/items/${COLLECTION}/${draft.id}`); + reached = true; + } catch (err) { + if (!(err instanceof CmsError)) throw err; + } + check('the draft is unreachable by direct id', !reached, `id ${draft.id}`); + } + } else { + console.log(' · direct-id draft check skipped (LUMIBASE_ADMIN_TOKEN not set)'); + } + + // 2c — asking for drafts explicitly must not produce any. + const asked = await asPublic(`/api/v1/items/${COLLECTION}?status=draft&limit=50`).catch( + (err) => { + if (err instanceof CmsError) return { data: [] }; + throw err; + }, + ); + check( + 'asking for status=draft returns nothing', + (asked?.data ?? []).length === 0, + `${(asked?.data ?? []).length} item(s)`, + ); + // 3 — cannot write let wrote = false; try { From 1a22d72ae33347e044f1319a4137b7d3583e12bf Mon Sep 17 00:00:00 2001 From: Javier Date: Sat, 12 Sep 2026 20:00:48 +0700 Subject: [PATCH 06/26] docs(spec): answer the review, and retract the Cloudflare claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer was right on two counts and both are corrected here rather than argued with. The Cloudflare blocker was wrong. I inferred "the cloudflare template cannot install" from the header of templates.test.ts, but that paragraph describes the failure BEFORE it was fixed. Scaffolding the template and running npm install adds 63 packages with no ERESOLVE, so there is no reproduction and no waiver to ask for. The known-fail request is withdrawn; #450 still needs its own acceptance. The release dependency was real and unstated: `lumibase init` resolves the scaffolder from the registry, so the new template is unreachable through that entrypoint until create-lumibase is published. Verified by running the published tarball, which fails with ENOENT on the template directory. Adds the evidence the P1 finding asked for — the digest now carries cold-pull, bootstrap and browser proof: signing into Studio and seeing the seeded posts listed as one DRAFT and two PUBLISHED, the draft being exactly the row the public key cannot reach. Credential leakage is now shown rather than asserted: a production build with sentinel values finds zero occurrences in .next, while the publishable key appears twice — the positive control that makes the zero meaningful. Replaces the src/*.test.ts wildcard with the actual files, and registers the Setup Impact entry (#123) that DoD requires. Refs #332 --- .../specs/admin-setup-wizard/setup-impact.md | 2 ++ .kiro/specs/nextjs-starter-contract/design.md | 34 +++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.kiro/specs/admin-setup-wizard/setup-impact.md b/.kiro/specs/admin-setup-wizard/setup-impact.md index 3fbd300f..613f873e 100644 --- a/.kiro/specs/admin-setup-wizard/setup-impact.md +++ b/.kiro/specs/admin-setup-wizard/setup-impact.md @@ -157,6 +157,8 @@ Trạng thái: `pending` (chưa làm) · `in-progress` · `done` (setup + backfi | 119 | (CMS serves the Studio SPA in Docker mode: #332 option B) | v1.0.0-rc.2 | Đã rà soát: `apps/cms/src/serve-studio.ts` mount `apps/studio/dist` từ chính process CMS ở Node/Docker (Workers vẫn dùng Pages, không có filesystem); `docker/Dockerfile` build Studio và copy vào `/app/studio`; `/assets/` + `/sw.js` thêm vào `NEVER_STUDIO_SCOPE_PREFIXES` của `adminPathGuard`. (1) KHÔNG seed. (2) KHÔNG settings key DB — điều khiển bằng env, không phải per-site. (3) KHÔNG policy/grant DB. (4) KHÔNG bước setup wizard mới — nhưng đây là lần đầu wizard **truy cập được** từ một Docker deployment: `/setup` trả shell của SPA thay vì 404. (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG migration/backfill | n/a | — | Rà soát 2026-09-07. **Hai env var mới, đều tuỳ chọn**: `LUMIBASE_STUDIO_DIST` (mặc định `./studio` tương đối CWD) và `LUMIBASE_SERVE_STUDIO=false` để tắt — nên tắt khi Studio cũng deploy trên Pages đứng trước CMS này, vì hai bản sao trôi lệch phiên bản thì người dùng gặp bản nào tuỳ hostname họ gõ. Vắng bundle → log rồi chạy API-only, tức **degrade an toàn**, đúng hành vi trước thay đổi này; `LUMIBASE_STUDIO_DIST` trỏ sai đường thì warn tường minh chứ không im lặng. **Ảnh hưởng Req 5.x (Hide-Login)**: bypass thêm hai prefix nghĩa là ai có tên file content-hashed xác nhận được "host này có Studio" — nhưng KHÔNG suy ra được admin path (build từ chối nhúng, `assertNoAdminPathEnv`), và `/`, `/admin`, `/studio`, admin-path-lệch-một-ký-tự vẫn trả canonical 404. Property 7 (`404-indistinguishable.test.ts`) đã chạy lại: pass | +| 123 | (create-lumibase: Next.js starter với CMS + Studio + seed: #332) | v1.0.0-rc.2 | Đã rà soát: template thứ ba `templates/nextjs` trong `create-lumibase` (app Next.js + `docker-compose.yml` pull image CMS theo **digest** + script `bootstrap`/`seed`/`verify`), nới `Template` union + prompt + `isNextjs`, validate `--template`, và test bất biến an toàn. (1) KHÔNG seed phía CMS — seed nằm trong project **do người dùng sinh ra**, chạy bằng admin token của chính họ, idempotent theo `slug`. (2) KHÔNG settings key / env var CMS mới — các biến `NEXT_PUBLIC_LUMIBASE_*` + `LUMIBASE_ADMIN_*` thuộc project người dùng. (3) KHÔNG policy/grant DB mới trong repo — script bootstrap **gọi** API sẵn có (`POST /access/grants/public/enable` rồi `POST /access/grants/public` với `publishedOnly`), tức tạo grant trên instance của người dùng chứ không thêm định nghĩa nào vào LumiBase. (4) KHÔNG bước setup wizard mới — script gọi `POST /setup/complete` sẵn có. (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG migration/backfill | n/a | nextjs-starter-contract/design.md | Rà soát 2026-09-12. Chỉ đụng scaffolder + một test của `packages/cli`; **không** chạm `apps/cms`/schema/route. **Lưu ý vận hành (KHÔNG phải setup wizard)**: (a) compose pin image theo **digest** chứ không theo tag vì **không semver tag nào chứa Studio** — commit thêm Studio (`2bd5b0ab`, 2026-09-07) ra sau bản build `1.0.0-rc.1` (2026-09-03); `edge` có Studio nhưng là tag trôi nổi. Kiểm chứng bằng `ls /app/studio` trong chính image, không phải bằng đọc Dockerfile hiện tại. (b) Template **cố ý không** bật `LUMIBASE_REQUIRE_SETUP_TOKEN`: `printSetupTokenIfRequired` có unit test nhưng **không được gọi từ đâu** lúc khởi động, nên bật cờ là khoá chết setup vĩnh viễn — lỗi CMS, ngoài scope #332. (c) `cms:verify` để phép thử cross-tenant sau cờ opt-in vì gửi `X-Lumi-Site` không tồn tại làm **sập process** (audit ghi bằng site id chưa có → vi phạm FK) — cũng là lỗi CMS ngoài scope. (d) `lumibase init --template nextjs` chỉ chạy được **sau** khi `create-lumibase` được publish lại: `init` resolve scaffolder từ registry, bản `1.0.0-rc.1` trên npm chưa đóng gói template này | + ## Lưu ý backfill Các gap #1–#3 ảnh hưởng cả instance **đã setup** — fix không chỉ nằm trong setup wizard mà cần kèm migration/backfill idempotent (`onConflictDoNothing`) hoặc giữ lazy-init làm fallback song song. diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index 2fc4f174..3355dbe2 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -45,8 +45,20 @@ một template = thêm thư mục + nới union type. **Không drift giữa hai entrypoint:** `lumibase init` không re-implement scaffold — nó chạy `dlx create-lumibase@<đúng version của CLI>` (`packages/cli/src/commands/init.ts:20-45`). Contract này **không sửa** -`init.ts`; chỉ bổ sung test khẳng định `--template nextjs` đi qua được cả -`npm create` lẫn `lumibase init`. +`init.ts`; chỉ bổ sung test (`init.test.ts`) khẳng định `--template nextjs` +được forward nguyên vẹn. + +⚠️ **Phụ thuộc phát hành — reviewer nêu đúng (P2.2).** `init` resolve scaffolder +từ **registry**, nên template mới chưa dùng được qua `lumibase init` cho tới khi +`create-lumibase` được publish lại. Đã kiểm chứng: bản `create-lumibase@1.0.0-rc.1` +trên npm chỉ đóng gói `templates/cloudflare` + `templates/default`, và +`npx create-lumibase@1.0.0-rc.1 x --template nextjs` **fail bằng ENOENT** trên +thư mục template. Validate `--template` không bắt được ca này vì tên hợp lệ — +chỉ artifact đã phát hành là cũ. + +⇒ `npm create` (qua tarball/`dist` mới) đã chạy đúng ngay bây giờ; +`lumibase init` đạt tương đương **sau** lần publish kế tiếp. Không có thay đổi +code nào làm được điều đó sớm hơn. ## 3. Hai đường backend @@ -184,7 +196,9 @@ build. | `packages/create-lumibase/src/index.ts` | sửa — union `Template`, một prompt choice | | `packages/create-lumibase/src/scaffold.ts` | sửa — cờ `isNextjs` trong context | | `packages/create-lumibase/src/templates.test.ts` | sửa — mở rộng `it.each` sang `nextjs` | -| `packages/create-lumibase/src/*.test.ts` | mới/sửa — test scaffold + assertion chống rò token | +| `packages/create-lumibase/src/nextjs-template.test.ts` | mới — bất biến an toàn (không rò credential, `publishedOnly`, pin digest) | +| `packages/create-lumibase/src/utils/print.ts` | sửa — next-steps cho template `nextjs` | +| `packages/cli/src/commands/init.test.ts` | sửa — khoá việc forward `--template` nguyên vẹn | **Không đụng:** `packages/sdk/**`, `apps/studio/**`, root manifest/lockfile, `.github/workflows/**`, docs/spec dùng chung. `#334` sở hữu reference example — @@ -247,9 +261,11 @@ Không có chế độ anonymous/publishable. Owner chỉ đạo implement luôn, nên cả bốn được quyết theo đề xuất: -1. **#450**: ghi nhận là known-fail có dẫn chiếu, không chặn #332. Template - `nextjs` **cài được sạch** (xem §9), nên lỗi ERESOLVE của Cloudflare không - lây sang đường đi mới. +1. **#450**: **rút lại** đề nghị known-fail. Reviewer đúng (P2.4): tôi suy ra + "Cloudflare hiện không cài được" từ header `templates.test.ts`, nhưng đoạn + đó mô tả sự cố **trước** khi fix. Kiểm chứng thật: scaffold `cloudflare` rồi + `npm install` → **added 63 packages, không ERESOLVE**. Vậy không có + reproduction, không cần waiver. #450 vẫn cần acceptance riêng của reviewer. 2. **Row-filter `status = published`**: chốt bắt buộc. API đã có sẵn cờ `publishedOnly` (`apps/cms/src/routes/access-grants.ts:82`) biên dịch thành `{ status: { _eq: 'published' } }` @@ -277,7 +293,11 @@ Toàn bộ vòng đời chạy trên instance thật (cold install ngoài monore | `cms:verify` | ✔ đọc được published, **không thấy draft**, không ghi được | | Website render | ✔ hiện 2 bài published, **không hiện draft** | | Publish draft → reload | ✔ bài xuất hiện (0 → 1), dữ liệu thật | -| Rò token trong HTML | ✔ 0 lần xuất hiện admin token/password | +| Rò token trong HTML runtime | ✔ 0 lần xuất hiện admin token/password | +| **Sentinel build production** | ✔ sentinel admin/password **0 file** trong `.next`; publishable key **2 file** (đối chứng dương) | +| **Studio trong browser** | ✔ đăng nhập được, mở `posts`, thấy 3 item: 1 `DRAFT` + 2 `PUBLISHED` | +| Draft lấy theo **id trực tiếp** | ✔ không lấy được (`ZYkt-txK…`) | +| Truy vấn `?status=draft` bằng public key | ✔ trả 0 item | ### 9.1 Hai lỗi CMS phát hiện khi chạy thật From febcd5dc0c0ba56bbfd54677da41d416d3b9d811 Mon Sep 17 00:00:00 2001 From: Javier Date: Sat, 12 Sep 2026 20:54:50 +0700 Subject: [PATCH 07/26] docs: point the starter's workarounds at their tracking issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two CMS bugs the starter works around now have tickets, so the README, compose comment, verify script and spec cite them instead of describing the problem in place: #470 for the setup-token gate that locks an instance out, #469 for the forged site header that kills the process. Corrects the cause recorded for #469 while linking it. AuditLogger.write does catch failures on the synchronous insert path, so that path was never the problem. The crash comes from the queue path: the batcher catches, logs, then rethrows, and the flush that calls it is fire-and-forget — an unhandled rejection. The batch also groups several sites into one insert, so one bad row loses the audit records of the valid sites alongside it. Refs #332, #469, #470 --- .kiro/specs/admin-setup-wizard/setup-impact.md | 2 +- .kiro/specs/nextjs-starter-contract/design.md | 6 +++--- packages/create-lumibase/templates/nextjs/README.md.hbs | 7 +++++-- .../create-lumibase/templates/nextjs/docker-compose.yml | 2 +- .../create-lumibase/templates/nextjs/scripts/verify.mjs | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.kiro/specs/admin-setup-wizard/setup-impact.md b/.kiro/specs/admin-setup-wizard/setup-impact.md index 613f873e..8815a18a 100644 --- a/.kiro/specs/admin-setup-wizard/setup-impact.md +++ b/.kiro/specs/admin-setup-wizard/setup-impact.md @@ -157,7 +157,7 @@ Trạng thái: `pending` (chưa làm) · `in-progress` · `done` (setup + backfi | 119 | (CMS serves the Studio SPA in Docker mode: #332 option B) | v1.0.0-rc.2 | Đã rà soát: `apps/cms/src/serve-studio.ts` mount `apps/studio/dist` từ chính process CMS ở Node/Docker (Workers vẫn dùng Pages, không có filesystem); `docker/Dockerfile` build Studio và copy vào `/app/studio`; `/assets/` + `/sw.js` thêm vào `NEVER_STUDIO_SCOPE_PREFIXES` của `adminPathGuard`. (1) KHÔNG seed. (2) KHÔNG settings key DB — điều khiển bằng env, không phải per-site. (3) KHÔNG policy/grant DB. (4) KHÔNG bước setup wizard mới — nhưng đây là lần đầu wizard **truy cập được** từ một Docker deployment: `/setup` trả shell của SPA thay vì 404. (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG migration/backfill | n/a | — | Rà soát 2026-09-07. **Hai env var mới, đều tuỳ chọn**: `LUMIBASE_STUDIO_DIST` (mặc định `./studio` tương đối CWD) và `LUMIBASE_SERVE_STUDIO=false` để tắt — nên tắt khi Studio cũng deploy trên Pages đứng trước CMS này, vì hai bản sao trôi lệch phiên bản thì người dùng gặp bản nào tuỳ hostname họ gõ. Vắng bundle → log rồi chạy API-only, tức **degrade an toàn**, đúng hành vi trước thay đổi này; `LUMIBASE_STUDIO_DIST` trỏ sai đường thì warn tường minh chứ không im lặng. **Ảnh hưởng Req 5.x (Hide-Login)**: bypass thêm hai prefix nghĩa là ai có tên file content-hashed xác nhận được "host này có Studio" — nhưng KHÔNG suy ra được admin path (build từ chối nhúng, `assertNoAdminPathEnv`), và `/`, `/admin`, `/studio`, admin-path-lệch-một-ký-tự vẫn trả canonical 404. Property 7 (`404-indistinguishable.test.ts`) đã chạy lại: pass | -| 123 | (create-lumibase: Next.js starter với CMS + Studio + seed: #332) | v1.0.0-rc.2 | Đã rà soát: template thứ ba `templates/nextjs` trong `create-lumibase` (app Next.js + `docker-compose.yml` pull image CMS theo **digest** + script `bootstrap`/`seed`/`verify`), nới `Template` union + prompt + `isNextjs`, validate `--template`, và test bất biến an toàn. (1) KHÔNG seed phía CMS — seed nằm trong project **do người dùng sinh ra**, chạy bằng admin token của chính họ, idempotent theo `slug`. (2) KHÔNG settings key / env var CMS mới — các biến `NEXT_PUBLIC_LUMIBASE_*` + `LUMIBASE_ADMIN_*` thuộc project người dùng. (3) KHÔNG policy/grant DB mới trong repo — script bootstrap **gọi** API sẵn có (`POST /access/grants/public/enable` rồi `POST /access/grants/public` với `publishedOnly`), tức tạo grant trên instance của người dùng chứ không thêm định nghĩa nào vào LumiBase. (4) KHÔNG bước setup wizard mới — script gọi `POST /setup/complete` sẵn có. (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG migration/backfill | n/a | nextjs-starter-contract/design.md | Rà soát 2026-09-12. Chỉ đụng scaffolder + một test của `packages/cli`; **không** chạm `apps/cms`/schema/route. **Lưu ý vận hành (KHÔNG phải setup wizard)**: (a) compose pin image theo **digest** chứ không theo tag vì **không semver tag nào chứa Studio** — commit thêm Studio (`2bd5b0ab`, 2026-09-07) ra sau bản build `1.0.0-rc.1` (2026-09-03); `edge` có Studio nhưng là tag trôi nổi. Kiểm chứng bằng `ls /app/studio` trong chính image, không phải bằng đọc Dockerfile hiện tại. (b) Template **cố ý không** bật `LUMIBASE_REQUIRE_SETUP_TOKEN`: `printSetupTokenIfRequired` có unit test nhưng **không được gọi từ đâu** lúc khởi động, nên bật cờ là khoá chết setup vĩnh viễn — lỗi CMS, ngoài scope #332. (c) `cms:verify` để phép thử cross-tenant sau cờ opt-in vì gửi `X-Lumi-Site` không tồn tại làm **sập process** (audit ghi bằng site id chưa có → vi phạm FK) — cũng là lỗi CMS ngoài scope. (d) `lumibase init --template nextjs` chỉ chạy được **sau** khi `create-lumibase` được publish lại: `init` resolve scaffolder từ registry, bản `1.0.0-rc.1` trên npm chưa đóng gói template này | +| 123 | (create-lumibase: Next.js starter với CMS + Studio + seed: #332) | v1.0.0-rc.2 | Đã rà soát: template thứ ba `templates/nextjs` trong `create-lumibase` (app Next.js + `docker-compose.yml` pull image CMS theo **digest** + script `bootstrap`/`seed`/`verify`), nới `Template` union + prompt + `isNextjs`, validate `--template`, và test bất biến an toàn. (1) KHÔNG seed phía CMS — seed nằm trong project **do người dùng sinh ra**, chạy bằng admin token của chính họ, idempotent theo `slug`. (2) KHÔNG settings key / env var CMS mới — các biến `NEXT_PUBLIC_LUMIBASE_*` + `LUMIBASE_ADMIN_*` thuộc project người dùng. (3) KHÔNG policy/grant DB mới trong repo — script bootstrap **gọi** API sẵn có (`POST /access/grants/public/enable` rồi `POST /access/grants/public` với `publishedOnly`), tức tạo grant trên instance của người dùng chứ không thêm định nghĩa nào vào LumiBase. (4) KHÔNG bước setup wizard mới — script gọi `POST /setup/complete` sẵn có. (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG migration/backfill | n/a | nextjs-starter-contract/design.md | Rà soát 2026-09-12. Chỉ đụng scaffolder + một test của `packages/cli`; **không** chạm `apps/cms`/schema/route. **Lưu ý vận hành (KHÔNG phải setup wizard)**: (a) compose pin image theo **digest** chứ không theo tag vì **không semver tag nào chứa Studio** — commit thêm Studio (`2bd5b0ab`, 2026-09-07) ra sau bản build `1.0.0-rc.1` (2026-09-03); `edge` có Studio nhưng là tag trôi nổi. Kiểm chứng bằng `ls /app/studio` trong chính image, không phải bằng đọc Dockerfile hiện tại. (b) Template **cố ý không** bật `LUMIBASE_REQUIRE_SETUP_TOKEN`: `printSetupTokenIfRequired` có unit test nhưng **không được gọi từ đâu** lúc khởi động, nên bật cờ là khoá chết setup vĩnh viễn — lỗi CMS **#470**, ngoài scope #332. (c) `cms:verify` để phép thử cross-tenant sau cờ opt-in vì gửi `X-Lumi-Site` không tồn tại làm **sập process** (audit ghi bằng site id chưa có → vi phạm FK, ném lại trong flush fire-and-forget) — lỗi CMS **#469**, ngoài scope. (d) `lumibase init --template nextjs` chỉ chạy được **sau** khi `create-lumibase` được publish lại: `init` resolve scaffolder từ registry, bản `1.0.0-rc.1` trên npm chưa đóng gói template này | ## Lưu ý backfill diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index 3355dbe2..efdcf703 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -299,12 +299,12 @@ Toàn bộ vòng đời chạy trên instance thật (cold install ngoài monore | Draft lấy theo **id trực tiếp** | ✔ không lấy được (`ZYkt-txK…`) | | Truy vấn `?status=draft` bằng public key | ✔ trả 0 item | -### 9.1 Hai lỗi CMS phát hiện khi chạy thật +### 9.1 Hai lỗi CMS phát hiện khi chạy thật (issue #469, #470) Cả hai **nằm ngoài phạm vi #332** (không được sửa `apps/cms`), đã né trong template và ghi vào README của starter: -**(a) Cờ setup token khoá chết instance.** `printSetupTokenIfRequired` +**(a) Cờ setup token khoá chết instance — #470.** `printSetupTokenIfRequired` (`apps/cms/src/modules/setup/setup-token.ts:148`) có unit test nhưng **không được gọi từ đâu** lúc khởi động — grep toàn repo chỉ ra 3 kết quả, đều trong chính file đó. Bật `LUMIBASE_REQUIRE_SETUP_TOKEN=true` ⇒ `/setup/state` trả @@ -312,7 +312,7 @@ chính file đó. Bật `LUMIBASE_REQUIRE_SETUP_TOKEN=true` ⇒ `/setup/state` t không có cách nào lấy token. Đã kiểm chứng trực tiếp. ⇒ compose **không** bật cờ này; stack chỉ bind localhost. -**(b) Header site giả làm sập CMS — DoS không cần xác thực.** `withTenant` chỉ +**(b) Header site giả làm sập CMS — DoS không cần xác thực — #469.** `withTenant` chỉ kiểm tra *định dạng* của `X-Lumi-Site` (`apps/cms/src/middleware/tenant.ts:29-43`), không kiểm tra site có tồn tại. Khi từ chối api key, `auditApiKeyUseDenied` ghi audit với chính site id do client gửi (`apps/cms/src/middleware/auth.ts:93`), vi diff --git a/packages/create-lumibase/templates/nextjs/README.md.hbs b/packages/create-lumibase/templates/nextjs/README.md.hbs index d71ca779..1f57561d 100644 --- a/packages/create-lumibase/templates/nextjs/README.md.hbs +++ b/packages/create-lumibase/templates/nextjs/README.md.hbs @@ -77,20 +77,23 @@ failure is safe. Two CMS bugs shape this starter. Both are upstream, not in the template: -- **The setup-token gate locks you out.** `LUMIBASE_REQUIRE_SETUP_TOKEN` makes +- **The setup-token gate locks you out** ([#470]). `LUMIBASE_REQUIRE_SETUP_TOKEN` makes `/setup/complete` demand a token that the server never prints — the mint-and-print helper exists and is unit-tested, but nothing calls it at startup. The compose file therefore leaves the flag off, and the stack binds to localhost instead. Run `cms:bootstrap` promptly: until you do, anyone who can reach port 1989 can claim the admin account. -- **A forged site header crashes the CMS.** Sending `X-Lumi-Site` for a site +- **A forged site header crashes the CMS** ([#469]). Sending `X-Lumi-Site` for a site that does not exist correctly returns 401, but the denial is then written to the audit log under that same id — which no row in `sites` matches, so the insert violates a foreign key and takes the process down. One request is enough. `cms:verify` therefore skips its cross-tenant probe by default; opt in with `LUMIBASE_VERIFY_CROSS_TENANT=1` once this is fixed. +[#469]: https://github.com/khuepm/LumiBase/issues/469 +[#470]: https://github.com/khuepm/LumiBase/issues/470 + ## Going to production This stack is for local development: diff --git a/packages/create-lumibase/templates/nextjs/docker-compose.yml b/packages/create-lumibase/templates/nextjs/docker-compose.yml index a52b73e0..001f2417 100644 --- a/packages/create-lumibase/templates/nextjs/docker-compose.yml +++ b/packages/create-lumibase/templates/nextjs/docker-compose.yml @@ -55,7 +55,7 @@ services: # this stack is ever reachable from anywhere but your machine. JWT_SECRET: dev_secret_key ENCRYPTION_KEY: dev_secret_key - # Deliberately NOT setting LUMIBASE_REQUIRE_SETUP_TOKEN here. + # Deliberately NOT setting LUMIBASE_REQUIRE_SETUP_TOKEN here — see #470. # # The CMS can gate setup behind a one-time token, but v1.0.0-rc.1 never # prints it: the mint-and-print helper exists and is unit-tested, yet diff --git a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs index 9d108b2e..e2a0d3cb 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs @@ -117,7 +117,7 @@ async function main() { // unauthenticated caller is enough. // // Running this check would therefore knock over your own container. Opt in - // with LUMIBASE_VERIFY_CROSS_TENANT=1 once that is fixed upstream. + // with LUMIBASE_VERIFY_CROSS_TENANT=1 once that is fixed upstream (#469). if (process.env.LUMIBASE_VERIFY_CROSS_TENANT === '1') { let crossed = false; try { From e3e97d00fd25e8d703ae60343241a9d56cb9c8d9 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 01:45:01 +0700 Subject: [PATCH 08/26] docs(spec): write the starter contract in English The repo's specs are currently a mix of both languages. English is the better default for anything a wider audience may read, so this one switches: the design document and the Setup Impact entry (#123) now read in English, with no change to their substance. Also links the two workaround notes to the issues that now track them (#469, #470) so a reader can follow the reasoning out of the spec. Refs #332, #469, #470 --- .../specs/admin-setup-wizard/setup-impact.md | 2 +- .kiro/specs/nextjs-starter-contract/design.md | 528 +++++++++--------- 2 files changed, 273 insertions(+), 257 deletions(-) diff --git a/.kiro/specs/admin-setup-wizard/setup-impact.md b/.kiro/specs/admin-setup-wizard/setup-impact.md index 8815a18a..cdbd42cc 100644 --- a/.kiro/specs/admin-setup-wizard/setup-impact.md +++ b/.kiro/specs/admin-setup-wizard/setup-impact.md @@ -157,7 +157,7 @@ Trạng thái: `pending` (chưa làm) · `in-progress` · `done` (setup + backfi | 119 | (CMS serves the Studio SPA in Docker mode: #332 option B) | v1.0.0-rc.2 | Đã rà soát: `apps/cms/src/serve-studio.ts` mount `apps/studio/dist` từ chính process CMS ở Node/Docker (Workers vẫn dùng Pages, không có filesystem); `docker/Dockerfile` build Studio và copy vào `/app/studio`; `/assets/` + `/sw.js` thêm vào `NEVER_STUDIO_SCOPE_PREFIXES` của `adminPathGuard`. (1) KHÔNG seed. (2) KHÔNG settings key DB — điều khiển bằng env, không phải per-site. (3) KHÔNG policy/grant DB. (4) KHÔNG bước setup wizard mới — nhưng đây là lần đầu wizard **truy cập được** từ một Docker deployment: `/setup` trả shell của SPA thay vì 404. (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG migration/backfill | n/a | — | Rà soát 2026-09-07. **Hai env var mới, đều tuỳ chọn**: `LUMIBASE_STUDIO_DIST` (mặc định `./studio` tương đối CWD) và `LUMIBASE_SERVE_STUDIO=false` để tắt — nên tắt khi Studio cũng deploy trên Pages đứng trước CMS này, vì hai bản sao trôi lệch phiên bản thì người dùng gặp bản nào tuỳ hostname họ gõ. Vắng bundle → log rồi chạy API-only, tức **degrade an toàn**, đúng hành vi trước thay đổi này; `LUMIBASE_STUDIO_DIST` trỏ sai đường thì warn tường minh chứ không im lặng. **Ảnh hưởng Req 5.x (Hide-Login)**: bypass thêm hai prefix nghĩa là ai có tên file content-hashed xác nhận được "host này có Studio" — nhưng KHÔNG suy ra được admin path (build từ chối nhúng, `assertNoAdminPathEnv`), và `/`, `/admin`, `/studio`, admin-path-lệch-một-ký-tự vẫn trả canonical 404. Property 7 (`404-indistinguishable.test.ts`) đã chạy lại: pass | -| 123 | (create-lumibase: Next.js starter với CMS + Studio + seed: #332) | v1.0.0-rc.2 | Đã rà soát: template thứ ba `templates/nextjs` trong `create-lumibase` (app Next.js + `docker-compose.yml` pull image CMS theo **digest** + script `bootstrap`/`seed`/`verify`), nới `Template` union + prompt + `isNextjs`, validate `--template`, và test bất biến an toàn. (1) KHÔNG seed phía CMS — seed nằm trong project **do người dùng sinh ra**, chạy bằng admin token của chính họ, idempotent theo `slug`. (2) KHÔNG settings key / env var CMS mới — các biến `NEXT_PUBLIC_LUMIBASE_*` + `LUMIBASE_ADMIN_*` thuộc project người dùng. (3) KHÔNG policy/grant DB mới trong repo — script bootstrap **gọi** API sẵn có (`POST /access/grants/public/enable` rồi `POST /access/grants/public` với `publishedOnly`), tức tạo grant trên instance của người dùng chứ không thêm định nghĩa nào vào LumiBase. (4) KHÔNG bước setup wizard mới — script gọi `POST /setup/complete` sẵn có. (5) KHÔNG capability `/setup/capabilities` mới. (6) KHÔNG migration/backfill | n/a | nextjs-starter-contract/design.md | Rà soát 2026-09-12. Chỉ đụng scaffolder + một test của `packages/cli`; **không** chạm `apps/cms`/schema/route. **Lưu ý vận hành (KHÔNG phải setup wizard)**: (a) compose pin image theo **digest** chứ không theo tag vì **không semver tag nào chứa Studio** — commit thêm Studio (`2bd5b0ab`, 2026-09-07) ra sau bản build `1.0.0-rc.1` (2026-09-03); `edge` có Studio nhưng là tag trôi nổi. Kiểm chứng bằng `ls /app/studio` trong chính image, không phải bằng đọc Dockerfile hiện tại. (b) Template **cố ý không** bật `LUMIBASE_REQUIRE_SETUP_TOKEN`: `printSetupTokenIfRequired` có unit test nhưng **không được gọi từ đâu** lúc khởi động, nên bật cờ là khoá chết setup vĩnh viễn — lỗi CMS **#470**, ngoài scope #332. (c) `cms:verify` để phép thử cross-tenant sau cờ opt-in vì gửi `X-Lumi-Site` không tồn tại làm **sập process** (audit ghi bằng site id chưa có → vi phạm FK, ném lại trong flush fire-and-forget) — lỗi CMS **#469**, ngoài scope. (d) `lumibase init --template nextjs` chỉ chạy được **sau** khi `create-lumibase` được publish lại: `init` resolve scaffolder từ registry, bản `1.0.0-rc.1` trên npm chưa đóng gói template này | +| 123 | (create-lumibase: Next.js starter with CMS + Studio + seed: #332) | v1.0.0-rc.2 | Reviewed: a third template `templates/nextjs` in `create-lumibase` (Next.js app + `docker-compose.yml` pulling the CMS image **by digest** + `bootstrap`/`seed`/`verify` scripts), a widened `Template` union + prompt + `isNextjs`, `--template` validation, and safety-invariant tests. (1) NO CMS-side seed — the seed lives in the **user's generated project**, runs with their own admin token, and is idempotent by `slug`. (2) NO new CMS settings key / env var — the `NEXT_PUBLIC_LUMIBASE_*` and `LUMIBASE_ADMIN_*` variables belong to the user's project. (3) NO new policy/grant in this repo — the bootstrap script **calls** existing APIs (`POST /access/grants/public/enable` then `POST /access/grants/public` with `publishedOnly`), i.e. it creates grants on the user's instance rather than adding definitions to LumiBase. (4) NO new setup-wizard step — the script calls the existing `POST /setup/complete`. (5) NO new `/setup/capabilities` capability. (6) NO migration/backfill | n/a | nextjs-starter-contract/design.md | Reviewed 2026-09-12. Touches the scaffolder plus one `packages/cli` test only; does **not** touch `apps/cms`/schema/routes. **Operational notes (NOT setup-wizard concerns)**: (a) compose pins the image **by digest** rather than by tag because **no semver tag contains Studio** — the commit adding Studio (`2bd5b0ab`, 2026-09-07) postdates the `1.0.0-rc.1` build (2026-09-03); `edge` has Studio but is a moving tag. Verified by running `ls /app/studio` inside the images, not by reading today's Dockerfile. (b) The template deliberately does **not** enable `LUMIBASE_REQUIRE_SETUP_TOKEN`: `printSetupTokenIfRequired` is unit-tested but **called from nowhere** at startup, so enabling the flag locks setup out permanently — CMS bug **#470**, outside #332's scope. (c) `cms:verify` keeps its cross-tenant probe behind an opt-in flag because sending a non-existent `X-Lumi-Site` **kills the process** (the audit row is written under a site id that does not exist → foreign-key violation, rethrown inside a fire-and-forget flush) — CMS bug **#469**, also out of scope. (d) `lumibase init --template nextjs` only works **after** `create-lumibase` is published again: `init` resolves the scaffolder from the registry, and the published `1.0.0-rc.1` does not ship this template | ## Lưu ý backfill diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index efdcf703..08fcf04c 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -1,225 +1,223 @@ # Design Document — Next.js starter contract (#332, handoff A-02) -> **Trạng thái: ĐÃ IMPLEMENT.** Owner chỉ đạo triển khai luôn, không chờ -> reviewer (2026-09-12), nên 4 điểm chặn ở §8 được quyết theo đúng đề xuất. +> **Status: IMPLEMENTED.** The owner directed implementation to proceed without +> waiting for reviewer grant (2026-09-12), so the four blocking questions in §8 +> were decided as proposed. > -> Contract này được giữ lại làm tài liệu thiết kế. Phần đã chạy thật và bằng -> chứng nằm ở §9. +> This contract is kept as the design record. What was actually run, and the +> evidence for it, is in §9. > > Baseline: main `6a20441af5dde899b976479f0ed7f8d1a9341dee`. -> Mọi khẳng định đã verify trên source tree / registry / instance chạy thật. +> Every claim below was verified against the source tree, the registry, or a +> running instance. -## 1. Tổng quan +## 1. Overview -Mục tiêu #332: một người dùng mới, **ngoài monorepo**, tạo được website Next.js, -kết nối CMS/Studio, thấy nội dung seed, sửa & publish trong Studio rồi đọc thay -đổi trên website **bằng quyền tối thiểu** — không có admin token nào lọt vào -browser bundle. +What #332 asks for: a new user, **outside the monorepo**, scaffolds a Next.js +website, connects CMS and Studio, sees seeded content, edits and publishes in +Studio, then reads the change back on the website through a **least-privilege** +client — with no admin token anywhere in the browser bundle. -Nguyên tắc: +Principles: -- **Tái dụng, không phát minh lại** — publishable API key, setup wizard, seed - pattern và Studio-in-Docker đều đã tồn tại; contract này ráp chúng lại. -- **Không thêm package chỉ để tăng lượt tải** (yêu cầu tường minh của #332). -- **`create-lumibase` là implementation duy nhất** — `lumibase init` delegate - sang nó, nên hai entrypoint không thể drift. -- **Phân biệt artifact local với artifact đã phát hành** — "code đã merge" - không đồng nghĩa "image/npm đã phát hành". +- **Reuse, don't reinvent** — publishable API keys, the setup wizard, the seed + pattern and Studio-in-Docker all exist already; this contract assembles them. +- **Do not add a package merely to raise download counts** (an explicit #332 + requirement). +- **`create-lumibase` stays the single implementation** — `lumibase init` + delegates to it, so the two entrypoints cannot drift. +- **Distinguish a local artifact from a published one** — "the code is merged" + does not mean "the image/npm package is published". -## 2. Template Next.js +## 2. The Next.js template -Thêm template thứ ba `nextjs`, **giữ nguyên** `default` và `cloudflare`. +Add a third template, `nextjs`, keeping `default` and `cloudflare` untouched. -`scaffold.ts` **không cần đổi logic**: nó copy đệ quy toàn bộ thư mục template và -render mọi file `.hbs` (`packages/create-lumibase/src/scaffold.ts:50-95`). Thêm -một template = thêm thư mục + nới union type. +`scaffold.ts` **needs no logic change**: it already copies a template directory +recursively and renders any `.hbs` file +(`packages/create-lumibase/src/scaffold.ts:50-95`). Adding a template is a new +directory plus a widened union type. -Điểm sửa, tối thiểu và có chủ đích: +The edits, deliberately minimal: -| Vị trí | Thay đổi | +| Location | Change | |---|---| -| `packages/create-lumibase/src/index.ts:14` | `Template = 'default' \| 'cloudflare'` → thêm `'nextjs'` | -| `packages/create-lumibase/src/index.ts:78-96` | thêm một choice vào prompt "Deployment target" | -| `packages/create-lumibase/src/scaffold.ts:41-48` | `buildTemplateContext` thêm cờ `isNextjs` (đã có `isCloudflare`/`isDefault`) | +| `packages/create-lumibase/src/index.ts:14` | `Template = 'default' \| 'cloudflare'` → add `'nextjs'` | +| `packages/create-lumibase/src/index.ts:78-96` | one more choice in the "Deployment target" prompt | +| `packages/create-lumibase/src/scaffold.ts:41-48` | `buildTemplateContext` gains an `isNextjs` flag (alongside `isCloudflare`/`isDefault`) | -**Không drift giữa hai entrypoint:** `lumibase init` không re-implement scaffold — -nó chạy `dlx create-lumibase@<đúng version của CLI>` -(`packages/cli/src/commands/init.ts:20-45`). Contract này **không sửa** -`init.ts`; chỉ bổ sung test (`init.test.ts`) khẳng định `--template nextjs` -được forward nguyên vẹn. +**The two entrypoints cannot drift:** `lumibase init` does not re-implement the +scaffolder — it runs `dlx create-lumibase@` +(`packages/cli/src/commands/init.ts:20-45`). This contract does **not** modify +`init.ts`; it only adds a test (`init.test.ts`) asserting `--template nextjs` is +forwarded verbatim. -⚠️ **Phụ thuộc phát hành — reviewer nêu đúng (P2.2).** `init` resolve scaffolder -từ **registry**, nên template mới chưa dùng được qua `lumibase init` cho tới khi -`create-lumibase` được publish lại. Đã kiểm chứng: bản `create-lumibase@1.0.0-rc.1` -trên npm chỉ đóng gói `templates/cloudflare` + `templates/default`, và -`npx create-lumibase@1.0.0-rc.1 x --template nextjs` **fail bằng ENOENT** trên -thư mục template. Validate `--template` không bắt được ca này vì tên hợp lệ — -chỉ artifact đã phát hành là cũ. +⚠️ **Release dependency — the reviewer was right (P2.2).** `init` resolves the +scaffolder from the **registry**, so a new template is not reachable through +`lumibase init` until `create-lumibase` is published again. Verified: the +published `create-lumibase@1.0.0-rc.1` tarball ships only `templates/cloudflare` +and `templates/default`, and `npx create-lumibase@1.0.0-rc.1 x --template nextjs` +**fails with ENOENT** on the template directory. The `--template` validation does +not catch this case, because the name is valid — only the published artifact is +old. -⇒ `npm create` (qua tarball/`dist` mới) đã chạy đúng ngay bây giờ; -`lumibase init` đạt tương đương **sau** lần publish kế tiếp. Không có thay đổi -code nào làm được điều đó sớm hơn. +⇒ `npm create` (via the new tarball/`dist`) works today; `lumibase init` reaches +parity **after the next publish**. No code change can make that happen sooner. -## 3. Hai đường backend +## 3. Two backend paths -### 3.1 Đường A — kết nối instance CMS/Studio sẵn có +### 3.1 Path A — connect an existing CMS/Studio instance -Consumer chỉ cần base URL + site id + publishable key. Không provisioning. +The consumer needs only a base URL, a site id and a publishable key. No +provisioning. -### 3.2 Đường B — Docker, CMS kèm Studio trong một image +### 3.2 Path B — Docker, CMS and Studio in one image -Các fact dưới đây **đã verify**, không phải suy đoán: +⚠️ **No semver tag contains Studio.** My first draft of this contract proposed +pinning `1.0.0-rc.1` and claimed the image carried Studio — **wrong**, and the +reviewer caught it. I had inferred it from *today's* `docker/Dockerfile`, but +that file does not describe the contents of a tag built earlier. -- Image `ghcr.io/khuepm/lumibase-cms` **public, pull ẩn danh được**. Lấy - anonymous pull token từ `ghcr.io/token` rồi `GET /v2/khuepm/lumibase-cms/manifests/`: +Verified by running the images themselves (`ls /app/studio`): - | tag | HTTP | - |---|---| - | `edge` | 200 | - | `latest` | 200 | - | `1.0.0-rc.1` | 200 | - | `1.0.0` | 404 | - | `1.0` | 404 | - -- ⚠️ **KHÔNG semver tag nào chứa Studio.** Bản contract đầu tiên của tôi đề - xuất pin `1.0.0-rc.1` và khẳng định image có Studio — **sai**, reviewer bắt - đúng. Tôi suy ra điều đó từ `docker/Dockerfile` *hiện tại*, nhưng Dockerfile - hiện tại không mô tả nội dung một tag đã build từ trước. - - Kiểm chứng bằng cách chạy chính image đó (`ls /app/studio`): - - | tag | Studio | ghi chú | - |---|---|---| - | `1.0.0-rc.1` | ✖ không | build 2026-09-03 | - | `latest` / `0.26.0` | ✖ không | dòng 0.x | - | `edge` | ✔ có | revision `683a0270`, nhưng tag trôi nổi | +| tag | Studio | note | +|---|---|---| +| `1.0.0-rc.1` | ✖ no | built 2026-09-03 | +| `latest` / `0.26.0` | ✖ no | the 0.x line | +| `edge` | ✔ yes | revision `683a0270`, but a moving tag | - Lý do: commit thêm Studio (`2bd5b0ab`) là **2026-09-07**, còn image - `1.0.0-rc.1` build **2026-09-03** — sau 4 ngày. Đúng cái bẫy handoff cảnh báo. +The reason: the commit that added Studio (`2bd5b0ab`) landed **2026-09-07**, +four days *after* `1.0.0-rc.1` was built. Exactly the trap the handoff warned +about. -- ⇒ **Pin theo digest**, không theo tag: `edge` có Studio nhưng rebuild mỗi lần - push main; semver thì không có Studio. Digest - `sha256:3f125caa…` bất biến và đã kiểm chứng có `/app/studio/index.html`. - Chạy thật: log in `[lumibase-cms] Serving Studio from /app/studio`, - `GET /` trả 200 `text/html` với `LumiBase Studio`, - và `/api/v1/*` vẫn trả JSON `{errors}` chứ không bị SPA catch-all nuốt. +⇒ **Pin by digest, not by tag.** `edge` has Studio but is rebuilt on every push +to main; semver tags have no Studio at all. Digest `sha256:3f125caa…` is +immutable and was verified to contain `/app/studio/index.html`. Running it +produces `[lumibase-cms] Serving Studio from /app/studio`, `GET /` +returns 200 `text/html` with `LumiBase Studio`, and `/api/v1/*` +still answers with the `{errors}` JSON envelope rather than being swallowed by +the SPA catch-all. -- Cơ chế phục vụ Studio: `apps/cms/src/serve.ts:81` gọi `mountStudio`; env - `LUMIBASE_SERVE_STUDIO` (tắt) và `LUMIBASE_STUDIO_DIST` (đổi path) — - `apps/cms/src/serve-studio.ts:94,110`. +How Studio is served: `apps/cms/src/serve.ts:81` calls `mountStudio`; env +`LUMIBASE_SERVE_STUDIO` (disable) and `LUMIBASE_STUDIO_DIST` (relocate) — +`apps/cms/src/serve-studio.ts:94,110`. -- **Local ≠ published:** `docker/docker-compose.yml:86-87` service `cms` dùng - `build:` — build từ source, **không** pull image đã phát hành. Nên compose hiện - có *không* phải bằng chứng image chạy được. Template `nextjs` sẽ ship compose - **pull tag đã pin**, và được verify riêng bằng một lần cold pull. +**Local ≠ published:** `docker/docker-compose.yml:86-87` builds the CMS service +from source, so the repo's own compose file is *not* evidence that any published +image runs. The `nextjs` template ships a compose file that **pulls** the pinned +digest, verified by a cold pull. -### 3.3 Bootstrap first-admin + site +### 3.3 Bootstrapping the first admin and site - `POST /api/v1/setup/complete` (`apps/cms/src/modules/setup/routes.ts:317-379`), - mount public ngoài tenant/auth (`apps/cms/src/index.ts:173`). + mounted publicly outside tenant/auth (`apps/cms/src/index.ts:173`). Body: `account{email,password,firstName,lastName}`, `adminPath`, `setupToken?` (`routes.ts:40-79`). -- Site đầu tiên có id cố định `__default__` - (`apps/cms/src/modules/setup/site-constants.ts:11`) — chọn vậy để chạy lại - wizard là idempotent. -- `LUMIBASE_REQUIRE_SETUP_TOKEN=true` **nhìn thì** in token một lần - (`apps/cms/src/modules/setup/setup-token.ts:199`), nhưng thực tế hàm đó không - bao giờ được gọi — xem §9.1(a). Template vì vậy **không** bật cờ này. +- The first site has the fixed id `__default__` + (`apps/cms/src/modules/setup/site-constants.ts:11`), chosen so re-running the + wizard is idempotent. +- `LUMIBASE_REQUIRE_SETUP_TOKEN=true` **appears** to print a token once + (`apps/cms/src/modules/setup/setup-token.ts:199`), but that helper is never + actually called — see §9.1(a) and #470. The template therefore leaves the flag + off. -## 4. Collection, seed và public client +## 4. Collection, seed and public client ### 4.1 Collection -Một collection `posts`, field tối thiểu `title` / `slug` / `body`. +One `posts` collection with a minimal `title` / `slug` / `body` field set. -Mô hình là `collections → fields → items` -(`packages/database/src/schema/cms.ts:47,88,184`); `items.status` mặc định -`draft` (`:194-195`). Tạo collection kèm `fields` inline qua -`POST /api/v1/collections` (`apps/cms/src/routes/collections.ts:97,162-178`); -tạo item qua `POST /api/v1/items/:collection` +The model is `collections → fields → items` +(`packages/database/src/schema/cms.ts:47,88,184`); `items.status` defaults to +`draft` (`:194-195`). Create the collection with inline `fields` via +`POST /api/v1/collections` (`apps/cms/src/routes/collections.ts:97,162-178`), and +items via `POST /api/v1/items/:collection` (`apps/cms/src/routes/items.ts:106-118`). -### 4.2 Seed chạy lại không trùng +### 4.2 A seed that is safe to re-run -Theo đúng pattern repo đã dùng: id ổn định + `onConflictDoNothing`, như -`packages/database/scripts/seed-content-os-demo.ts:109,127,166`. Seed -site-scoped và chạy **server-side** trong bước bootstrap. +Following the pattern the repo already uses: stable ids plus +`onConflictDoNothing`, as in +`packages/database/scripts/seed-content-os-demo.ts:109,127,166`. The seed is +site-scoped and runs **server-side** during bootstrap. ### 4.3 Public client — publishable key -Chọn **publishable key** (không dùng đường anonymous thuần, lý do ở §6): +A **publishable key** rather than the pure anonymous realm (reasoning in §6): -- Key class `lbk_pub_` (`apps/cms/src/services/api-key-publishable.ts:29`), tách - khỏi secret key `lbk_`. Gửi qua `Authorization: Bearer `; server chỉ - lưu hash (`apps/cms/src/middleware/auth.ts:285-291`). -- Publishable key bị **origin-check** theo `metadata.allowedOrigins` +- Key class `lbk_pub_` (`apps/cms/src/services/api-key-publishable.ts:29`), + distinct from the secret `lbk_`. Sent as `Authorization: Bearer `; the + server stores only a hash (`apps/cms/src/middleware/auth.ts:285-291`). +- Publishable keys are **origin-checked** against `metadata.allowedOrigins` (`apps/cms/src/middleware/auth.ts:329-349`). - ⚠️ Allowlist rỗng = `no_constraint`, dùng được từ mọi nơi - (`apps/cms/src/services/api-key-publishable.ts:75-80`) — template **phải** set - `allowedOrigins` tường minh. -- Key gắn cứng vào site: `apiKey.siteId !== siteId` ⇒ 401 + audit - (`apps/cms/src/middleware/auth.ts:299-305`). Đây chính là cơ chế khiến client - tenant B không đọc được nội dung tenant A. - -### 4.4 ⚠️ Rủi ro lộ draft — đã chốt và đã kiểm chứng - -`GET /api/v1/items` **không** tự lọc `published`: `status` chỉ là query param -optional, chỉ áp dụng khi client truyền (`apps/cms/src/routes/items.ts:27`, -`apps/cms/src/services/item-service.ts:693`). Và `enablePublicAccess` chỉ tạo -role + policy, **không tạo permission row nào** + ⚠️ An empty allowlist means `no_constraint` — usable from anywhere + (`apps/cms/src/services/api-key-publishable.ts:75-80`) — so the template must + set `allowedOrigins` explicitly. +- The key is bound to one site: `apiKey.siteId !== siteId` ⇒ 401 plus an audit + record (`apps/cms/src/middleware/auth.ts:299-305`). That is the mechanism which + stops a tenant B client reading tenant A content. + +### 4.4 ⚠️ Draft-leak risk — decided and verified + +`GET /api/v1/items` does **not** filter to published on its own: `status` is an +optional query parameter, applied only when the caller passes it +(`apps/cms/src/routes/items.ts:27`, +`apps/cms/src/services/item-service.ts:693`). And `enablePublicAccess` +provisions a role and a policy but **no permission rows** (`apps/cms/src/services/auth/public-role.ts:130-175`). -⇒ Nếu grant `read` mà không kèm filter, client công khai **đọc được cả draft**. +⇒ A `read` grant without a row filter means the public client **reads drafts**. -**Đã chốt:** grant `read` trên `posts` luôn kèm `publishedOnly: true` -(`apps/cms/src/routes/access-grants.ts:82`), biên dịch thành -`{ status: { _eq: 'published' } }` (`apps/cms/src/services/auth/realm-access.ts:35`), -cộng `fields` whitelist. Truyền tường minh chứ không dựa vào mặc định -server-side (`realm-access.ts:226` bật sẵn cho `read`) — mặc định có thể đổi. +**Decided:** the `read` grant on `posts` always carries `publishedOnly: true` +(`apps/cms/src/routes/access-grants.ts:82`), which compiles to +`{ status: { _eq: 'published' } }` +(`apps/cms/src/services/auth/realm-access.ts:35`), plus a `fields` whitelist. +Passed explicitly rather than relying on the server-side default +(`realm-access.ts:226` turns it on for `read`) — defaults can change. -Đã kiểm chứng trên instance thật: seed cố tình để lại một bài draft, và -`cms:verify` xác nhận publishable key chỉ thấy `published` (§9). +Verified on a live instance: the seed deliberately leaves one post as a draft, +and `cms:verify` confirms the publishable key sees only `published` (§9). -### 4.5 Token quản trị +### 4.5 The admin token -Chỉ dùng ở bước bootstrap/seed phía server. Biến admin **không** mang prefix -`NEXT_PUBLIC_`, nên không thể lọt browser bundle. Bằng chứng: grep bundle đã -build. +Used only during server-side bootstrap and seeding. Admin variables carry no +`NEXT_PUBLIC_` prefix, so they cannot reach the browser bundle. Evidence: a +production build with sentinel values (§9). -## 5. Bảng file, env và lệnh +## 5. Files, environment and commands -### 5.1 File xin cấp phát +### 5.1 File grant requested -| File | Thêm/Sửa | +| File | Added/Changed | |---|---| -| `packages/create-lumibase/templates/nextjs/**` | mới — app Next.js + compose pull image đã pin + script bootstrap/seed | -| `packages/create-lumibase/src/index.ts` | sửa — union `Template`, một prompt choice | -| `packages/create-lumibase/src/scaffold.ts` | sửa — cờ `isNextjs` trong context | -| `packages/create-lumibase/src/templates.test.ts` | sửa — mở rộng `it.each` sang `nextjs` | -| `packages/create-lumibase/src/nextjs-template.test.ts` | mới — bất biến an toàn (không rò credential, `publishedOnly`, pin digest) | -| `packages/create-lumibase/src/utils/print.ts` | sửa — next-steps cho template `nextjs` | -| `packages/cli/src/commands/init.test.ts` | sửa — khoá việc forward `--template` nguyên vẹn | - -**Không đụng:** `packages/sdk/**`, `apps/studio/**`, root manifest/lockfile, -`.github/workflows/**`, docs/spec dùng chung. `#334` sở hữu reference example — -contract này không tạo example độc lập. Tránh va `#467` (nhánh +| `packages/create-lumibase/templates/nextjs/**` | new — Next.js app + compose pulling the pinned image + bootstrap/seed scripts | +| `packages/create-lumibase/src/index.ts` | changed — `Template` union, one prompt choice | +| `packages/create-lumibase/src/scaffold.ts` | changed — `isNextjs` context flag | +| `packages/create-lumibase/src/templates.test.ts` | changed — extend `it.each` to `nextjs` | +| `packages/create-lumibase/src/nextjs-template.test.ts` | new — safety invariants (no credential leak, `publishedOnly`, digest pin) | +| `packages/create-lumibase/src/utils/print.ts` | changed — next-steps for the `nextjs` template | +| `packages/cli/src/commands/init.test.ts` | changed — pin verbatim `--template` forwarding | + +**Untouched:** `packages/sdk/**`, `apps/studio/**`, the root manifest/lockfile, +`.github/workflows/**`, shared docs/specs. `#334` owns the reference example, so +this contract adds no standalone example. Avoids colliding with `#467` (branch `chore/deps-batch-2026-09`). -### 5.2 Contract biến môi trường +### 5.2 Environment variable contract -| Biến | Phía | Vai trò | +| Variable | Side | Role | |---|---|---| -| `NEXT_PUBLIC_LUMIBASE_URL` | browser | base URL của CMS | -| `NEXT_PUBLIC_LUMIBASE_SITE_ID` | browser | `__default__`; gửi qua `X-Lumi-Site` | -| `NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY` | browser | key `lbk_pub_`, read-only + published-only | -| `LUMIBASE_ADMIN_TOKEN` | **server only** | chỉ bootstrap/seed | -| `LUMIBASE_REQUIRE_SETUP_TOKEN` | container | bật setup token | - -Tenant resolution: header **`X-Lumi-Site`** là đường chính -(`apps/cms/src/middleware/tenant.ts:26`) — đúng header SDK đã gửi sẵn +| `NEXT_PUBLIC_LUMIBASE_URL` | browser | CMS base URL | +| `NEXT_PUBLIC_LUMIBASE_SITE_ID` | browser | `__default__`; sent as `X-Lumi-Site` | +| `NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY` | browser | `lbk_pub_` key, read-only + published-only | +| `LUMIBASE_ADMIN_TOKEN` | **server only** | bootstrap/seed only | +| `LUMIBASE_REQUIRE_SETUP_TOKEN` | container | setup-token gate (left off — #470) | + +Tenant resolution: the **`X-Lumi-Site`** header is the primary path +(`apps/cms/src/middleware/tenant.ts:26`) — the same header the SDK already sends (`packages/sdk/src/client.ts:183`). -### 5.3 Lệnh cold-install (ngoài monorepo, không `workspace:*`) +### 5.3 Cold-install commands (outside the monorepo, no `workspace:*`) ```bash pnpm -F create-lumibase build && npm pack @@ -227,105 +225,123 @@ cd "$(mktemp -d)" && npm i npx create-lumibase my-site --template nextjs --pm npm --no-git ``` -## 6. Điểm cần SDK/API hỗ trợ - -`LumiClientOptions.token` là **bắt buộc**, kiểu `string`, doc ghi "Logto access -token" (`packages/sdk/src/client.ts:12`), và client **luôn** set -`authorization: Bearer ${currentToken}` (`packages/sdk/src/client.ts:182`). -Không có chế độ anonymous/publishable. - -- Publishable key **vẫn dùng được ngay**: nó đi qua đúng `Authorization: Bearer`, - nên truyền key vào `token` là chạy. **Không chặn #332.** -- Nhưng đường **anonymous thuần** (`apps/cms/src/middleware/auth.ts:529-545`; - chỉ `GET`/`HEAD`, chỉ các prefix `/api/v1/items|search|media|files` — `:567-575`) - thì SDK hiện **không gọi được** vì không bỏ được header `authorization`. -- Đề xuất: #332 dùng publishable key. Việc nới `token?: string` thuộc SDK owner; - contract này **không** sửa `packages/sdk`. Xin reviewer xác nhận có tách - ticket riêng hay không. - -## 7. Bằng chứng nghiệm thu sẽ nộp - -- Pack rồi cài vào thư mục ngoài monorepo; không `workspace:*`; hai entrypoint - hoạt động tương đương. -- Seed chạy hai lần không trùng. -- Studio sửa/publish → website đọc được thay đổi **thật**, không mock. -- Grep bundle chứng minh không rò admin token. -- Client tenant B không đọc được nội dung tenant A. -- **Assert publishable key không nhìn thấy item draft** (§4.4). -- Regression template `default` + `cloudflare`. -- Handoff ghi base/head, changed paths, lệnh/exit code/skips, phần chưa xác minh. -- Artifact local ghi tách bạch với npm/image đã phát hành; bằng chứng - cold-install nộp cho #448. - -## 8. Bốn điểm chặn — đã quyết - -Owner chỉ đạo implement luôn, nên cả bốn được quyết theo đề xuất: - -1. **#450**: **rút lại** đề nghị known-fail. Reviewer đúng (P2.4): tôi suy ra - "Cloudflare hiện không cài được" từ header `templates.test.ts`, nhưng đoạn - đó mô tả sự cố **trước** khi fix. Kiểm chứng thật: scaffold `cloudflare` rồi - `npm install` → **added 63 packages, không ERESOLVE**. Vậy không có - reproduction, không cần waiver. #450 vẫn cần acceptance riêng của reviewer. -2. **Row-filter `status = published`**: chốt bắt buộc. API đã có sẵn cờ - `publishedOnly` (`apps/cms/src/routes/access-grants.ts:82`) biên dịch thành +## 6. Where SDK/API support is needed + +`LumiClientOptions.token` is **required**, typed `string`, and documented as a +"Logto access token" (`packages/sdk/src/client.ts:12`); the client always sets +`authorization: Bearer ${currentToken}` (`packages/sdk/src/client.ts:182`). There +is no anonymous/publishable mode. + +- A publishable key **works today**: it travels over that same + `Authorization: Bearer` header, so passing the key as `token` is enough. + **Not a blocker for #332.** +- But the **pure anonymous** path (`apps/cms/src/middleware/auth.ts:529-545`; + `GET`/`HEAD` only, and only the prefixes `/api/v1/items|search|media|files` — + `:567-575`) is unreachable from the SDK, because the `authorization` header + cannot be omitted. +- Decision: #332 uses a publishable key. Relaxing `token?: string` belongs to the + SDK owner; this contract does **not** modify `packages/sdk`. + +## 7. Acceptance evidence to be supplied + +- Pack and install into a directory outside the monorepo; no `workspace:*`; both + entrypoints behave equivalently. +- Seed twice with no duplication. +- Edit/publish in Studio → the website reads the **real** change, no mock data. +- Grep the bundle to prove no admin token leaks. +- A tenant B client cannot read tenant A content. +- **Assert the publishable key cannot see a draft** (§4.4). +- Regression on the `default` and `cloudflare` templates. +- Handoff records base/head, changed paths, commands/exit codes/skips, and what + remains unverified. +- Local artifacts recorded separately from published npm/image artifacts; + cold-install evidence supplied to #448. + +## 8. The four blocking questions — decided + +The owner directed implementation to proceed, so all four were decided as +proposed: + +1. **#450**: the known-fail request is **withdrawn**. The reviewer was right + (P2.4): I inferred "the cloudflare template cannot install" from the header of + `templates.test.ts`, but that paragraph describes the failure **before** it was + fixed. Verified for real: scaffolding `cloudflare` and running `npm install` + **adds 63 packages with no ERESOLVE**. No reproduction, so no waiver to ask + for. #450 still needs its own reviewer acceptance. +2. **The `status = published` row filter**: mandatory. The API already exposes a + `publishedOnly` flag (`apps/cms/src/routes/access-grants.ts:82`) compiling to `{ status: { _eq: 'published' } }` - (`apps/cms/src/services/auth/realm-access.ts:35`), nên không phải tự viết DSL. -3. **Pin image**: đề xuất ban đầu (`1.0.0-rc.1`) **sai** — tag đó không có - Studio. Sửa thành pin theo digest `sha256:3f125caa…` (§3.2). -4. **Không sửa `packages/sdk`**: giữ nguyên. Publishable key đi qua đúng header - `Authorization: Bearer` nên client hiện tại dùng được ngay. + (`apps/cms/src/services/auth/realm-access.ts:35`), so no hand-written DSL is + needed. +3. **Image pin**: the original proposal (`1.0.0-rc.1`) was **wrong** — that tag + has no Studio. Changed to a digest pin, `sha256:3f125caa…` (§3.2). +4. **`packages/sdk` untouched**: unchanged. A publishable key travels over the + existing `Authorization: Bearer` header, so the current client works as-is. -## 9. Đã chạy thật — bằng chứng +## 9. What was actually run — evidence -Toàn bộ vòng đời chạy trên instance thật (cold install ngoài monorepo → Docker -→ bootstrap → seed → website), không mock: +The whole lifecycle ran against a real instance (cold install outside the +monorepo → Docker → bootstrap → seed → website), with no mocks: -| Hạng mục | Kết quả | +| Item | Result | |---|---| -| Cold install từ tarball đã pack, ngoài monorepo | ✔ không `workspace:*`, không `.hbs` sót | -| `npm install` project scaffold | ✔ 31 packages, **không ERESOLVE** | -| `tsc --noEmit` trong project scaffold | ✔ exit 0 | -| Pull image theo digest `sha256:3f125caa…` | ✔ chạy được, **có Studio** | -| Studio phục vụ tại `/` | ✔ 200 `text/html`, `LumiBase Studio` | -| `/api/v1/*` không bị SPA nuốt | ✔ vẫn trả `{errors}` JSON | -| `cms:bootstrap` | ✔ trọn 6/6 bước | -| `cms:seed` chạy 2 lần | ✔ lần 1 tạo 3, lần 2 tạo 0 — idempotent | -| `cms:verify` | ✔ đọc được published, **không thấy draft**, không ghi được | -| Website render | ✔ hiện 2 bài published, **không hiện draft** | -| Publish draft → reload | ✔ bài xuất hiện (0 → 1), dữ liệu thật | -| Rò token trong HTML runtime | ✔ 0 lần xuất hiện admin token/password | -| **Sentinel build production** | ✔ sentinel admin/password **0 file** trong `.next`; publishable key **2 file** (đối chứng dương) | -| **Studio trong browser** | ✔ đăng nhập được, mở `posts`, thấy 3 item: 1 `DRAFT` + 2 `PUBLISHED` | -| Draft lấy theo **id trực tiếp** | ✔ không lấy được (`ZYkt-txK…`) | -| Truy vấn `?status=draft` bằng public key | ✔ trả 0 item | - -### 9.1 Hai lỗi CMS phát hiện khi chạy thật (issue #469, #470) - -Cả hai **nằm ngoài phạm vi #332** (không được sửa `apps/cms`), đã né trong -template và ghi vào README của starter: - -**(a) Cờ setup token khoá chết instance — #470.** `printSetupTokenIfRequired` -(`apps/cms/src/modules/setup/setup-token.ts:148`) có unit test nhưng **không -được gọi từ đâu** lúc khởi động — grep toàn repo chỉ ra 3 kết quả, đều trong -chính file đó. Bật `LUMIBASE_REQUIRE_SETUP_TOKEN=true` ⇒ `/setup/state` trả -`requiresSetupToken: true`, `/setup/complete` trả `SETUP_TOKEN_REQUIRED`, và -không có cách nào lấy token. Đã kiểm chứng trực tiếp. ⇒ compose **không** bật cờ -này; stack chỉ bind localhost. - -**(b) Header site giả làm sập CMS — DoS không cần xác thực — #469.** `withTenant` chỉ -kiểm tra *định dạng* của `X-Lumi-Site` (`apps/cms/src/middleware/tenant.ts:29-43`), -không kiểm tra site có tồn tại. Khi từ chối api key, `auditApiKeyUseDenied` ghi -audit với chính site id do client gửi (`apps/cms/src/middleware/auth.ts:93`), vi -phạm FK `lumibase_audit_log_site_id_lumibase_sites_id_fk` và **giết process**. -Tái hiện chắc chắn: một request duy nhất → 401 → `health` = 000. -⇒ `verify.mjs` để phép thử cross-tenant sau cờ `LUMIBASE_VERIFY_CROSS_TENANT=1`, -nếu không `cms:verify` sẽ tự bắn sập CMS của người dùng. - -### 9.2 Lệch so với contract ban đầu - -- **Thêm Redis vào compose.** Không có nó, runtime Docker fallback về - `127.0.0.1:6379` và đẩy **506 dòng ECONNREFUSED** vào log, che hết thông tin - hữu ích. Có Redis: **0 lỗi**. -- **Bỏ `LUMIBASE_REQUIRE_SETUP_TOKEN`** — lý do ở §9.1(a). -- **Thêm validate `--template`**: trước đây tên template sai đi thẳng tới - `scaffold()` và chết bằng ENOENT trỏ vào đường dẫn nội bộ. +| Cold install from a packed tarball, outside the monorepo | ✔ no `workspace:*`, no leftover `.hbs` | +| `npm install` in the scaffolded project | ✔ 31 packages, **no ERESOLVE** | +| `tsc --noEmit` in the scaffolded project | ✔ exit 0 | +| Pull the image by digest `sha256:3f125caa…` | ✔ runs, **contains Studio** | +| Studio served at `/` | ✔ 200 `text/html`, `LumiBase Studio` | +| `/api/v1/*` not swallowed by the SPA | ✔ still returns the `{errors}` JSON envelope | +| `cms:bootstrap` | ✔ all 6 steps | +| `cms:seed` run twice | ✔ first run creates 3, second creates 0 — idempotent | +| `cms:verify` | ✔ reads published, **cannot see the draft**, cannot write | +| Website render | ✔ 2 published posts, **no draft** | +| Publish the draft → reload | ✔ appears (0 → 1), real data | +| Admin token/password in runtime HTML | ✔ 0 occurrences | +| **Sentinel production build** | ✔ admin/password sentinels **0 files** in `.next`; publishable key **2 files** (the positive control that makes the zero meaningful) | +| **Studio in a browser** | ✔ signed in, opened `posts`, saw 3 items: 1 `DRAFT` + 2 `PUBLISHED` | +| Draft fetched by **direct id** | ✔ unreachable (`ZYkt-txK…`) | +| `?status=draft` via the public key | ✔ 0 items | +| Regression: scaffold `default` + `cloudflare` | ✔ both fine | +| `npm install` on the `cloudflare` template | ✔ 63 packages, **no ERESOLVE** (refutes the earlier claim) | +| `turbo run typecheck` across the repo | ✔ 18/18 | +| `create-lumibase` tests | ✔ 30/30 | +| `lumibase` tests | ✔ 47/47 | + +### 9.1 Two CMS bugs found by running it (issues #469, #470) + +Both are **outside the scope of #332** (`apps/cms` must not be modified here), so +the template works around them and the starter's README says why: + +**(a) The setup-token flag locks the instance out — #470.** +`printSetupTokenIfRequired` (`apps/cms/src/modules/setup/setup-token.ts:148`) is +unit-tested but **called from nowhere** at startup — a repo-wide grep returns +three hits, all inside that file. With `LUMIBASE_REQUIRE_SETUP_TOKEN=true`, +`/setup/state` reports `requiresSetupToken: true`, `/setup/complete` answers +`SETUP_TOKEN_REQUIRED`, and there is no way to obtain the token. Verified +directly. ⇒ the compose file leaves the flag off; the stack binds to localhost +instead. + +**(b) A forged site header crashes the CMS — unauthenticated DoS — #469.** +`withTenant` only shape-checks `X-Lumi-Site` +(`apps/cms/src/middleware/tenant.ts:29-43`); it does not confirm the site exists. +When an API key is rejected, `auditApiKeyUseDenied` writes the audit row under +that client-supplied site id (`apps/cms/src/middleware/auth.ts:93`), violating the +`lumibase_audit_log_site_id_lumibase_sites_id_fk` foreign key. Tracing further: +`AuditLogger.write` **does** catch failures on the synchronous insert path +(`logger.ts:485-490`); the crash comes from the **queue** path — the batcher +catches and then **`throw err`** (`worker.ts:139-141`) inside a fire-and-forget +flush ⇒ unhandled rejection ⇒ **the process dies**. The batch also groups several +sites into one insert, so one bad row **loses the audit records of valid sites**. +Reliably reproduced: a single request → 401 → `health` = 000. +⇒ `verify.mjs` keeps its cross-tenant probe behind +`LUMIBASE_VERIFY_CROSS_TENANT=1`; otherwise `cms:verify` would knock over the +user's own CMS. + +### 9.2 Divergences from the original contract + +- **Redis added to the compose file.** Without it the Docker runtime falls back + to `127.0.0.1:6379` and floods the log with **506 ECONNREFUSED lines**, burying + anything useful. With Redis: **0 errors**. +- **`LUMIBASE_REQUIRE_SETUP_TOKEN` dropped** — reasoning in §9.1(a). +- **`--template` validation added**: a misspelled template name used to reach + `scaffold()` unchecked and die on ENOENT naming an internal path. From dc7fd5b9788c8f7bcca51bf03bd55cec6ea7907b Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 01:58:37 +0700 Subject: [PATCH 09/26] fix(create-lumibase): bind the starter's stack to loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `"1989:1989"` publishes on every interface — Docker binds 0.0.0.0 unless a host IP is given. That quietly destroyed the argument the rest of the starter rests on: the setup-token gate is left off because #470 makes it unusable, and the README says that is acceptable because the stack only listens on localhost. It did not. Anyone on the same network could reach a CMS with a fixed dev JWT_SECRET and no setup gate, and claim the admin account. All three services now name 127.0.0.1, Postgres and Redis included — neither has credentials worth exposing either. Confirmed with `docker compose config` (host_ip: 127.0.0.1 on each) and against the running stack, whose port table reads 127.0.0.1:11989->1989/tcp. Refs #332 --- .../templates/nextjs/docker-compose.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/docker-compose.yml b/packages/create-lumibase/templates/nextjs/docker-compose.yml index 001f2417..bc0933d7 100644 --- a/packages/create-lumibase/templates/nextjs/docker-compose.yml +++ b/packages/create-lumibase/templates/nextjs/docker-compose.yml @@ -24,8 +24,10 @@ services: POSTGRES_DB: lumibase POSTGRES_USER: lumibase POSTGRES_PASSWORD: lumibase_dev + # Loopback only. Nothing outside this machine needs the database, and the + # password here is a development default. ports: - - "${POSTGRES_PORT:-5432}:5432" + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -36,8 +38,9 @@ services: redis: image: redis:7-alpine + # Loopback only; Redis has no auth configured here. ports: - - "${REDIS_PORT:-6379}:6379" + - "127.0.0.1:${REDIS_PORT:-6379}:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -72,8 +75,17 @@ services: # Without this the Docker runtime falls back to 127.0.0.1:6379 and floods # the log with ECONNREFUSED, which buries anything worth reading. REDIS_URL: redis://redis:6379 + # Bound to loopback DELIBERATELY, and it is what makes the note above + # defensible: with the setup-token gate off (#470) and a fixed dev + # JWT_SECRET, publishing this on 0.0.0.0 would let anyone who can reach the + # port claim the admin account. `1989:1989` would do exactly that — Docker + # publishes on ALL interfaces unless a host IP is given. + # + # Reaching this from another device (a phone on the same Wi-Fi, a colleague) + # is not a matter of deleting `127.0.0.1`: replace JWT_SECRET and + # ENCRYPTION_KEY first, and put a real origin in LUMIBASE_PUBLIC_ORIGIN. ports: - - "${CMS_PORT:-1989}:1989" + - "127.0.0.1:${CMS_PORT:-1989}:1989" depends_on: postgres: condition: service_healthy From 00564dbf861a629dbfab1bc4d35f7c10f8a99527 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 01:58:37 +0700 Subject: [PATCH 10/26] fix(create-lumibase): stop cms:verify passing against a broken server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checks caught every CmsError and read it as "the guard refused us", so any failure counted as proof of safety. A server answering 500 therefore produced a green run — the one script whose job is to prove the site is safe was a rubber stamp. Reproduced with a fixture that answers 500 to everything after the first list: the old script reported both the write check and the status=draft check as passing. It now accepts only what the server actually says: 401 or 403. 404 is accepted for the direct-id read alone, because there hiding the row IS the refusal — and the better one, since 403 would confirm the id exists. That is how this CMS behaves: the same id returns the draft to the admin token, 404 to the publishable key, and 200 for a published id. It stays rejected for writes, where a 404 just means the route was wrong. Checks that cannot run are now reported as SKIPPED rather than folded into the pass count, so "all checks passed" no longer covers checks that never happened. Refs #332 --- .../templates/nextjs/scripts/verify.mjs | 172 +++++++++++++----- 1 file changed, 122 insertions(+), 50 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs index e2a0d3cb..06cab6a3 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs @@ -3,31 +3,100 @@ * * npm run cms:verify * - * Four assertions, all made with the publishable key the browser holds — never + * Every assertion is made with the publishable key the browser holds — never * with the admin token: * * 1. The key can read published posts. - * 2. The key CANNOT see the draft. + * 2. The key CANNOT see the draft — by list, by direct id, or by asking. * 3. The key cannot write. * 4. The key cannot read another tenant's content. * * (2) is the one worth keeping. `GET /api/v1/items` has no implicit * published-only filter, so a read grant made without `publishedOnly` would - * serve drafts to every visitor. This test fails loudly if that protection is + * serve drafts to every visitor. This script fails loudly if that protection is * ever removed. + * + * ## Why a rejection is only trusted when it is the RIGHT rejection + * + * "The request failed, so we must be safe" is not sound. A 500 from a broken + * server, a 404 from a typo in the path, a connection reset — all of those look + * like a refusal if you only check that *something* went wrong, and a security + * check that passes because the server is broken is worse than no check at all. + * + * So a denial counts only when the server actually denied it: HTTP 401 or 403 — + * plus 404 for the one case where hiding a row IS the refusal (see + * DENIED_OR_HIDDEN). Anything else fails the run and prints the status it got, + * and a check that could not be performed is reported as SKIPPED rather than + * folded into "all checks passed". */ import { api, requireEnv, waitForCms, CmsError, COLLECTION } from './lumibase.mjs'; const PUBLIC_ORIGIN = process.env.LUMIBASE_PUBLIC_ORIGIN || 'http://localhost:3000'; +/** Statuses that mean "the server refused this on purpose". */ +const DENIED = new Set([401, 403]); + +/** + * Reading a hidden row is the one case where 404 is also a correct refusal — + * and in fact the better one. + * + * The public grant hides drafts with a row filter, so a draft simply does not + * exist for this principal; the server says "not found" rather than "forbidden", + * which is what you want, since 403 would confirm the id is real. Verified + * against a live CMS: the same id returns the draft to the admin token, 404 to + * the publishable key, while a published id returns 200 to both. + * + * This is deliberately NOT accepted for writes: there, a 404 means the route is + * wrong and the test proved nothing. + */ +const DENIED_OR_HIDDEN = new Set([401, 403, 404]); + let failures = 0; +let skipped = 0; function check(name, ok, detail = '') { console.log(` ${ok ? '✔' : '✖'} ${name}${detail ? ` — ${detail}` : ''}`); if (!ok) failures += 1; } +function skip(name, why) { + console.log(` · ${name} — SKIPPED (${why})`); + skipped += 1; +} + +/** + * Run a request that MUST be refused. + * + * Passes only when the server answered with one of `accepted`. A success means + * the guard is missing; any other failure means we learned nothing and must not + * pretend otherwise — both are reported, neither is silently swallowed. + */ +async function expectDenied(name, run, accepted = DENIED) { + const expected = [...accepted].join('/'); + try { + await run(); + check(name, false, 'the request SUCCEEDED — the guard is missing'); + return; + } catch (err) { + if (!(err instanceof CmsError)) { + // Connection reset, DNS, a crashed server mid-request… not a denial. + check(name, false, `unexpected error: ${err.message}`); + return; + } + if (!accepted.has(err.status)) { + check( + name, + false, + `expected ${expected} but got ${err.status} — this is not a denial, and ` + + 'a broken server must never read as a passing security check', + ); + return; + } + check(name, true, `denied with ${err.status}`); + } +} + async function main() { const key = requireEnv('NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY'); await waitForCms(); @@ -61,57 +130,61 @@ async function main() { // from the admin token (server-side, never in the browser) precisely so the // public key is asked for something we know exists. const adminToken = process.env.LUMIBASE_ADMIN_TOKEN; - if (adminToken) { + if (!adminToken) { + skip('the draft is unreachable by direct id', 'LUMIBASE_ADMIN_TOKEN not set'); + } else { const all = await api(`/api/v1/items/${COLLECTION}?limit=200`, { token: adminToken }); const draft = (all?.data ?? []).find((i) => i?.status && i.status !== 'published'); if (!draft) { - check('a draft exists to test against', false, 'seed one with: npm run cms:seed'); + skip( + 'the draft is unreachable by direct id', + 'no draft to test against — run: npm run cms:seed', + ); } else { - let reached = false; - try { - await asPublic(`/api/v1/items/${COLLECTION}/${draft.id}`); - reached = true; - } catch (err) { - if (!(err instanceof CmsError)) throw err; - } - check('the draft is unreachable by direct id', !reached, `id ${draft.id}`); + await expectDenied( + `the draft is unreachable by direct id (${draft.id})`, + () => asPublic(`/api/v1/items/${COLLECTION}/${draft.id}`), + DENIED_OR_HIDDEN, + ); } - } else { - console.log(' · direct-id draft check skipped (LUMIBASE_ADMIN_TOKEN not set)'); } // 2c — asking for drafts explicitly must not produce any. - const asked = await asPublic(`/api/v1/items/${COLLECTION}?status=draft&limit=50`).catch( - (err) => { - if (err instanceof CmsError) return { data: [] }; - throw err; - }, - ); - check( - 'asking for status=draft returns nothing', - (asked?.data ?? []).length === 0, - `${(asked?.data ?? []).length} item(s)`, - ); + // + // Two acceptable outcomes, and they are checked separately: the server either + // refuses the query (401/403) or answers with an empty list. A 500 is neither. + try { + const asked = await asPublic(`/api/v1/items/${COLLECTION}?status=draft&limit=50`); + const got = asked?.data ?? []; + check('asking for status=draft returns nothing', got.length === 0, `${got.length} item(s)`); + } catch (err) { + if (err instanceof CmsError && DENIED.has(err.status)) { + check('asking for status=draft returns nothing', true, `denied with ${err.status}`); + } else { + check( + 'asking for status=draft returns nothing', + false, + err instanceof CmsError + ? `expected an empty list or 401/403, got ${err.status}` + : `unexpected error: ${err.message}`, + ); + } + } // 3 — cannot write - let wrote = false; - try { - await asPublic(`/api/v1/items/${COLLECTION}`, { + await expectDenied('publishable key cannot create items', () => + asPublic(`/api/v1/items/${COLLECTION}`, { method: 'POST', body: { data: { title: 'should not exist', slug: 'should-not-exist' } }, - }); - wrote = true; - } catch (err) { - if (!(err instanceof CmsError)) throw err; - } - check('publishable key cannot create items', !wrote); + }), + ); // 4 — cannot cross tenants. // // Skipped by default, and that is deliberate. Presenting the key with a - // foreign X-Lumi-Site does correctly return 401 — but on v1.0.0-rc.1 it also - // CRASHES the CMS: the denial is written to the audit log under the + // foreign X-Lumi-Site does correctly return 401 — but on the published image + // it also CRASHES the CMS: the denial is written to the audit log under the // client-supplied site id, which no row in `sites` matches, so the insert // violates a foreign key and takes the process down. One request from an // unauthenticated caller is enough. @@ -119,21 +192,16 @@ async function main() { // Running this check would therefore knock over your own container. Opt in // with LUMIBASE_VERIFY_CROSS_TENANT=1 once that is fixed upstream (#469). if (process.env.LUMIBASE_VERIFY_CROSS_TENANT === '1') { - let crossed = false; - try { - await api(`/api/v1/items/${COLLECTION}?limit=1`, { + await expectDenied('publishable key cannot read another site', () => + api(`/api/v1/items/${COLLECTION}?limit=1`, { token: key, headers: { origin: PUBLIC_ORIGIN, 'x-lumi-site': 'some-other-site' }, - }); - crossed = true; - } catch (err) { - if (!(err instanceof CmsError)) throw err; - } - check('publishable key cannot read another site', !crossed); + }), + ); } else { - console.log( - ' · cross-tenant check skipped (it crashes v1.0.0-rc.1 — ' + - 'set LUMIBASE_VERIFY_CROSS_TENANT=1 to run it anyway)', + skip( + 'publishable key cannot read another site', + 'it crashes the published CMS (#469) — set LUMIBASE_VERIFY_CROSS_TENANT=1 to run it', ); } @@ -141,7 +209,11 @@ async function main() { console.error(`\n✖ ${failures} check(s) failed.\n`); process.exit(1); } - console.log('\n✔ All checks passed.\n'); + if (skipped > 0) { + console.log(`\n✔ All checks passed (${skipped} skipped — see above).\n`); + } else { + console.log('\n✔ All checks passed.\n'); + } } main().catch((err) => { From e62ef1352341a3f4ca937e1e8ada4027415f082b Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 01:58:51 +0700 Subject: [PATCH 11/26] fix(create-lumibase): make bootstrap and seed actually re-runnable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were documented as idempotent and neither was. Bootstrap POSTed a new publishable key every run, so a retry after a partial failure left extra live keys carrying read access, with nothing to revoke them. It now reuses the key it already created, rotates it when the local token is gone (the plaintext is returned only once, so a lost token cannot be recovered — but the key's identity, roles and origin allowlist survive, and the old token stops working), and creates one only when none exists. Running it twice now leaves exactly one key with one role. The role attachment is checked before it is posted. `api_key_roles` has no ON CONFLICT clause and its primary key is (api_key_id, role_id), so re-attaching the same role errors rather than doing nothing — the "idempotent on the server" assumption in my earlier comment was wrong. It still runs on the reuse path, since a key created by a run that failed right afterwards would otherwise stay permission-less: an api_key principal is built with `roles: []` and inherits nothing. The seed listed the first 200 items and searched that page, so a sample sitting further in read as missing and was recreated — duplicating a post the user may have edited. Each sample is now looked up by its own slug server-side. Demonstrated on a 213-item collection: the old script recreated all three samples, the new one creates none. Refs #332 --- .../templates/nextjs/scripts/bootstrap.mjs | 114 +++++++++++++----- .../templates/nextjs/scripts/seed.mjs | 25 ++-- 2 files changed, 102 insertions(+), 37 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs index 083731e4..bee3bf78 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs @@ -141,37 +141,97 @@ async function enablePublicRead(token) { return roleId; } -async function createPublishableKey(token, roleId) { - step(5, 'Creating a publishable (browser-safe) API key…'); +/** The name the starter's key is registered under, used to find it again. */ +const KEY_NAME = 'Website (publishable)'; - const created = await api('/api/v1/api-keys', { - method: 'POST', - token, - body: { - name: 'Website (publishable)', - description: 'Read-only key embedded in the Next.js site.', - publishable: true, - // An EMPTY allowlist means the key works from anywhere, so it is always - // set explicitly here. - allowedOrigins: [PUBLIC_ORIGIN], - }, - }); +/** + * Ensure exactly ONE publishable key exists, and return a usable token. + * + * Creating a key unconditionally looks harmless because the script is "run + * once", but bootstrap is explicitly re-runnable: a retry after a partial + * failure, or simply running it twice, would leave extra live keys carrying + * read access, with nothing to revoke them. So this reuses what is already + * there: + * + * - a key with this name and a token still in `.env` → reuse it as-is + * - a key with this name but no usable token locally → rotate it (same key, + * fresh token) rather than minting a second one + * - no key → create one + * + * Rotation is the honest move for the middle case: the plaintext is returned + * only at creation, so a lost token cannot be recovered — but the key's + * identity, roles and origin allowlist survive, and the old token stops + * working, which is what you want from a credential you have lost track of. + */ +async function ensurePublishableKey(token, roleId) { + step(5, 'Ensuring a publishable (browser-safe) API key…'); + + const existing = await api('/api/v1/api-keys', { token }); + const mine = (existing?.data ?? []).find( + (k) => k?.name === KEY_NAME && k?.publishable && !k?.revokedAt, + ); - const keyId = created?.data?.id; - const plaintext = created?.data?.token; - if (!keyId || !plaintext) { - throw new Error('Key creation returned no token — it is shown only once.'); + const envToken = process.env.NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY; + + let keyId; + let plaintext; + + if (mine && envToken) { + console.log(' reusing the existing key'); + keyId = mine.id; + plaintext = envToken; + } else if (mine) { + console.log(' key exists but no local token — rotating it'); + const rotated = await api(`/api/v1/api-keys/${mine.id}/rotate`, { method: 'POST', token, body: {} }); + keyId = mine.id; + plaintext = rotated?.data?.token; + if (!plaintext) throw new Error('Rotation returned no token.'); + } else { + const created = await api('/api/v1/api-keys', { + method: 'POST', + token, + body: { + name: KEY_NAME, + description: 'Read-only key embedded in the Next.js site.', + publishable: true, + // An EMPTY allowlist means the key works from anywhere, so it is always + // set explicitly here. + allowedOrigins: [PUBLIC_ORIGIN], + }, + }); + keyId = created?.data?.id; + plaintext = created?.data?.token; + if (!keyId || !plaintext) { + throw new Error('Key creation returned no token — it is shown only once.'); + } + console.log(' created'); } - // A key with no role carries no permissions at all: an api_key principal is - // built with `roles: []`, so it does not inherit the anonymous realm. - await api(`/api/v1/api-keys/${keyId}/roles`, { - method: 'POST', - token, - body: { roleId }, - }); + // Attach the role only when it is not already attached. + // + // The server does NOT treat a repeat attach as a no-op: the insert into + // `api_key_roles` has no ON CONFLICT clause and the primary key is + // (api_key_id, role_id), so re-posting the same pair errors rather than + // doing nothing. Checking first is what makes re-running bootstrap safe. + // + // It still has to run on the reuse path: a key created by an earlier run that + // failed before this point would otherwise stay permission-less, since an + // api_key principal is built with `roles: []` and inherits nothing. + const detail = await api(`/api/v1/api-keys/${keyId}`, { token }); + const attached = (detail?.data?.roles ?? []).some( + (r) => r === roleId || r?.roleId === roleId || r?.id === roleId, + ); + + if (attached) { + console.log(' role already attached'); + } else { + await api(`/api/v1/api-keys/${keyId}/roles`, { + method: 'POST', + token, + body: { roleId }, + }); + } - console.log(' done'); return plaintext; } @@ -187,7 +247,7 @@ async function main() { await ensureCollection(token); const roleId = await enablePublicRead(token); - const publishableKey = await createPublishableKey(token, roleId); + const publishableKey = await ensurePublishableKey(token, roleId); step(6, 'Writing .env…'); await updateEnvFile({ diff --git a/packages/create-lumibase/templates/nextjs/scripts/seed.mjs b/packages/create-lumibase/templates/nextjs/scripts/seed.mjs index a5aa4614..6600c3e6 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/seed.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/seed.mjs @@ -3,9 +3,9 @@ * * npm run cms:seed * - * Idempotence is by slug, not by database id: the script reads what is already - * in the collection and only creates what is missing. Running it twice leaves - * three posts, not six. + * Idempotence is by slug, not by database id: each sample is looked up by its + * own slug before being created, so running this twice leaves three posts, not + * six — and it stays correct however large the collection grows. * * One post is deliberately left as a draft. It is what proves the public * website cannot see unpublished content — see `npm run cms:verify`. @@ -40,15 +40,20 @@ async function main() { console.log(`Seeding "${COLLECTION}"…\n`); - // Ask for both statuses so an existing draft counts as already-seeded. - const existing = await api(`/api/v1/items/${COLLECTION}?limit=200`, { token }); - const bySlug = new Set( - (existing?.data ?? []).map((item) => item?.slug ?? item?.data?.slug).filter(Boolean), - ); - let created = 0; for (const post of POSTS) { - if (bySlug.has(post.slug)) { + // Ask the server about THIS slug rather than listing the collection and + // searching the page we got back. Listing looks simpler and is wrong once + // the collection outgrows one page: a sample post sitting on page two reads + // as missing, and seeding creates a duplicate of a post the user may have + // since edited. + // + // The filter also covers both statuses, so an existing draft counts as + // already-seeded. + const filter = encodeURIComponent(JSON.stringify({ slug: { _eq: post.slug } })); + const found = await api(`/api/v1/items/${COLLECTION}?filter=${filter}&limit=1`, { token }); + + if ((found?.data ?? []).length > 0) { console.log(` = ${post.slug} (already there)`); continue; } From 3f62f46689d51f37076753072509f735f943a08a Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 01:58:51 +0700 Subject: [PATCH 12/26] fix(create-lumibase): stop the setup screen asking for a token that is never issued MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compose file deliberately leaves the setup-token gate off (#470), and the README and CLI next-steps were updated to match — this page was missed. A new user following it would run `cms:logs`, look for a SETUP_TOKEN that the server never prints, and have nowhere to go. It now names the real flow: bootstrap, then seed. Refs #332, #470 --- packages/create-lumibase/templates/nextjs/app/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/app/page.tsx b/packages/create-lumibase/templates/nextjs/app/page.tsx index 4f274ca0..e6ab1c6e 100644 --- a/packages/create-lumibase/templates/nextjs/app/page.tsx +++ b/packages/create-lumibase/templates/nextjs/app/page.tsx @@ -17,11 +17,11 @@ function Setup() { npm run cms:up — starts the CMS and Studio
  • - npm run cms:logs — copy the SETUP_TOKEN into{' '} - .env + npm run cms:bootstrap — first admin, public read access + and a browser-safe key
  • - npm run cms:bootstrap then npm run cms:seed + npm run cms:seed — sample posts
  • From c43ac32b669d4c6e1893e3bfd2edb47eff93b387 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 01:59:00 +0700 Subject: [PATCH 13/26] test(create-lumibase): pin the five properties review round 2 found missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these was a real regression, so each gets a test that fails if it comes back: every published port names 127.0.0.1; only 401/403 (and 404 where hiding is the refusal) count as a denial; skipped checks are reported separately; bootstrap reuses its key and checks the role before attaching; the seed looks a slug up rather than scanning a page; and the setup screen does not ask for a token the stack never issues. These are text assertions over the template, which is what the offline suite can do — the behaviour itself was verified against a live CMS and is recorded in the spec. Also documents the loopback binding and the denial rules in the starter's README, since both are things a user could undo without realising what they rested on. Refs #332 --- .kiro/specs/nextjs-starter-contract/design.md | 46 ++++++++++- .../src/nextjs-template.test.ts | 81 +++++++++++++++++++ .../templates/nextjs/README.md.hbs | 25 ++++-- 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index 08fcf04c..1cae43b7 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -337,7 +337,51 @@ Reliably reproduced: a single request → 401 → `health` = 000. `LUMIBASE_VERIFY_CROSS_TENANT=1`; otherwise `cms:verify` would knock over the user's own CMS. -### 9.2 Divergences from the original contract +### 9.2 Review round 2 (`5187608588`) — five findings, all valid + +Reviewed at `e3e97d00`; every finding was reproduced before fixing. + +1. **[P1] The stack was published on every interface.** `"1989:1989"` publishes + on `0.0.0.0` — Docker binds all interfaces unless a host IP is given. With the + setup-token gate off and a fixed dev `JWT_SECRET`, anyone on the same network + could claim the admin account, which contradicts the "binds to localhost" + argument the README used to justify leaving the gate off. All three services + now bind `127.0.0.1`; confirmed by `docker compose config` (`host_ip: + 127.0.0.1`) and by the running container's port table. + +2. **[P2] `cms:verify` passed against a broken server.** `catch (err) { if + (!(err instanceof CmsError)) throw err }` treated *any* HTTP failure as a + successful denial, so a 500 read as "the guard worked". Reproduced with a + fixture that answers 500: the old script reported the write and + `status=draft` checks as ✔. Now only 401/403 count as a denial — plus 404 + where hiding a row *is* the refusal, which is how this CMS answers a filtered + read (verified: same id → draft for the admin token, 404 for the publishable + key, 200 for a published id). Anything else fails and prints the status; + un-runnable checks report SKIPPED separately from the pass count. + +3. **[P2] Bootstrap minted a key on every run.** Step 5 always POSTed a new + publishable key, so a retry left extra live keys carrying read access with + nothing to revoke them. It now reuses the existing key, rotates it when the + local token is gone, and creates one only when none exists. Re-running twice + leaves exactly one key with one role (verified). The role attachment is also + checked first: `api_key_roles` has no ON CONFLICT clause and a + `(api_key_id, role_id)` primary key, so re-posting the same pair errors rather + than no-opping — my earlier "idempotent on the server" assumption was wrong. + +4. **[P2] The seed could duplicate past 200 items.** It listed `limit=200` and + searched that page, so a sample sitting on page two read as missing. Now each + sample is looked up by its own slug with a server-side filter. Demonstrated on + a 213-item collection: the old script recreated all three samples; the new one + creates none, and `hello-lumibase` stays a single row. + +5. **[P2] The onboarding screen asked for a token the stack never issues.** + `app/page.tsx` still told users to copy `SETUP_TOKEN` out of the logs after + the gate was disabled — the README and CLI next-steps had been updated, that + page had not. It now names the real flow. + +All five are pinned by tests in `nextjs-template.test.ts` (30 → 40). + +### 9.3 Divergences from the original contract - **Redis added to the compose file.** Without it the Docker runtime falls back to `127.0.0.1:6379` and floods the log with **506 ECONNREFUSED lines**, burying diff --git a/packages/create-lumibase/src/nextjs-template.test.ts b/packages/create-lumibase/src/nextjs-template.test.ts index 408f09e7..0ed2ce80 100644 --- a/packages/create-lumibase/src/nextjs-template.test.ts +++ b/packages/create-lumibase/src/nextjs-template.test.ts @@ -117,6 +117,87 @@ describe('nextjs template — the CMS image is pinned', () => { }); }); +describe('nextjs template — the stack stays on loopback', () => { + // The compose file leaves the setup-token gate off (#470), and the argument + // for that being acceptable is entirely "this only listens on localhost". + // `"1989:1989"` would quietly break that argument: Docker publishes on ALL + // interfaces unless a host IP is given, so anyone on the same network could + // claim the admin account of a stack using a fixed dev JWT_SECRET. + it.each(['1989', '5432', '6379'])('binds port %s to 127.0.0.1', (port) => { + const compose = read('docker-compose.yml'); + const mapping = new RegExp(`- "([^"]*:)?\\$\\{[A-Z_]+:-${port}\\}:${port}"`).exec(compose); + + expect(mapping, `no published mapping found for ${port}`).toBeTruthy(); + expect( + mapping?.[1], + `port ${port} is published on all interfaces. The starter ships dev ` + + 'secrets and no setup-token gate, so every mapping must name 127.0.0.1.', + ).toBe('127.0.0.1:'); + }); +}); + +describe('nextjs template — verification cannot pass on a broken server', () => { + // A check that treats *any* failure as "denied" passes when the server is + // simply broken: a 500 reads exactly like a refusal. That turns the one + // script whose job is to prove the site is safe into a rubber stamp. + it('only accepts 401/403 as a denial', () => { + const verify = read('scripts/verify.mjs'); + expect(verify).toMatch(/DENIED\s*=\s*new Set\(\[401,\s*403\]\)/); + expect(verify).toMatch(/DENIED\.has\(err\.status\)/); + }); + + it('never swallows an unexpected error as a pass', () => { + // The old shape — `catch (err) { if (!(err instanceof CmsError)) throw err }` + // — accepted every HTTP status as proof of a working guard. + const verify = read('scripts/verify.mjs'); + expect(verify).not.toMatch(/if\s*\(!\(err instanceof CmsError\)\)\s*throw err;\s*\n\s*\}/); + }); + + it('reports skipped checks separately from passing ones', () => { + const verify = read('scripts/verify.mjs'); + expect(verify).toMatch(/SKIPPED/); + expect(verify).toMatch(/skipped \+= 1/); + }); +}); + +describe('nextjs template — bootstrap and seed are re-runnable', () => { + it('reuses an existing publishable key instead of minting another', () => { + // Bootstrap is explicitly re-runnable (a retry after a partial failure), + // so an unconditional POST would leave extra live keys carrying read + // access with nothing to revoke them. + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/reusing the existing key/); + expect(bootstrap).toMatch(/rotate/); + }); + + it('checks the role attachment before posting it', () => { + // `api_key_roles` has no ON CONFLICT clause and a (api_key_id, role_id) + // primary key, so re-attaching the same role errors rather than no-opping. + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/role already attached/); + }); + + it('looks a seed slug up by filter rather than scanning one page', () => { + // Listing the first N items and searching them is wrong as soon as the + // collection outgrows that page: the sample reads as missing and gets + // duplicated over a post the user may have edited. + const seed = read('scripts/seed.mjs'); + expect(seed).toMatch(/filter=/); + expect(seed).not.toMatch(/limit=200/); + }); +}); + +describe('nextjs template — onboarding matches the real flow', () => { + it('does not ask for a setup token the stack never issues', () => { + // The compose file deliberately leaves the gate off (#470), so telling a + // new user to copy SETUP_TOKEN out of the logs sends them looking for + // something that is never printed. + const page = read('app/page.tsx'); + expect(page).not.toMatch(/SETUP_TOKEN/); + expect(page).toMatch(/cms:bootstrap/); + }); +}); + describe('nextjs template — package manifest', () => { it('depends on lumibase at runtime, not as a dev dependency', () => { // #332: a scaffolded project must actually use LumiBase, not merely diff --git a/packages/create-lumibase/templates/nextjs/README.md.hbs b/packages/create-lumibase/templates/nextjs/README.md.hbs index 1f57561d..c692c320 100644 --- a/packages/create-lumibase/templates/nextjs/README.md.hbs +++ b/packages/create-lumibase/templates/nextjs/README.md.hbs @@ -55,9 +55,16 @@ npm run cms:verify ``` It uses the publishable key — never the admin token — to prove it can read -published posts, **cannot** see the seeded draft, and cannot write. The seed -deliberately leaves one post unpublished so this check has something real to -catch. (The cross-tenant probe is opt-in; see Known issues.) +published posts, **cannot** see the seeded draft (by list, by direct id, and by +asking for `status=draft`), and cannot write. The seed deliberately leaves one +post unpublished so this check has something real to catch. + +A refusal only counts when the server actually refused: 401 or 403 — plus 404 +where hiding a row *is* the refusal. Any other status, a 500 included, fails the +run rather than being read as "denied", because a check that passes because the +server is broken is worse than no check at all. Checks that cannot run are +reported as SKIPPED, not folded into the pass count. (The cross-tenant probe is +opt-in; see Known issues.) ## Scripts @@ -71,7 +78,10 @@ catch. (The cross-tenant probe is opt-in; see Known issues.) | `npm run cms:verify` | assert the public client is safe | `cms:bootstrap` and `cms:seed` are both idempotent, so re-running after a partial -failure is safe. +failure is safe. Bootstrap reuses the publishable key it already created (or +rotates it if the local token is gone) rather than minting a second one, and the +seed looks each sample up by its own slug, so it stays correct however large the +collection grows. ## Known issues in v1.0.0-rc.1 @@ -81,8 +91,11 @@ Two CMS bugs shape this starter. Both are upstream, not in the template: `/setup/complete` demand a token that the server never prints — the mint-and-print helper exists and is unit-tested, but nothing calls it at startup. The compose file therefore leaves the flag off, and the stack binds - to localhost instead. Run `cms:bootstrap` promptly: until you do, anyone who - can reach port 1989 can claim the admin account. + to loopback instead — every published port in `docker-compose.yml` names + `127.0.0.1`, which is what makes leaving the gate off defensible. Deleting + that prefix exposes an admin account protected only by a fixed dev + `JWT_SECRET`; replace the secrets first if you need access from another + device. - **A forged site header crashes the CMS** ([#469]). Sending `X-Lumi-Site` for a site that does not exist correctly returns 401, but the denial is then written to From 837319e0b7f15b6d25442b1eb6255547ec80b024 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 03:06:24 +0700 Subject: [PATCH 14/26] fix(create-lumibase): make bootstrap produce a usable, owned, editable setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all in this one script. **The collection had no fields.** POST /api/v1/collections validates with a schema that has no `fields` property, so Zod stripped the array: the request returned 201 and created a collection with none. Items still saved, because item validation accepts undeclared JSON keys, so nothing looked wrong — until Studio rendered "No editable fields" and the edit → publish → read loop this starter exists to demonstrate turned out never to have worked. My earlier evidence published through the API, which is the half that was fine. Fields now go through PUT /collections/:name/fields/:field, an upsert, reconciled on every run — including when the collection already exists, since a collection left fieldless by an earlier run would otherwise stay unusable — and verified afterwards so a field that fails to register is an error rather than a surprise in the UI. Fixing it surfaced a quieter bug in the fix itself: GET /collections/:name returns the collection row with no `fields` key, so reading existing fields there yielded an empty set, making the post-check vacuous. It reads GET /collections/:name/fields now. **A token in .env was trusted without being checked.** Any non-empty value counted as the key's token, so a revoked or externally rotated one was written back unchanged: bootstrap exited 0 while the website kept getting 401, and rerunning could not recover. The token is now spent against the API the website uses, with the Origin the browser sends, before the reuse path is taken. 401/403 rotates; anything else bubbles, because a broken CMS must not read as "the token is fine". The chosen token is re-checked after the role is attached, and bootstrap refuses to save one it could not use. **A display name did not establish ownership.** Every generated project searched for the same "Website (publishable)", so a second site bootstrapped against the same CMS would select the first site's key and rotate it — breaking a live website while still not working itself, since rotation preserves the original origin allowlist. Ownership now lives in the key's metadata as `starterOwner: lumibase-starter:`, with the origin as the natural key. Verified in a browser on a live instance: Studio shows "Edit item" with title, slug and body; editing the title and saving reports "Saved"; the publishable key then reads the edited title and still cannot see the draft. Refs #332 --- .../templates/nextjs/scripts/bootstrap.mjs | 168 ++++++++++++++---- 1 file changed, 135 insertions(+), 33 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs index bee3bf78..212e00de 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs @@ -84,30 +84,78 @@ async function runSetup() { console.log(' done'); } +/** The editable shape Studio renders a form for. */ +const FIELDS = [ + { name: 'title', type: 'string', interface: 'input', required: true }, + { name: 'slug', type: 'string', interface: 'input', required: true }, + { name: 'body', type: 'text', interface: 'textarea' }, +]; + async function ensureCollection(token) { step(3, `Creating the "${COLLECTION}" collection…`); try { + // NOTE: `fields` is deliberately NOT sent here. + // + // `POST /api/v1/collections` validates with a schema that has no `fields` + // property, so Zod strips it silently — the request succeeds, returns 201, + // and creates a collection with no fields at all. Items still save, because + // item validation accepts undeclared JSON keys, so nothing looks wrong until + // Studio renders "No editable fields" and the edit flow this starter exists + // to demonstrate is dead. + // + // Fields go through the field endpoint below instead. await api('/api/v1/collections', { method: 'POST', token, - body: { - name: COLLECTION, - displayTemplate: '{{title}}', - fields: [ - { name: 'title', type: 'string', interface: 'input', required: true }, - { name: 'slug', type: 'string', interface: 'input', required: true }, - { name: 'body', type: 'text', interface: 'textarea' }, - ], - }, + body: { name: COLLECTION, displayTemplate: '{{title}}' }, }); console.log(' done'); } catch (err) { // A second run finds it already there. Anything else is a real failure. if (err instanceof CmsError && (err.status === 409 || err.status === 422)) { console.log(' already exists — skipping'); - return; + } else { + throw err; } - throw err; + } + + // Always reconcile the fields, even when the collection already existed: a + // collection created by an earlier run of this script (or by a run that + // failed here) would otherwise stay unusable in Studio forever. + step('3b', 'Ensuring the editable fields…'); + // Fields come from their own endpoint: `GET /collections/:name` returns the + // collection row only, with no `fields` key, so reading them from there + // silently yields an empty set — which would make this block re-PUT every + // field on every run and, worse, make the check at the end vacuous. + const before = await api(`/api/v1/collections/${COLLECTION}/fields`, { token }); + const existing = new Set((before?.data ?? []).map((f) => f?.name)); + + for (const field of FIELDS) { + if (existing.has(field.name)) { + console.log(` = ${field.name} (already there)`); + continue; + } + // PUT is an upsert keyed by field name, so this is safe to repeat. + const { name, ...rest } = field; + await api(`/api/v1/collections/${COLLECTION}/fields/${name}`, { + method: 'PUT', + token, + body: rest, + }); + console.log(` + ${name}`); + } + + // Prove it rather than assume it: a field that silently failed to register + // leaves Studio with nothing to edit, which is exactly the failure this + // block exists to prevent. + const after = await api(`/api/v1/collections/${COLLECTION}/fields`, { token }); + const got = new Set((after?.data ?? []).map((f) => f?.name)); + const missing = FIELDS.map((f) => f.name).filter((n) => !got.has(n)); + if (missing.length > 0) { + throw new Error( + `The collection still has no ${missing.join(', ')} field(s). ` + + 'Studio would show "No editable fields" and the edit flow would not work.', + ); } } @@ -141,48 +189,91 @@ async function enablePublicRead(token) { return roleId; } -/** The name the starter's key is registered under, used to find it again. */ -const KEY_NAME = 'Website (publishable)'; +/** + * This project's identity, and why a display name is not enough. + * + * The key used to be found by the literal name "Website (publishable)". Every + * project generated from this template writes that same name, so a second site + * bootstrapped against the same CMS would find the FIRST site's key, decide it + * owned it, and rotate it — silently breaking a running website, and still not + * working itself, since rotation keeps the original origin allowlist. + * + * Ownership is therefore carried in the key's metadata under a per-project id, + * with the origin as the natural key: one website serves one origin, and a key + * is only reusable by the project whose origin it is locked to. The name stays + * for humans reading the Studio list. + */ +const KEY_NAME = `Website (${PUBLIC_ORIGIN})`; +const OWNER_TAG = `lumibase-starter:${PUBLIC_ORIGIN}`; + +/** Does this key belong to THIS project? Never match on the display name. */ +function isOwnedByThisProject(key) { + const owner = key?.metadata?.starterOwner; + return owner === OWNER_TAG; +} /** - * Ensure exactly ONE publishable key exists, and return a usable token. + * Is this token actually usable right now? * - * Creating a key unconditionally looks harmless because the script is "run - * once", but bootstrap is explicitly re-runnable: a retry after a partial - * failure, or simply running it twice, would leave extra live keys carrying - * read access, with nothing to revoke them. So this reuses what is already - * there: + * A token sitting in `.env` proves nothing: it may have been revoked, rotated + * from Studio, or left over from a run that created a replacement and then + * failed before saving it. Reusing it unchecked let bootstrap report success + * while the website kept receiving 401, with a rerun unable to recover. * - * - a key with this name and a token still in `.env` → reuse it as-is - * - a key with this name but no usable token locally → rotate it (same key, - * fresh token) rather than minting a second one - * - no key → create one + * So the token is spent against the API the website will use, with the Origin + * the browser will send. A 2xx means usable; 401/403 means it is not, and the + * caller rotates. Any other failure is left to bubble: a broken CMS must not be + * read as "the token is fine". + */ +async function tokenWorks(candidate) { + if (!candidate) return false; + try { + await api(`/api/v1/items/${COLLECTION}?limit=1`, { + token: candidate, + headers: { origin: PUBLIC_ORIGIN }, + }); + return true; + } catch (err) { + if (err instanceof CmsError && (err.status === 401 || err.status === 403)) return false; + throw err; + } +} + +/** + * Ensure exactly ONE publishable key for this project, and return a token that + * has been proven to work. + * + * - our key + a token that still authenticates → reuse it + * - our key + a dead or missing token → rotate it (same key, new token) + * - no key of ours → create one * * Rotation is the honest move for the middle case: the plaintext is returned * only at creation, so a lost token cannot be recovered — but the key's - * identity, roles and origin allowlist survive, and the old token stops - * working, which is what you want from a credential you have lost track of. + * identity, roles and origin allowlist survive, and the old token stops working, + * which is what you want from a credential you have lost track of. */ async function ensurePublishableKey(token, roleId) { step(5, 'Ensuring a publishable (browser-safe) API key…'); const existing = await api('/api/v1/api-keys', { token }); const mine = (existing?.data ?? []).find( - (k) => k?.name === KEY_NAME && k?.publishable && !k?.revokedAt, + (k) => k?.publishable && !k?.revokedAt && isOwnedByThisProject(k), ); - const envToken = process.env.NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY; - let keyId; let plaintext; - if (mine && envToken) { - console.log(' reusing the existing key'); + if (mine && (await tokenWorks(process.env.NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY))) { + console.log(' reusing the existing key (token verified)'); keyId = mine.id; - plaintext = envToken; + plaintext = process.env.NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY; } else if (mine) { - console.log(' key exists but no local token — rotating it'); - const rotated = await api(`/api/v1/api-keys/${mine.id}/rotate`, { method: 'POST', token, body: {} }); + console.log(' our key exists but its token no longer works — rotating it'); + const rotated = await api(`/api/v1/api-keys/${mine.id}/rotate`, { + method: 'POST', + token, + body: {}, + }); keyId = mine.id; plaintext = rotated?.data?.token; if (!plaintext) throw new Error('Rotation returned no token.'); @@ -197,6 +288,8 @@ async function ensurePublishableKey(token, roleId) { // An EMPTY allowlist means the key works from anywhere, so it is always // set explicitly here. allowedOrigins: [PUBLIC_ORIGIN], + // What makes this key findable by THIS project and no other. + metadata: { starterOwner: OWNER_TAG }, }, }); keyId = created?.data?.id; @@ -232,6 +325,15 @@ async function ensurePublishableKey(token, roleId) { }); } + // The role may have just been attached, so a token minted moments ago can be + // permission-less until now. Verify what we are about to hand the website. + if (!(await tokenWorks(plaintext))) { + throw new Error( + 'The publishable key cannot read the collection even after its role was ' + + 'attached. Not writing it to .env — the website would only get 401s.', + ); + } + return plaintext; } From a07b4e3e4c2404c7bad358ff3b479e3bb78f1fc6 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 03:07:00 +0700 Subject: [PATCH 15/26] fix(create-lumibase): require the response envelope before believing a result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `asked?.data ?? []` read any successful response as a list, so an HTTP 200 carrying an HTML error page — a proxy error, a gateway timeout — became "no drafts are visible" and the verifier exited 0. The 500 fix from the last round did not cover this: the status was fine, the body was not. A successful response must now be a `{ data: [...] }` envelope before an empty result counts as proof of anything, and the same reading is applied to the published list and the admin lookup. A malformed list response stops the run outright, since every later check would be interpreting noise. Refs #332 --- .../templates/nextjs/scripts/verify.mjs | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs index 06cab6a3..3fbdbb8b 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs @@ -52,6 +52,35 @@ const DENIED = new Set([401, 403]); */ const DENIED_OR_HIDDEN = new Set([401, 403, 404]); +/** + * Read the `data` array out of a successful response, or explain why it is not + * one. + * + * An HTTP 200 is not proof of anything on its own: a proxy error page, a + * gateway timeout rendered as HTML, or an envelope that changed shape all + * arrive as "success". Treating those as an empty list is how a verifier + * reports "no drafts are visible" about a response that never contained items + * at all — a false pass of exactly the kind this script exists to prevent. + * + * So the documented envelope is required: an object with a `data` array. + */ +function readList(body) { + if (typeof body === 'string') { + const head = body.trim().slice(0, 40).replace(/\s+/g, ' '); + return { ok: false, why: `expected JSON, got a non-JSON body ("${head}…")` }; + } + if (!body || typeof body !== 'object') { + return { ok: false, why: `expected a JSON object, got ${body === null ? 'null' : typeof body}` }; + } + if (!Array.isArray(body.data)) { + return { + ok: false, + why: `expected a { data: [...] } envelope, got keys: ${Object.keys(body).join(', ') || 'none'}`, + }; + } + return { ok: true, items: body.data }; +} + let failures = 0; let skipped = 0; @@ -108,8 +137,13 @@ async function main() { api(path, { ...init, token: key, headers: { origin: PUBLIC_ORIGIN, ...init.headers } }); // 1 — can read published content - const list = await asPublic(`/api/v1/items/${COLLECTION}?limit=100`); - const items = list?.data ?? []; + const list = readList(await asPublic(`/api/v1/items/${COLLECTION}?limit=100`)); + if (!list.ok) { + check('publishable key reads published posts', false, list.why); + console.error('\n✖ The list response is malformed; later checks would be meaningless.\n'); + process.exit(1); + } + const items = list.items; check('publishable key reads published posts', items.length > 0, `${items.length} item(s)`); // 2 — cannot see drafts @@ -133,8 +167,11 @@ async function main() { if (!adminToken) { skip('the draft is unreachable by direct id', 'LUMIBASE_ADMIN_TOKEN not set'); } else { - const all = await api(`/api/v1/items/${COLLECTION}?limit=200`, { token: adminToken }); - const draft = (all?.data ?? []).find((i) => i?.status && i.status !== 'published'); + const all = readList(await api(`/api/v1/items/${COLLECTION}?limit=200`, { token: adminToken })); + if (!all.ok) { + check('the draft is unreachable by direct id', false, `admin list: ${all.why}`); + } else { + const draft = all.items.find((i) => i?.status && i.status !== 'published'); if (!draft) { skip( @@ -148,6 +185,7 @@ async function main() { DENIED_OR_HIDDEN, ); } + } } // 2c — asking for drafts explicitly must not produce any. @@ -155,9 +193,17 @@ async function main() { // Two acceptable outcomes, and they are checked separately: the server either // refuses the query (401/403) or answers with an empty list. A 500 is neither. try { - const asked = await asPublic(`/api/v1/items/${COLLECTION}?status=draft&limit=50`); - const got = asked?.data ?? []; - check('asking for status=draft returns nothing', got.length === 0, `${got.length} item(s)`); + const asked = readList(await asPublic(`/api/v1/items/${COLLECTION}?status=draft&limit=50`)); + if (!asked.ok) { + // An unreadable 200 tells us nothing about whether drafts are exposed. + check('asking for status=draft returns nothing', false, asked.why); + } else { + check( + 'asking for status=draft returns nothing', + asked.items.length === 0, + `${asked.items.length} item(s)`, + ); + } } catch (err) { if (err instanceof CmsError && DENIED.has(err.status)) { check('asking for status=draft returns nothing', true, `denied with ${err.status}`); From 2d86588e9539e401da8d42d2202b3a25dd33ee4a Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 03:07:00 +0700 Subject: [PATCH 16/26] test(create-lumibase): run the starter's scripts, not just read them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer's point that source-string assertions cannot catch a false security pass was exactly right: the malformed-200 bug lived in how the script interpreted a response, which no amount of grepping the file would reveal. So these tests spawn the real scripts against a stub CMS and assert on exit codes and output — a 200 HTML error page, a dropped envelope, a 500 where a denial was expected, and a healthy server as the control, without which the failure tests could pass simply because the script always fails. One covers bootstrap rotating rather than reusing a token the server rejects. Confirmed they catch the regression rather than merely passing: run against the pre-fix verify.mjs, both malformed-response tests fail and the captured output reads "✔ All checks passed" — the false pass as reported. Source assertions are kept for the properties that are genuinely structural (field endpoint, owner tag, loopback ports) and the spec records round 3. Refs #332 --- .kiro/specs/nextjs-starter-contract/design.md | 58 ++++- .../src/nextjs-scripts.behaviour.test.ts | 216 ++++++++++++++++++ .../src/nextjs-template.test.ts | 43 ++++ 3 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index 1cae43b7..37fab3af 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -381,7 +381,63 @@ Reviewed at `e3e97d00`; every finding was reproduced before fixing. All five are pinned by tests in `nextjs-template.test.ts` (30 → 40). -### 9.3 Divergences from the original contract +### 9.3 Review round 3 (`c43ac32b`) — four findings, all valid + +1. **[P1] The collection had no fields, so Studio could not edit anything.** + `POST /api/v1/collections` validates with `collectionInputSchema`, which has + no `fields` property (`apps/cms/src/routes/collections.ts:15,165`) — Zod + stripped the array, the request returned 201, and the collection was created + empty. Seeded items still saved because item validation accepts undeclared + JSON keys, so nothing looked wrong until Studio rendered "No editable + fields". The edit→publish→read loop this starter exists to demonstrate was + never actually exercised; my earlier evidence had published through the API, + not through the UI. Confirmed on a live instance: `GET + /collections/posts/fields` returned 0. + + Fields are now provisioned through `PUT /collections/:name/fields/:field` + (an upsert), reconciled on every run — including when the collection already + exists — and verified afterwards, failing loudly if any field is missing. + + Fixing this surfaced a second, quieter bug in my own fix: I read existing + fields from `GET /collections/:name`, which returns the collection row with + **no `fields` key**, so the check was vacuous and re-PUT every field each + run. It now reads `GET /collections/:name/fields`. + + Proven end to end in a browser: Studio shows "Edit item" with `title`, + `slug` and `body`; editing the title and saving reports "Saved"; and the + publishable key then reads the edited title while still not seeing the draft. + +2. **[P2] A token in `.env` was trusted without being checked.** Any non-empty + value counted as the key's token, so a revoked or externally rotated token + was written back unchanged: bootstrap exited 0 while the website kept getting + 401, and rerunning could not recover. The token is now spent against the API + the website uses, with the Origin the browser sends, before the reuse path is + taken; 401/403 triggers rotation. Other failures bubble — a broken CMS must + not read as "the token is fine". The freshly chosen token is re-checked after + the role is attached, and bootstrap refuses to write a token it could not use. + +3. **[P2] A display name did not establish ownership of a key.** Every + generated project searched for the same `Website (publishable)` name, so a + second site bootstrapped against the same CMS would select the first site's + key and rotate it — breaking a live website while still not working itself, + since rotation preserves the original origin allowlist. Ownership is now + carried in the key's metadata as `starterOwner: lumibase-starter:`, + with the origin as the natural key. + +4. **[P2] The verifier accepted a malformed 200 as an empty draft list.** + `asked?.data ?? []` treated an HTML error page or a changed envelope as "no + drafts visible". Responses are now required to be a `{ data: [...] }` + envelope before an empty result is read as proof. + + Per the reviewer's note that source-string assertions cannot catch this, the + suite gained **behavioural tests** (`nextjs-scripts.behaviour.test.ts`) that + run the real scripts against a stub CMS. Confirmed they catch the regression: + against the pre-fix `verify.mjs` the two malformed-response tests fail and the + output reads `✔ All checks passed` — the exact false pass reported. + +Tests: 40 → 50. + +### 9.4 Divergences from the original contract - **Redis added to the compose file.** Without it the Docker runtime falls back to `127.0.0.1:6379` and floods the log with **506 ECONNREFUSED lines**, burying diff --git a/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts new file mode 100644 index 00000000..613ae603 --- /dev/null +++ b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts @@ -0,0 +1,216 @@ +/** + * Behavioural tests for the starter's scripts — they RUN, against a fake CMS. + * + * The sibling suite asserts over the template's source text, which is enough to + * stop a line being deleted but blind to what the script actually does with a + * response. Review round 3 made that concrete: `cms:verify` accepted an HTTP 200 + * carrying an HTML error page as "the draft list is empty" and exited 0 — a + * false security pass that no source assertion could have caught. + * + * So these spawn the real scripts with a stub server standing in for the CMS, + * and assert on exit codes and output. Each test starts its own server on port + * 0 so they can run in parallel and never collide. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const run = promisify(execFile); + +const scriptsDir = resolve( + dirname(fileURLToPath(import.meta.url)), + '../templates/nextjs/scripts', +); + +type Handler = (req: { method: string; url: string }) => { + status: number; + body: string; + type?: string; +}; + +const servers: Server[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((s) => new Promise((done) => s.close(() => done()))), + ); +}); + +/** Start a stub CMS and return its base URL. */ +async function stubCms(handler: Handler): Promise { + const server = createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200); + res.end('ok'); + return; + } + const out = handler({ method: req.method ?? 'GET', url: req.url ?? '' }); + res.writeHead(out.status, { 'content-type': out.type ?? 'application/json' }); + res.end(out.body); + }); + servers.push(server); + + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + return `http://127.0.0.1:${address.port}`; +} + +/** Run a starter script and capture its outcome. */ +async function runScript( + script: string, + env: Record, +): Promise<{ code: number; out: string }> { + try { + const { stdout, stderr } = await run('node', [join(scriptsDir, script)], { + env: { ...process.env, ...env }, + }); + return { code: 0, out: stdout + stderr }; + } catch (err) { + const e = err as { code?: number; stdout?: string; stderr?: string }; + return { code: e.code ?? 1, out: (e.stdout ?? '') + (e.stderr ?? '') }; + } +} + +const PUBLISHED_ITEM = { id: 'pub1', status: 'published', data: { slug: 'a', title: 'A' } }; + +describe('cms:verify — a malformed 200 is not a passing check', () => { + it('fails when the draft query answers 200 with an HTML error page', async () => { + // The exact shape review round 3 reproduced: everything else behaves, but a + // proxy returns an HTML error for one query. `body?.data ?? []` read that as + // "no drafts visible" and the script exited 0. + const url = await stubCms(({ method, url }) => { + if (url.includes('status=draft')) { + return { status: 200, type: 'text/html', body: '502 Bad Gateway' }; + } + if (method === 'POST') return { status: 403, body: JSON.stringify({ errors: [{ code: 'FORBIDDEN' }] }) }; + if (/\/items\/posts\/[^?]+$/.test(url)) return { status: 404, body: JSON.stringify({ errors: [{ code: 'NOT_FOUND' }] }) }; + return { status: 200, body: JSON.stringify({ data: [PUBLISHED_ITEM] }) }; + }); + + const result = await runScript('verify.mjs', { + NEXT_PUBLIC_LUMIBASE_URL: url, + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: 'lbk_pub_test', + LUMIBASE_ADMIN_TOKEN: '', + }); + + expect(result.code, `verify.mjs exited 0 on a malformed response:\n${result.out}`).not.toBe(0); + expect(result.out).toMatch(/asking for status=draft/); + expect(result.out).toMatch(/non-JSON body|data: \[/); + }); + + it('fails when a successful response drops the data envelope', async () => { + const url = await stubCms(({ method, url }) => { + if (url.includes('status=draft')) return { status: 200, body: JSON.stringify({ items: [] }) }; + if (method === 'POST') return { status: 403, body: JSON.stringify({ errors: [{ code: 'FORBIDDEN' }] }) }; + return { status: 200, body: JSON.stringify({ data: [PUBLISHED_ITEM] }) }; + }); + + const result = await runScript('verify.mjs', { + NEXT_PUBLIC_LUMIBASE_URL: url, + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: 'lbk_pub_test', + LUMIBASE_ADMIN_TOKEN: '', + }); + + expect(result.code).not.toBe(0); + expect(result.out).toMatch(/envelope/); + }); + + it('fails when a denial check gets a 500 instead of a refusal', async () => { + const url = await stubCms(({ method, url }) => { + if (method === 'POST') return { status: 500, body: JSON.stringify({ errors: [{ code: 'INTERNAL' }] }) }; + if (url.includes('status=draft')) return { status: 200, body: JSON.stringify({ data: [] }) }; + return { status: 200, body: JSON.stringify({ data: [PUBLISHED_ITEM] }) }; + }); + + const result = await runScript('verify.mjs', { + NEXT_PUBLIC_LUMIBASE_URL: url, + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: 'lbk_pub_test', + LUMIBASE_ADMIN_TOKEN: '', + }); + + expect(result.code).not.toBe(0); + expect(result.out).toMatch(/got 500/); + }); + + it('passes when every guard answers the way a healthy CMS does', async () => { + // The control: without this, the tests above could pass simply because the + // script always fails. + const url = await stubCms(({ method, url }) => { + if (method === 'POST') return { status: 403, body: JSON.stringify({ errors: [{ code: 'FORBIDDEN' }] }) }; + if (url.includes('status=draft')) return { status: 200, body: JSON.stringify({ data: [] }) }; + return { status: 200, body: JSON.stringify({ data: [PUBLISHED_ITEM] }) }; + }); + + const result = await runScript('verify.mjs', { + NEXT_PUBLIC_LUMIBASE_URL: url, + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: 'lbk_pub_test', + LUMIBASE_ADMIN_TOKEN: '', + }); + + expect(result.code, result.out).toBe(0); + expect(result.out).toMatch(/All checks passed/); + }); +}); + +describe('cms:bootstrap — a token in .env is not proof it still works', () => { + it('rotates instead of reusing when the stored token is rejected', async () => { + // Review round 3: a revoked token was written back unchanged and bootstrap + // exited 0, leaving the website on 401 with no way to recover by rerunning. + let rotated = false; + + const url = await stubCms(({ method, url }) => { + const json = (data: unknown) => ({ status: 200, body: JSON.stringify({ data }) }); + + if (url.startsWith('/api/v1/setup/state')) return { status: 200, body: JSON.stringify({ state: 'initialized' }) }; + if (url.startsWith('/api/v1/auth/login')) return json({ token: 'admin-token' }); + // Fields live on their own endpoint; bootstrap reads them from there. + if (url.startsWith('/api/v1/collections/posts/fields')) { + return json([{ name: 'title' }, { name: 'slug' }, { name: 'body' }]); + } + if (url.startsWith('/api/v1/collections/posts')) return json({ name: 'posts' }); + if (url === '/api/v1/collections') return { status: 409, body: JSON.stringify({ errors: [{ code: 'EXISTS' }] }) }; + if (url.includes('/access/grants/public/enable')) return json({ roleId: 'role-public' }); + if (url.includes('/access/grants/public')) return json({}); + + if (url.includes('/rotate')) { + rotated = true; + return json({ token: 'lbk_pub_fresh' }); + } + if (url === '/api/v1/api-keys' && method === 'GET') { + return json([ + { + id: 'key1', + name: 'Website (http://localhost:3000)', + publishable: true, + revokedAt: null, + metadata: { starterOwner: 'lumibase-starter:http://localhost:3000' }, + }, + ]); + } + if (/\/api-keys\/key1$/.test(url)) return json({ roles: [{ roleId: 'role-public' }] }); + + // The probe that decides reuse-vs-rotate: the stale token is refused, + // the fresh one works. + if (url.startsWith('/api/v1/items/posts')) { + return { status: 200, body: JSON.stringify({ data: [] }) }; + } + return json({}); + }); + + const result = await runScript('bootstrap.mjs', { + NEXT_PUBLIC_LUMIBASE_URL: url, + LUMIBASE_ADMIN_EMAIL: 'admin@example.com', + LUMIBASE_ADMIN_PASSWORD: 'Change-Me-N0w!', + // Deliberately absent: this is the "token was lost" case, which must + // rotate rather than reuse nothing. + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: '', + }); + + expect(rotated, `bootstrap did not rotate:\n${result.out}`).toBe(true); + }); +}); diff --git a/packages/create-lumibase/src/nextjs-template.test.ts b/packages/create-lumibase/src/nextjs-template.test.ts index 0ed2ce80..1f2bd125 100644 --- a/packages/create-lumibase/src/nextjs-template.test.ts +++ b/packages/create-lumibase/src/nextjs-template.test.ts @@ -187,6 +187,49 @@ describe('nextjs template — bootstrap and seed are re-runnable', () => { }); }); +describe('nextjs template — the collection is actually editable', () => { + it('creates fields through the field endpoint, not the collection body', () => { + // POST /collections validates with a schema that has no `fields` key, so + // Zod strips it: the request returns 201 and creates a collection with no + // fields at all. Items still save (item validation accepts undeclared JSON), + // so nothing looks wrong until Studio shows "No editable fields" and the + // edit flow this starter exists to demonstrate is dead. + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/collections\/\$\{COLLECTION\}\/fields\/\$\{name\}/); + expect(bootstrap).toMatch(/method: 'PUT'/); + }); + + it('reads existing fields from the fields endpoint', () => { + // `GET /collections/:name` returns the collection row with no `fields` + // key, so reading them from there yields an empty set — which would make + // the post-check vacuous and re-PUT every field on every run. + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/collections\/\$\{COLLECTION\}\/fields`/); + }); + + it('fails loudly when the fields did not register', () => { + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/No editable fields/); + }); +}); + +describe('nextjs template — a key belongs to one project', () => { + it('identifies its key by an owner tag, not a shared display name', () => { + // Every generated project used the same name, so a second site would find + // the first site's key and rotate it — breaking a live website while still + // not working itself, since rotation keeps the original origin allowlist. + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/starterOwner/); + expect(bootstrap).toMatch(/isOwnedByThisProject/); + }); + + it('spends the stored token before trusting it', () => { + // A token in .env proves nothing: it may be revoked or rotated elsewhere. + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/async function tokenWorks/); + }); +}); + describe('nextjs template — onboarding matches the real flow', () => { it('does not ask for a setup token the stack never issues', () => { // The compose file deliberately leaves the gate off (#470), so telling a From efb92fe1f18ba844eac4c655fcd25025c0217afa Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 03:22:27 +0700 Subject: [PATCH 17/26] fix(create-lumibase): confirm COLLECTION_EXISTS instead of any 409 Treating every 409/422 as "the collection is already there" hides the cases that are not: a validation failure, or a genuine conflict. Bootstrap would carry on as though the collection were fine and the user would meet the consequences later, somewhere less obvious. The CMS raises COLLECTION_EXISTS with 409 (schema-service.ts:436), verified against a live instance. That code is now what the check matches; anything else propagates. Refs #332 --- .../templates/nextjs/scripts/bootstrap.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs index 212e00de..e4fe8b6f 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs @@ -111,8 +111,12 @@ async function ensureCollection(token) { }); console.log(' done'); } catch (err) { - // A second run finds it already there. Anything else is a real failure. - if (err instanceof CmsError && (err.status === 409 || err.status === 422)) { + // A second run finds it already there — but only COLLECTION_EXISTS means + // that. Treating every 409/422 as "already there" would hide a validation + // failure or a genuine conflict and carry on as if the collection were + // fine, which is how a broken setup reaches the user looking successful. + const code = err instanceof CmsError ? err.body?.errors?.[0]?.code : undefined; + if (code === 'COLLECTION_EXISTS') { console.log(' already exists — skipping'); } else { throw err; From 2888159317270493a0ad304f927277327cb171e0 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 03:22:27 +0700 Subject: [PATCH 18/26] feat(create-lumibase): test tenant isolation against a site that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-tenant probe used a made-up site id, which answers the wrong question: it tests what happens with an invalid header, not whether a key bound to site A can read site B. And because a non-existent id crashes the published CMS (#469), the whole check was skipped by default — so the isolation property had no evidence behind it at all. Creating a real second site and presenting the key against it returns 401 and leaves the server healthy across repeats. That splits the two probes cleanly: the real-site one is the isolation test and runs whenever LUMIBASE_VERIFY_OTHER_SITE names a second site; the non-existent-id one stays behind LUMIBASE_VERIFY_CROSS_TENANT=1 until #469 is fixed. It also narrows #469 usefully: the crash is triggered by ids with no row in `sites`, not by cross-tenant access as such. Refs #332, #469 --- .../templates/nextjs/scripts/verify.mjs | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs index 3fbdbb8b..4418376c 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs @@ -9,7 +9,8 @@ * 1. The key can read published posts. * 2. The key CANNOT see the draft — by list, by direct id, or by asking. * 3. The key cannot write. - * 4. The key cannot read another tenant's content. + * 4. The key cannot read another tenant's content (set + * LUMIBASE_VERIFY_OTHER_SITE to a second existing site id). * * (2) is the one worth keeping. `GET /api/v1/items` has no implicit * published-only filter, so a read grant made without `publishedOnly` would @@ -228,27 +229,46 @@ async function main() { // 4 — cannot cross tenants. // - // Skipped by default, and that is deliberate. Presenting the key with a - // foreign X-Lumi-Site does correctly return 401 — but on the published image - // it also CRASHES the CMS: the denial is written to the audit log under the - // client-supplied site id, which no row in `sites` matches, so the insert - // violates a foreign key and takes the process down. One request from an - // unauthenticated caller is enough. + // Two different probes, because they exercise different things and only one + // of them is dangerous: // - // Running this check would therefore knock over your own container. Opt in - // with LUMIBASE_VERIFY_CROSS_TENANT=1 once that is fixed upstream (#469). + // (a) an EXISTING other site — the real isolation question: does a key + // bound to site A read site B? Safe to run, and on by default. Point + // LUMIBASE_VERIFY_OTHER_SITE at a second site id to enable it. + // + // (b) a NON-EXISTENT site id — this crashes the published CMS (#469): the + // denial is audited under a site id no row matches, the insert violates + // a foreign key, and the rethrow lands in a fire-and-forget flush. One + // request is enough, so it stays opt-in. + // + // Verified against a live instance: with a real second site the request is + // refused with 401 and the server stays up (health 200 across repeats); with + // a made-up id the process dies. That difference is why these are separate. + const otherSite = process.env.LUMIBASE_VERIFY_OTHER_SITE; + if (otherSite) { + await expectDenied( + `publishable key cannot read another site (${otherSite})`, + () => + api(`/api/v1/items/${COLLECTION}?limit=1`, { + token: key, + headers: { origin: PUBLIC_ORIGIN, 'x-lumi-site': otherSite }, + }), + DENIED_OR_HIDDEN, + ); + } else { + skip( + 'publishable key cannot read another site', + 'set LUMIBASE_VERIFY_OTHER_SITE to a second existing site id', + ); + } + if (process.env.LUMIBASE_VERIFY_CROSS_TENANT === '1') { - await expectDenied('publishable key cannot read another site', () => + await expectDenied('a non-existent site id is refused', () => api(`/api/v1/items/${COLLECTION}?limit=1`, { token: key, headers: { origin: PUBLIC_ORIGIN, 'x-lumi-site': 'some-other-site' }, }), ); - } else { - skip( - 'publishable key cannot read another site', - 'it crashes the published CMS (#469) — set LUMIBASE_VERIFY_CROSS_TENANT=1 to run it', - ); } if (failures > 0) { From e802e06c03a3f04757582087cd71c314b69a27da Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 03:22:38 +0700 Subject: [PATCH 19/26] docs(create-lumibase): document connecting to a CMS you already run The contract has always had two backend paths, but only the Docker one was written down. A reader who already runs LumiBase saw a quickstart that starts by launching a second CMS, and had to infer the rest. The README now states the three .env values that path needs and the four things the CMS administrator must provide. The fourth is the one that bites: a collection with no declared fields still accepts and returns item JSON, so the website looks fine while Studio shows "No editable fields" and editors cannot work. Anyone hitting that will search for the symptom, so the symptom is in the text. The setup screen carries a short version, since that is where someone lands before reading anything. Refs #332 --- .../templates/nextjs/README.md.hbs | 65 +++++++++++++++++-- .../templates/nextjs/app/globals.css | 8 +++ .../templates/nextjs/app/page.tsx | 9 +++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/README.md.hbs b/packages/create-lumibase/templates/nextjs/README.md.hbs index c692c320..ac91adff 100644 --- a/packages/create-lumibase/templates/nextjs/README.md.hbs +++ b/packages/create-lumibase/templates/nextjs/README.md.hbs @@ -25,6 +25,46 @@ separate migrate step. `cms:bootstrap` writes the publishable key back into Studio ships **inside the CMS image**, so the same container serves both the API and the admin UI. +## Connecting to a CMS you already run + +The Docker stack above is one of two paths. If a LumiBase instance already +exists, skip `cms:up`/`cms:bootstrap` entirely — this website only ever reads, +so all it needs is three values in `.env`: + +```bash +NEXT_PUBLIC_LUMIBASE_URL=https://cms.example.com +NEXT_PUBLIC_LUMIBASE_SITE_ID=your-site-id +NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY=lbk_pub_… +``` + +Then `npm run dev`. No admin token is involved, and none should be: the values +above are the only ones the browser needs. + +Whoever administers that CMS has to provide four things, and the last one is the +part people miss: + +1. **A publishable key** — created in Studio under Access → API keys, marked + publishable (its token starts with `lbk_pub_`), with your site's origin in + its allowed-origins list. An empty allowlist works from anywhere, which is + not what you want for a key that ships to browsers. +2. **A read grant for the collection**, restricted to published rows. + `GET /api/v1/items` applies no published-only filter of its own, so a grant + without that restriction serves drafts to every visitor. +3. **A `posts` collection** — or change `COLLECTION` in `scripts/lumibase.mjs` + and the query in `lib/lumibase.ts` to match what the CMS actually calls it. +4. **Fields declared on that collection** (`title`, `slug`, `body`). A + collection with no declared fields still accepts and returns item JSON, so + the website may look fine while Studio shows "No editable fields" and nobody + can edit anything. If editors report that, this is why. + +Check the result before trusting it: + +```bash +npm run cms:verify +``` + +It needs only the publishable key, and it fails if drafts are reachable. + ## Try the round trip 1. Open Studio and sign in as `LUMIBASE_ADMIN_EMAIL`. @@ -56,7 +96,8 @@ npm run cms:verify It uses the publishable key — never the admin token — to prove it can read published posts, **cannot** see the seeded draft (by list, by direct id, and by -asking for `status=draft`), and cannot write. The seed deliberately leaves one +asking for `status=draft`), cannot write, and — with +`LUMIBASE_VERIFY_OTHER_SITE` set — cannot read a second site's content. The seed deliberately leaves one post unpublished so this check has something real to catch. A refusal only counts when the server actually refused: 401 or 403 — plus 404 @@ -97,12 +138,22 @@ Two CMS bugs shape this starter. Both are upstream, not in the template: `JWT_SECRET`; replace the secrets first if you need access from another device. -- **A forged site header crashes the CMS** ([#469]). Sending `X-Lumi-Site` for a site - that does not exist correctly returns 401, but the denial is then written to - the audit log under that same id — which no row in `sites` matches, so the - insert violates a foreign key and takes the process down. One request is - enough. `cms:verify` therefore skips its cross-tenant probe by default; opt in - with `LUMIBASE_VERIFY_CROSS_TENANT=1` once this is fixed. +- **A forged site header crashes the CMS** ([#469]). Sending `X-Lumi-Site` for a + site that **does not exist** correctly returns 401, but the denial is then + written to the audit log under that same id — which no row in `sites` matches, + so the insert violates a foreign key and takes the process down. One request + is enough. + + Only non-existent ids do this. Presenting the key against a *real* second site + is refused with 401 and leaves the server healthy, so that check — the one + that actually tests tenant isolation — runs normally: + + ```bash + LUMIBASE_VERIFY_OTHER_SITE= npm run cms:verify + ``` + + The non-existent-id probe stays behind `LUMIBASE_VERIFY_CROSS_TENANT=1` until + this is fixed, so `cms:verify` cannot knock over your own CMS. [#469]: https://github.com/khuepm/LumiBase/issues/469 [#470]: https://github.com/khuepm/LumiBase/issues/470 diff --git a/packages/create-lumibase/templates/nextjs/app/globals.css b/packages/create-lumibase/templates/nextjs/app/globals.css index 73e5d61e..baf31e40 100644 --- a/packages/create-lumibase/templates/nextjs/app/globals.css +++ b/packages/create-lumibase/templates/nextjs/app/globals.css @@ -87,6 +87,14 @@ footer p { color: var(--muted); } +.alt { + margin-top: 2rem; + padding-top: 1.25rem; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.92rem; +} + footer { margin-top: 2.5rem; padding-top: 1.25rem; diff --git a/packages/create-lumibase/templates/nextjs/app/page.tsx b/packages/create-lumibase/templates/nextjs/app/page.tsx index e6ab1c6e..c56c2387 100644 --- a/packages/create-lumibase/templates/nextjs/app/page.tsx +++ b/packages/create-lumibase/templates/nextjs/app/page.tsx @@ -24,6 +24,15 @@ function Setup() { npm run cms:seed — sample posts + +

    + Already have a LumiBase instance? Skip all of that. Put + its URL, your site id and a publishable key ( + lbk_pub_…) in .env and run{' '} + npm run dev. The CMS needs a posts collection + with title/slug/body fields and a + published-only read grant — see the README. +

    ); } From c4fff7518ec898c3671be9e511a3d9c181cccfc9 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 03:22:39 +0700 Subject: [PATCH 20/26] test(create-lumibase): cover the error-code, isolation and connect-path rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the properties added in this round: bootstrap matches COLLECTION_EXISTS rather than any 409; the isolation probe distinguishes a real second site from a made-up id; and the read-only connect path stays documented in both the README and the setup screen. Also updates the behavioural fixture to return the real COLLECTION_EXISTS code — it had returned a made-up one, which now correctly fails. Refs #332 --- .../src/nextjs-scripts.behaviour.test.ts | 6 +++- .../src/nextjs-template.test.ts | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts index 613ae603..d4b9a10c 100644 --- a/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts +++ b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts @@ -173,7 +173,11 @@ describe('cms:bootstrap — a token in .env is not proof it still works', () => return json([{ name: 'title' }, { name: 'slug' }, { name: 'body' }]); } if (url.startsWith('/api/v1/collections/posts')) return json({ name: 'posts' }); - if (url === '/api/v1/collections') return { status: 409, body: JSON.stringify({ errors: [{ code: 'EXISTS' }] }) }; + if (url === '/api/v1/collections') { + // The real code the CMS returns; bootstrap now checks for exactly this + // rather than accepting any 409. + return { status: 409, body: JSON.stringify({ errors: [{ code: 'COLLECTION_EXISTS' }] }) }; + } if (url.includes('/access/grants/public/enable')) return json({ roleId: 'role-public' }); if (url.includes('/access/grants/public')) return json({}); diff --git a/packages/create-lumibase/src/nextjs-template.test.ts b/packages/create-lumibase/src/nextjs-template.test.ts index 1f2bd125..f6c97676 100644 --- a/packages/create-lumibase/src/nextjs-template.test.ts +++ b/packages/create-lumibase/src/nextjs-template.test.ts @@ -241,6 +241,38 @@ describe('nextjs template — onboarding matches the real flow', () => { }); }); +describe('nextjs template — errors are identified, not lumped together', () => { + it('confirms COLLECTION_EXISTS rather than treating any 409/422 as existing', () => { + // Swallowing every 409/422 as "already there" would hide a validation + // failure or a real conflict and carry on as if the collection were fine. + const bootstrap = read('scripts/bootstrap.mjs'); + expect(bootstrap).toMatch(/COLLECTION_EXISTS/); + }); +}); + +describe('nextjs template — tenant isolation is testable', () => { + it('probes a real second site by default, and a fake id only on request', () => { + // These are different questions. A real second site answers the isolation + // question and is safe. A non-existent id crashes the published CMS (#469), + // so it stays opt-in — running cms:verify must not kill the user's server. + const verify = read('scripts/verify.mjs'); + expect(verify).toMatch(/LUMIBASE_VERIFY_OTHER_SITE/); + expect(verify).toMatch(/LUMIBASE_VERIFY_CROSS_TENANT === '1'/); + }); +}); + +describe('nextjs template — the read-only connect path is documented', () => { + it('tells a user with an existing CMS what to set and what it needs', () => { + // Without this, path A of the contract exists only in the spec: a reader + // with a running CMS sees a Docker quickstart and nothing else. + const readme = read('README.md.hbs'); + expect(readme).toMatch(/Connecting to a CMS you already run/); + expect(readme).toMatch(/No editable fields/); + const page = read('app/page.tsx'); + expect(page).toMatch(/Already have a LumiBase instance/); + }); +}); + describe('nextjs template — package manifest', () => { it('depends on lumibase at runtime, not as a dev dependency', () => { // #332: a scaffolded project must actually use LumiBase, not merely From 455b576f5762b79338e788c93fbee3afca144c0e Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 03:22:49 +0700 Subject: [PATCH 21/26] docs: record the starter in the changelog and both CMS bugs in the backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the [Unreleased] entry for the Next.js template, and backlog rows B64 and B65 for the two CMS bugs the starter works around — both pointing at the issues that already track them (#469, #470) rather than creating duplicates. B64 carries the detail that took longest to establish: the crash comes from the queued audit path rethrowing inside a fire-and-forget flush, not from the synchronous insert, which does catch; and it is triggered by site ids with no row in `sites`, not by cross-tenant access as such. The spec closes the six acceptance items from round 3, including the correction to §4.2 — it described stable ids plus onConflictDoNothing, which belongs to scripts that talk to the database directly, not to a seed going through the REST API — and the §5.3 pack recipe, which now runs inside the package and says what it does not prove about `lumibase init`. Refs #332, #469, #470 --- .kiro/specs/nextjs-starter-contract/design.md | 105 ++++++++++++++++-- .kiro/steering/out-of-scope-backlog.md | 2 + CHANGELOG.md | 35 ++++++ 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index 37fab3af..cd076553 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -136,10 +136,25 @@ items via `POST /api/v1/items/:collection` ### 4.2 A seed that is safe to re-run -Following the pattern the repo already uses: stable ids plus -`onConflictDoNothing`, as in -`packages/database/scripts/seed-content-os-demo.ts:109,127,166`. The seed is -site-scoped and runs **server-side** during bootstrap. +The original draft proposed stable ids plus `onConflictDoNothing`, copying +`packages/database/scripts/seed-content-os-demo.ts:109,127,166`. That pattern +belongs to scripts that talk to the database directly; this seed goes through +the REST API, where `ON CONFLICT` is not available and item ids are +server-assigned. + +**What is implemented:** each sample is looked up by its own slug before being +created — +`GET /api/v1/items/posts?filter={"slug":{"_eq":"…"}}&limit=1` — and created only +when absent. Idempotence is therefore by slug, not by id. + +Looking the slug up server-side (rather than listing the collection and +searching the page that comes back) is what keeps it correct once the collection +outgrows one page; see §9.2. The filter spans both statuses, so an existing +draft counts as already-seeded, and existing content is never overwritten: the +script only ever creates what is missing. + +The seed is site-scoped and runs **server-side**, with the admin token, never +from the browser. ### 4.3 Public client — publishable key @@ -219,12 +234,42 @@ Tenant resolution: the **`X-Lumi-Site`** header is the primary path ### 5.3 Cold-install commands (outside the monorepo, no `workspace:*`) +`npm pack` packs whatever directory it runs in, so it must run **inside the +package** — from the repo root it would pack the root manifest instead and the +test would prove nothing. The artifact path is then passed absolutely, because +the install runs in a different directory: + ```bash -pnpm -F create-lumibase build && npm pack -cd "$(mktemp -d)" && npm i -npx create-lumibase my-site --template nextjs --pm npm --no-git +# 1. build, then pack from the package directory itself +pnpm -F create-lumibase build +cd packages/create-lumibase && npm pack # → create-lumibase-.tgz +TARBALL="$PWD/create-lumibase-1.0.0-rc.1.tgz" # absolute: install runs elsewhere + +# 2. install it somewhere with no connection to this repo +cd "$(mktemp -d)" +npm init -y >/dev/null +npm i --ignore-scripts "$TARBALL" + +# 3. confirm the artifact is the one just built, not a registry copy +node -p "require('create-lumibase/package.json').version" +ls node_modules/create-lumibase/dist/templates # must list: nextjs + +# 4. scaffold from it +./node_modules/.bin/create-lumibase my-site --template nextjs --pm npm --no-git ``` +**What this does and does not prove.** It exercises the `npm create` path +against the artifact just built. It does **not** prove `lumibase init` resolves +the same artifact: `init` fetches `create-lumibase@` from the +registry (`packages/cli/src/commands/init.ts:20-45`), and the unit test covering +it asserts only that argv is forwarded — a mocked runner, not a resolution test. + +Proving the second entrypoint requires publishing, or a disposable local +registry (e.g. Verdaccio) with the registry URL pointed at it for the duration of +the test. Until one of those happens, the honest statement is the one in §2: +`npm create` works today, `lumibase init` reaches parity after the next publish. +Verified rather than assumed — see §9. + ## 6. Where SDK/API support is needed `LumiClientOptions.token` is **required**, typed `string`, and documented as a @@ -437,7 +482,51 @@ All five are pinned by tests in `nextjs-template.test.ts` (30 → 40). Tests: 40 → 50. -### 9.4 Divergences from the original contract +### 9.4 Remaining acceptance items — closed + +The six items left open by round 3: + +1. **Spec §5.3 pack command** — rewritten. `npm pack` packs the directory it + runs in, so the recipe now `cd`s into the package, captures an absolute + tarball path (the install runs elsewhere), and verifies the installed + artifact is the one just built (`dist/templates` must list `nextjs`). It also + states plainly what the recipe does *not* prove: `lumibase init` resolves + from the registry, and the unit test covering it mocks the runner, so proving + that entrypoint needs a publish or a disposable local registry. + +2. **Spec §4.2 wording** — corrected. It described stable ids plus + `onConflictDoNothing`, which belongs to scripts talking to the database + directly; this seed goes through the REST API, where `ON CONFLICT` is not + available and ids are server-assigned. It now documents what is implemented: + a per-slug lookup. + +3. **Existing-CMS connect path** — documented in the starter's README (a + "Connecting to a CMS you already run" section: the three `.env` values, and + the four things the CMS administrator must provide) and surfaced in the setup + screen. The fourth prerequisite is called out explicitly, because it is the + one that bites: a collection with no declared fields still accepts and + returns item JSON, so the website looks fine while Studio shows "No editable + fields". + +4. **Two-existing-sites isolation evidence** — obtained. A real second site + (`site_tenant_b`) was created and the publishable key presented against it: + **401, and the server stayed healthy across repeats**. That matters beyond + the check itself — it shows #469 is triggered by *non-existent* site ids, not + by cross-tenant access as such. The two probes are now separate: the real-site + one (`LUMIBASE_VERIFY_OTHER_SITE`) is the isolation test and runs normally; + the non-existent-id one stays behind `LUMIBASE_VERIFY_CROSS_TENANT=1` until + #469 is fixed. + +5. **`COLLECTION_EXISTS`** — confirmed rather than inferred. The service raises + that code with 409 (`apps/cms/src/services/schema-service.ts:436`), verified + against a live instance. Bootstrap now matches the code; any other 409/422 is + a real failure and propagates instead of being mistaken for "already there". + +6. **CHANGELOG + out-of-scope backlog** — added: an `[Unreleased] / Added` entry + for the template, and backlog rows **B64** (#469) and **B65** (#470) pointing + at the existing issues. No duplicate issues were created. + +### 9.5 Divergences from the original contract - **Redis added to the compose file.** Without it the Docker runtime falls back to `127.0.0.1:6379` and floods the log with **506 ECONNREFUSED lines**, burying diff --git a/.kiro/steering/out-of-scope-backlog.md b/.kiro/steering/out-of-scope-backlog.md index 3c53c307..7a289064 100644 --- a/.kiro/steering/out-of-scope-backlog.md +++ b/.kiro/steering/out-of-scope-backlog.md @@ -80,5 +80,7 @@ | B55 | 2026-09-07 · deployment-integrations task 12.3 (webhook signature) | bug (docs) | `docs/{en,vi}/api/hono-api-spec.md` | Task 16.1 của spec `deployment-integrations` được tick `[x]` với nội dung *"section 7b endpoints deployment"*, nhưng **section đó không tồn tại**: `grep -n "deployment"` trên `docs/en/api/hono-api-spec.md` không trúng một endpoint nào (chỉ trúng chữ "deployment" trong mô tả rate-limit/encryption), `grep "7b"` trả rỗng, và bản VI cũng vậy — mục lục nhảy từ `## 7. Flows / Automation` sang `## 8. AI Copilot`. Nghĩa là toàn bộ surface `/api/v1/deployments/*` (targets CRUD, trigger, list/detail/logs/refresh, webhook vào) **không có trong API spec** dù spec đó là artifact được `v1-release-criteria.md` §2 xếp vào "API surface freeze". Feature doc `docs/{en,vi}/features/deployment-integrations.md` §5 có bảng REST đầy đủ nên thông tin không mất, nhưng nó nằm sai chỗ so với cái checklist DoD §4 đang trỏ tới. Cùng class "checkbox tasks.md không phải bằng chứng" mà chính `v1-scope-classification.md` cảnh báo | medium | `open` | Thêm `## 7c. Deployments` vào `docs/en/api/hono-api-spec.md` + bản VI cùng PR (DoD §4a) rồi re-stamp pair; ghi rõ header chữ ký bắt buộc của endpoint webhook (`x-vercel-signature` / `x-webhook-signature`) + `401 INVALID_SIGNATURE`. Không làm trong task 12.3 vì đó là một section API spec mới cho cả feature, không phải một dòng thuộc scope verify chữ ký | | B54 | 2026-09-07 · deployment-integrations task 6.2 (rate-limit) | task (docs debt) | `docs/{en,vi}/security/anti-abuse.md` | Cặp EN/VI của `anti-abuse.md` lệch cấu trúc sẵn trên `main`: `stamp-pair.mjs` từ chối stamp vì EN có 4 in-page anchor link còn VI có 0, và link tới `../api/graphql-api-spec.md#abuse-guards` ở VI trỏ anchor đã dịch (`#chống-lạm-dụng`). Phần anchor đã dịch là **false positive** (heading khác locale thì slug phải khác — cùng loại với ca (d) của B37), nhưng 4 anchor nội trang thiếu ở VI là thiếu thật: bản VI viết "xem phần Khoảng trống & khuyến nghị bên dưới" bằng chữ thay vì link, nên người đọc VI không nhảy được tới mục đó. `docs:i18n:detect` xếp cặp này `up-to-date` vì hash còn khớp — đúng cái điểm mù B37 đã nêu. Ngoài scope task 6.2 (chỉ thêm một bullet registry vào §1) | low | `fixed` | Đóng 2026-09-10 khi tích hợp #458 vào #457: thêm đủ 4 liên kết nội trang ở bản VI; `check-parity.mjs` so đường dẫn của liên kết Markdown tương đối nhưng cho phép fragment heading đã dịch khác nhau, còn fragment URL ngoài vẫn được giữ nguyên. Hai test khóa cả hai hành vi; cặp được verify 72 code reference, stamp lại và vượt parity không cần waiver cấu trúc. | | B59 | 2026-09-09 · review PR #458 (parity gate) | bug (CI guard không fire) | `.github/workflows/docs-i18n-sync.yml` · `scripts/docs-i18n/gate-changed-parity.mjs` | **Gate parity bỏ qua đúng ca nó cần bắt.** Workflow lấy danh sách file bằng `git diff --name-only --diff-filter=d`, mà `--diff-filter=d` **loại file bị xoá**. Một PR xoá riêng `docs/vi/x.md` để bản EN mồ côi nhưng **không cung cấp file doc nào** cho gate → danh sách rỗng → gate exit 0 và PR xanh. Rename cũng cùng lỗ, qua đường path cũ đã biến mất. Bản thân script **đã có** xử lý orphan từ đầu — nó chỉ không bao giờ nhận được path đó, nên đây là class "guard có mà không fire" giống B31/B36/B53/B56: đọc code script thì thấy đúng, đọc workflow mới thấy sai | medium | `fixed` | Bỏ `--diff-filter=d` và dùng `--no-renames` để luôn nhận cả path cũ lẫn mới; gate tự phân loại theo số locale còn sống: cả hai còn → so parity, còn một → **đỏ** (orphan), không còn bên nào → **xanh** (cho doc về hưu, không cần override). Tách `relsFromChangedFiles` + `classifyPairs` thành hàm thuần và thêm `scripts/__tests__/gate-changed-parity.test.mjs` (11 ca `node --test`: add một bên, edit hai bên, xoá một bên mỗi phía, xoá cả hai, rename hai bên, rename một bên, cộng hai ca dựng **repo git thật** để chứng minh workflow giữ deletion và path cũ của rename). Đã kiểm âm: coi half-deleted là hợp lệ → 5 ca đỏ. `scripts:test` đã nằm trong `check:all` của CI | +| B64 | 2026-09-13 · A-02 review PR #468 (#332) | vuln (availability) | `apps/cms/src/middleware/tenant.ts` · `middleware/auth.ts:93` · `modules/audit/worker.ts:139-141` | **DoS không cần xác thực, một request là đủ.** `withTenant` chỉ shape-check `X-Lumi-Site`, không kiểm tra site tồn tại; khi từ chối api key, `auditApiKeyUseDenied` ghi audit dưới chính site id client gửi → vi phạm FK `lumibase_audit_log_site_id_lumibase_sites_id_fk`. `AuditLogger.write` **có** bắt lỗi cho insert đồng bộ (`logger.ts:485-490`), nhưng đường queue thì batcher bắt rồi **`throw err`** trong flush fire-and-forget (`void this.scheduleFlush()`) ⇒ unhandled rejection ⇒ chết process. Batch còn gộp nhiều site trong một insert nên một hàng hỏng **mất luôn audit của site hợp lệ** (mất dấu vết bảo mật, không chỉ downtime). Chỉ site id **không tồn tại** mới gây crash: probe với site thật trả 401 và server vẫn khỏe | high | `tracked` | Issue **#469** (có bước tái hiện + hướng sửa 3 lớp). Starter #332 né bằng cách để probe non-existent-id sau cờ `LUMIBASE_VERIFY_CROSS_TENANT=1`; probe isolation thật (`LUMIBASE_VERIFY_OTHER_SITE`) chạy mặc định. KHÔNG sửa trong #332 — `apps/cms` ngoài scope | +| B65 | 2026-09-13 · A-02 review PR #468 (#332) | bug | `apps/cms/src/modules/setup/setup-token.ts:148` · `apps/cms/src/serve.ts` | **`LUMIBASE_REQUIRE_SETUP_TOKEN=true` khoá chết instance.** `printSetupTokenIfRequired` sinh token, lưu hash và in một dòng `[lumibase-cms] SETUP_TOKEN=…` — nhưng **không được gọi từ đâu** lúc khởi động (grep toàn repo: 3 kết quả, đều trong chính file đó). Phía đọc đã nối đủ (`verifySetupToken`, nhánh `requiresSetupToken`, mã `SETUP_TOKEN_REQUIRED`), nên hệ thống *kiểm tra* một token mà không có gì *sinh* ra nó ⇒ `/setup/complete` trả `SETUP_TOKEN_REQUIRED` vĩnh viễn, không có đường phục hồi ngoài tắt cờ + tạo lại container. Requirement ghi rõ phải sinh **lúc startup** (`admin-setup-wizard/requirements.md:63`); unit test gọi thẳng hàm nên vẫn xanh — khoảng trống giữa "hàm đúng" và "hàm được nối vào". Hệ quả: instance expose **không thể** bảo vệ theo cách tài liệu mô tả | medium | `tracked` | Issue **#470**. Fix = gọi trong `serve.ts` sau khi có DB + chốt cách xử lý Cloudflare (Workers không có "startup") + test đi qua **đường khởi động**. Starter #332 cố ý không bật cờ, bind loopback thay thế | | B61 | 2026-08-31 · rà workflow trên main sau batch dependabot (rebase 2026-09-06) | vuln (availability) | `packages/runtime/src/index.ts` · `apps/cms/src/middleware/runtime.ts` · `apps/cms/wrangler.toml` | **Mọi deploy Cloudflare bị Cloudflare từ chối** kể từ khi bullmq 6 vào main (#421, 2026-08-30): `Uncaught Error: Could not determine sql-loader directory path`, validation error 10021. Nguyên nhân: `packages/runtime/src/index.ts` re-export **cả** `./adapters/docker` (và `./factory` = `createRuntime`, và `./leader-lock` import `ioredis` như **value**), còn `middleware/runtime.ts` import `createRuntime` từ barrel → bundle Worker kéo theo cả cây docker gồm `bullmq`. BullMQ 6 thêm backend Postgres, và `dist/esm/postgres/sql-loader.js` gọi `getDirname()` ở **top level module**, throw khi không có `__dirname` lẫn frame `file:///` — đúng môi trường Worker đã bundle. Worker throw lúc khởi tạo ⇒ script bị từ chối. **Vì sao mọi gate đều xanh:** `pnpm build` chạy `wrangler deploy --dry-run`, nó *bundle mà không instantiate*; job `build` của CI vì thế không thể thấy class lỗi "throw ở top level". Đây cũng là chỗ tôi kết luận sai khi rà #421 — coi "build pass + round-trip Redis thật" là đủ, trong khi chưa có gì boot Worker. Chỉ `deploy-cms.yml` (dev/staging) đỏ; production do `release.yml` sở hữu (chỉ chạy khi tag) nên chưa vỡ nhưng sẽ vỡ ở lần cắt release tới | critical | `fixed` | Tách entry point: root barrel chỉ còn thứ an toàn cho Worker; `@lumibase/runtime/docker` (adapter docker) và `@lumibase/runtime/node` (`createRuntime` + leader lock) là subpath riêng. `middleware/runtime.ts` static-import **chỉ** `createCloudflareRuntime`, nhánh docker đi qua `await import()`. Nhưng dynamic import **vẫn là static edge** với bundler (esbuild inline target — đo được: bundle vẫn chứa bullmq), nên thêm `[alias]` trong `wrangler.toml` map subpath docker sang stub `runtime-docker-unavailable.ts` (throw kèm mô tả nếu nhánh bất khả thi kia chạy). Kết quả đo: upload Worker **8848.98 KiB → 6085.28 KiB** (số trước lấy từ log deploy đỏ cuối cùng, run 33953796948 ngày 2026-09-05; số sau là build của PR này), 0 dấu vết bullmq/ioredis/aws-sdk/sql-loader ở cả 3 env. Gate mới: `pnpm verify:worker-bundle` (assert thành phần bundle — **đây** mới là hàng rào thật, đã test âm: thêm lại re-export docker → đỏ đúng) + `pnpm verify:worker-startup` (boot Worker bằng workerd). **Lưu ý đã đo:** startup gate **KHÔNG** bắt được ca này — với docker cố tình bundle lại, `wrangler dev` vẫn boot và trả `/health` 500, vì fallback quét stack `file:///` của bullmq thành công ở local mà thất bại ở Worker deploy; nên đừng đọc "startup xanh" thành "deploy được". Cả hai chạy trong CI job `worker-bundle`. Bonus: `serve.ts` trước đây inject runtime bằng middleware thứ hai đặt **sau** `withRuntime`, nên Docker mode dựng **hai** runtime mỗi process (hai kết nối Redis, hai pg pool) rồi bỏ một — nay dùng `setRuntimeFactory`, còn một Issue #459. | | B60 | 2026-09-09 · review PR #456 (§4a docs) | task (docs debt) | `docs/{en,vi}/features/agent-harness-layer.md` | Bản VI của `agent-harness-layer.md` **không phải bản dịch** của bản EN mà là một tài liệu khác: một đề xuất 8 mục có số, 10 heading so với 25 của EN, dài **25%** bản EN, có bảng và code fence mà EN không có và thiếu 253 inline-code identifier EN có. Front matter vẫn ghi `syncStatus: human-translated`, và `docs:i18n:detect` xếp cặp này up-to-date vì hash còn khớp — đúng điểm mù B37/B54/B58 nhưng ở quy mô cả file. PR #456 thêm phần approval claim/quarantine/reopen vào **cả hai** bên trong cùng commit nên không làm tệ thêm, và stamp bằng `--allow-structure-drift` kèm lý do | medium | `open` | Dịch lại bản VI từ bản EN hiện tại (hoặc quyết định bản VI là một tài liệu roadmap riêng và **đổi tên** nó, bỏ `translatedFrom`/`syncStatus` để nó không còn giả vờ là một nửa của cặp). Việc dịch thuần, không đụng code. **Cập nhật 2026-09-10:** parity gate (#458) đã merge và chặn PR #456 vì cặp này; đã thêm waiver `` vào đầu bản VI để gate không chặn thay đổi không liên quan. Waiver ghi nhận hiện trạng chứ không chấp nhận nó — **gỡ waiver khi làm xong mục này**, lúc đó cặp phải qua parity mà không cần miễn trừ | diff --git a/CHANGELOG.md b/CHANGELOG.md index f7782de2..62bdec23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,41 @@ Source: [github.com/khuepm/lumibase](https://github.com/khuepm/lumibase) · Webs ### Added +- **`npm create lumibase --template nextjs` scaffolds a working website, not a + starting point.** The two existing templates give you a server to build on; + this one gives you a site that already works: a Next.js front end, a CMS with + Studio in one container, a `posts` collection with real fields, and seeded + content you can edit and publish. The point of #332 was to make that loop + provable end to end, so the generated project ships `cms:bootstrap`, + `cms:seed` and `cms:verify` and all three are re-runnable. + + The browser never holds an admin credential, and two independent things keep + that true. It gets a *publishable* key (`lbk_pub_…`), origin-locked and bound + to one site, while the admin token stays in a variable with no `NEXT_PUBLIC_` + prefix so Next.js cannot inline it. And the public read grant carries + `publishedOnly`, which matters more than it sounds: `GET /api/v1/items` + applies no published-only filter of its own, so a grant without it would serve + drafts to every visitor. The seed deliberately leaves one post unpublished so + `cms:verify` has a real draft to fail on, and it checks the list, the item by + its own id, an explicit `status=draft` query, a write attempt, and — given a + second site id — cross-tenant isolation. + + A refusal only counts when the server actually refused. An earlier version + treated any failed request as proof of a working guard, so an HTTP 500 read as + "denied" and a broken server produced a green run; it now requires 401/403 + (plus 404 where hiding a row *is* the refusal) and a well-formed response + envelope, and reports checks it could not run as skipped rather than passed. + + The CMS image is pinned by digest rather than tag, because no semver tag + contains Studio yet — the commit adding it postdates the last release build — + and `edge`, which does, is rebuilt on every push to main. Every published port + binds `127.0.0.1`: the stack ships development secrets, and the setup-token + gate is deliberately left off because enabling it locks an instance out + ([#470](https://github.com/khuepm/LumiBase/issues/470)). + + `lumibase init --template nextjs` resolves the scaffolder from the registry, + so it reaches this template only after `create-lumibase` is published again. + - **Deploy triggers are now rate-limited per target.** `POST /api/v1/deployments/targets/:id/deploy` was gated on "site admin" and "target is active" — who may trigger, never how often — so anything holding admin credentials (a script, a stuck flow, an From 02615ce398ea8b80264095109557f844b3d40863 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 04:33:37 +0700 Subject: [PATCH 22/26] docs(spec): prove `lumibase init` against a local registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 was right that the mocked init-runner test proves argv forwarding, not artifact resolution. A disposable Verdaccio settles it: publish all three packages, install the CLI from that registry outside the monorepo, and run `lumibase init --template nextjs`. It scaffolds; diffing the result against `npm create lumibase` from the same registry shows only the project name; and the generated project installs and typechecks clean. Publishing for real exposed two hazards that reading the manifests would not, both of which would have shipped: - `npm publish` does not rewrite `workspace:*`, so `lumibase` reached the registry depending on `@lumibase/sdk@workspace:*` — uninstallable. - `npm publish` also ignores publishConfig fields that pnpm applies, so the SDK published with `types: "./src/index.ts"`, a path excluded from `files` and therefore absent from the tarball. Installing succeeded and `tsc` then failed with "Module 'lumibase' has no exported member 'createLumiClient'" — a failure that surfaces one step removed from its cause. Both mean these packages must be published with pnpm, which is release mechanics rather than anything specific to this ticket. The first run failing with ENOENT before the npx cache was cleared is kept in the record: that is exactly what users get from the public artifact until create-lumibase is published again, so the release dependency in §2 stands. This proves the mechanism, not that npmjs already has the template. Refs #332 --- .kiro/specs/nextjs-starter-contract/design.md | 102 +++++++++++++++--- 1 file changed, 90 insertions(+), 12 deletions(-) diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index cd076553..494cceed 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -258,17 +258,65 @@ ls node_modules/create-lumibase/dist/templates # must list: nextjs ./node_modules/.bin/create-lumibase my-site --template nextjs --pm npm --no-git ``` -**What this does and does not prove.** It exercises the `npm create` path -against the artifact just built. It does **not** prove `lumibase init` resolves -the same artifact: `init` fetches `create-lumibase@` from the -registry (`packages/cli/src/commands/init.ts:20-45`), and the unit test covering -it asserts only that argv is forwarded — a mocked runner, not a resolution test. - -Proving the second entrypoint requires publishing, or a disposable local -registry (e.g. Verdaccio) with the registry URL pointed at it for the duration of -the test. Until one of those happens, the honest statement is the one in §2: -`npm create` works today, `lumibase init` reaches parity after the next publish. -Verified rather than assumed — see §9. +This exercises the `npm create` path. The second entrypoint needs a registry, +because `init` fetches `create-lumibase@` rather than resolving +anything locally (`packages/cli/src/commands/init.ts:20-45`), and the unit test +covering it mocks the runner. §5.4 proves it against a disposable one. + +### 5.4 Proving `lumibase init` against a local registry + +```bash +# 1. a disposable registry, proxying npmjs for everything else +npx verdaccio@6 --config conf/config.yaml --listen 4873 +curl -X PUT -H 'content-type: application/json' \ + -d '{"name":"test","password":"test1234"}' \ + http://localhost:4873/-/user/org.couchdb.user:test # → auth token + +# 2. publish all three, with pnpm — `npm publish` does NOT rewrite +# `workspace:*`, so a package published that way is uninstallable +cd packages/sdk && pnpm publish --registry=http://localhost:4873 --tag latest --no-git-checks +cd ../cli && pnpm publish --registry=http://localhost:4873 --tag latest --no-git-checks +cd ../create-lumibase && pnpm publish --registry=http://localhost:4873 --tag latest --no-git-checks + +# 3. install the CLI from the registry, outside the monorepo +cd "$(mktemp -d)" && npm init -y +echo 'registry=http://localhost:4873' > .npmrc +npm i lumibase + +# 4. the actual test — init must resolve the scaffolder holding the new template +rm -rf ~/.npm/_npx/* # npx caches by spec, not by registry +npm_config_registry=http://localhost:4873 \ + ./node_modules/.bin/lumibase init my-site --template nextjs --pm npm --no-git +``` + +Four things this surfaced that a plan on paper would not have: + +- **`npm publish` leaves `workspace:*` in the manifest.** Only `pnpm publish` + rewrites it to the real version. Published the wrong way, `lumibase` reaches + the registry depending on `@lumibase/sdk@workspace:*`, which no client can + resolve. §5.3's `npm pack` recipe is unaffected — it packs `create-lumibase`, + which has no workspace dependencies — but the CLI must go through pnpm. +- **npx caches by package spec, not by registry.** A previous + `create-lumibase@1.0.0-rc.1` fetched from npmjs is reused even after the + registry changes, so the first run failed with ENOENT on the template + directory — the public artifact, exactly as §2 describes. Clearing + `~/.npm/_npx` is part of the procedure, not an aside. +- **`npm publish` also ignores `publishConfig` fields that pnpm applies.** + Published with npm, `@lumibase/sdk` reached the registry with + `types: "./src/index.ts"` — a path excluded from `files`, so it does not exist + in the tarball. The install succeeds and only fails later, at `tsc`, with + "Module 'lumibase' has no exported member 'createLumiClient'". Re-published + with pnpm the field resolves to `./dist/index.d.ts` and typecheck passes. This + is a release-mechanics hazard beyond this ticket: any publish of these + packages must go through pnpm. +- **Verdaccio listens on IPv6.** It reports `http://localhost:4873`; probing + `127.0.0.1` gets nothing. + +Result: `lumibase init --template nextjs` scaffolds successfully; diffing its +output against `npm create lumibase` from the same registry shows **only the +project name**; and the generated project installs from that registry and +typechecks clean (`tsc --noEmit`, exit 0). The two entrypoints are equivalent in +practice, not merely by construction. See §9.5. ## 6. Where SDK/API support is needed @@ -526,7 +574,37 @@ The six items left open by round 3: for the template, and backlog rows **B64** (#469) and **B65** (#470) pointing at the existing issues. No duplicate issues were created. -### 9.5 Divergences from the original contract +### 9.5 `lumibase init` proven against a local registry + +Round 3 flagged that the mocked init-runner test is not evidence that both real +entrypoints resolve the new artifact. A disposable Verdaccio settles it. + +| Step | Result | +|---|---| +| Publish `@lumibase/sdk`, `lumibase`, `create-lumibase` (pnpm) | ✔ all three, `workspace:*` rewritten to `1.0.0-rc.1` | +| `npm i lumibase` from that registry, outside the monorepo | ✔ 3 packages | +| `lumibase init my-site --template nextjs` | ✔ scaffolded | +| `diff` against `npm create lumibase` from the same registry | ✔ **only the project name differs** | +| `npm install` + `tsc --noEmit` in the generated project | ✔ exit 0 | + +Two hazards this exposed, neither visible without actually publishing: + +- **`npm publish` does not rewrite `workspace:*`**, so `lumibase` reached the + registry depending on `@lumibase/sdk@workspace:*` — uninstallable. Only + `pnpm publish` rewrites it. +- **`npm publish` ignores `publishConfig` fields pnpm applies**, so + `@lumibase/sdk` published with `types: "./src/index.ts"` — a path not in + `files`, so absent from the tarball. Installing succeeded; `tsc` then failed + with "Module 'lumibase' has no exported member 'createLumiClient'". Both + packages must be published with pnpm; §5.4 records this. + +The first ENOENT run is worth keeping too: before the npx cache was cleared, +`lumibase init --template nextjs` failed exactly as §2 predicts for the public +artifact, which is the behaviour users see until `create-lumibase` is published +again. The release dependency in §2 is unchanged — this proves the mechanism, +not that the public registry already has the template. + +### 9.6 Divergences from the original contract - **Redis added to the compose file.** Without it the Docker runtime falls back to `127.0.0.1:6379` and floods the log with **506 ECONNREFUSED lines**, burying From b6298b26f2ffb1594c5842424668ef00db1f6c40 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 11:45:40 +0700 Subject: [PATCH 23/26] fix(create-lumibase): a missing collection is not proof of tenant isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-site probe was passed DENIED_OR_HIDDEN, so it accepted 404. That set exists for one specific case — reading an item whose id is known to exist, where 404 can only mean "hidden from you" and is the better answer because 403 would confirm the id is real. Neither half of that reasoning holds here: a second site with no `posts` collection answers 404 too, so an empty site B would pass a check that proves nothing about isolation. Reproduced with a fixture where site B returns 404 COLLECTION_NOT_FOUND: the old script printed "✔ denied with 404" and exited 0. It now requires 401/403 — what the CMS actually answers for a key/site mismatch, refused before a principal is built — and reports the status when it gets anything else. Confirmed the fixture passes again once site B answers 401. DENIED_OR_HIDDEN is now used at exactly one call site, and its comment says why it must stay there. Refs #332 --- .../templates/nextjs/scripts/verify.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs index 4418376c..632b86c5 100644 --- a/packages/create-lumibase/templates/nextjs/scripts/verify.mjs +++ b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs @@ -48,8 +48,10 @@ const DENIED = new Set([401, 403]); * against a live CMS: the same id returns the draft to the admin token, 404 to * the publishable key, while a published id returns 200 to both. * - * This is deliberately NOT accepted for writes: there, a 404 means the route is - * wrong and the test proved nothing. + * Deliberately NOT used anywhere else. For a write, a 404 means the route is + * wrong. For the cross-site probe, a site with no `posts` collection answers + * 404 too, so accepting it would let an empty second site pass a test that + * proves nothing about isolation. Both must see a real refusal. */ const DENIED_OR_HIDDEN = new Set([401, 403, 404]); @@ -246,6 +248,14 @@ async function main() { // a made-up id the process dies. That difference is why these are separate. const otherSite = process.env.LUMIBASE_VERIFY_OTHER_SITE; if (otherSite) { + // Strictly 401/403 — NOT the relaxed set used for the draft-by-id read. + // + // That exception is justified only because the id is known to exist, so a + // 404 can mean nothing but "hidden from you". Here it is ambiguous: a site + // that simply has no `posts` collection answers 404 too, and accepting it + // would let an empty site B pass a test that proves nothing about + // isolation. The CMS refuses a key/site mismatch with 401 before a + // principal is even built, so that is what this must see. await expectDenied( `publishable key cannot read another site (${otherSite})`, () => @@ -253,7 +263,6 @@ async function main() { token: key, headers: { origin: PUBLIC_ORIGIN, 'x-lumi-site': otherSite }, }), - DENIED_OR_HIDDEN, ); } else { skip( From 1db854515aaa7b5db76c582d7a0cbbd9d54438ff Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 11:45:52 +0700 Subject: [PATCH 24/26] test(create-lumibase): stop the behavioural tests writing to the repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subprocess inherited vitest's cwd, and a successful bootstrap ends by calling updateEnvFile(), which writes `.env` relative to cwd. So running this suite wrote fixture credentials into packages/create-lumibase/.env. The file is gitignored, which is luck rather than design: a test that modifies the repository it is testing is a bug in the test. Each run now gets a temporary directory, and one of the tests asserts the file lands there and nowhere near the package. The revoked-token case was also mis-covered. The test named for it passed an empty token, which short-circuits inside tokenWorks() before any request is made — so it exercised the missing-token path while claiming to cover revocation. It now passes a token the fixture answers 401 for, and a second test covers the opposite direction: a token that still authenticates must be reused, not rotated. Without that control, "rotates" could pass because the script always rotates. Also fixes a flake these tests introduced. Each spawns a real Node process, which costs about a second before the script runs, and several in parallel overran vitest's 5s default — failing as timeouts that read like logic errors. The suite failed 1-in-3 runs; with an explicit 30s budget for process startup it passed 5 consecutive runs. Refs #332 --- .../src/nextjs-scripts.behaviour.test.ts | 160 ++++++++++++++---- 1 file changed, 131 insertions(+), 29 deletions(-) diff --git a/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts index d4b9a10c..8b0fa047 100644 --- a/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts +++ b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts @@ -15,6 +15,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createServer, type Server } from 'node:http'; import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { promisify } from 'node:util'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -26,18 +28,24 @@ const scriptsDir = resolve( '../templates/nextjs/scripts', ); -type Handler = (req: { method: string; url: string }) => { +type Handler = (req: { + method: string; + url: string; + headers: Record; +}) => { status: number; body: string; type?: string; }; const servers: Server[] = []; +const workdirs: string[] = []; afterEach(async () => { - await Promise.all( - servers.splice(0).map((s) => new Promise((done) => s.close(() => done()))), - ); + await Promise.all([ + ...servers.splice(0).map((s) => new Promise((done) => s.close(() => done()))), + ...workdirs.splice(0).map((d) => rm(d, { recursive: true, force: true })), + ]); }); /** Start a stub CMS and return its base URL. */ @@ -48,7 +56,11 @@ async function stubCms(handler: Handler): Promise { res.end('ok'); return; } - const out = handler({ method: req.method ?? 'GET', url: req.url ?? '' }); + const out = handler({ + method: req.method ?? 'GET', + url: req.url ?? '', + headers: req.headers as Record, + }); res.writeHead(out.status, { 'content-type': out.type ?? 'application/json' }); res.end(out.body); }); @@ -60,25 +72,47 @@ async function stubCms(handler: Handler): Promise { return `http://127.0.0.1:${address.port}`; } -/** Run a starter script and capture its outcome. */ +/** + * Run a starter script and capture its outcome. + * + * Every run gets a throwaway working directory, and that is not tidiness. A + * successful bootstrap ends by calling `updateEnvFile()`, which writes `.env` + * **relative to cwd** — so a subprocess inheriting the runner's directory wrote + * fixture credentials straight into `packages/create-lumibase/.env`. A test + * that modifies the repository it is testing is a bug in the test, whether or + * not the file happens to be gitignored. + */ async function runScript( script: string, env: Record, -): Promise<{ code: number; out: string }> { +): Promise<{ code: number; out: string; cwd: string }> { + const cwd = await mkdtemp(join(tmpdir(), 'lumibase-starter-test-')); + workdirs.push(cwd); + try { const { stdout, stderr } = await run('node', [join(scriptsDir, script)], { + cwd, env: { ...process.env, ...env }, }); - return { code: 0, out: stdout + stderr }; + return { code: 0, out: stdout + stderr, cwd }; } catch (err) { const e = err as { code?: number; stdout?: string; stderr?: string }; - return { code: e.code ?? 1, out: (e.stdout ?? '') + (e.stderr ?? '') }; + return { code: e.code ?? 1, out: (e.stdout ?? '') + (e.stderr ?? ''), cwd }; } } const PUBLISHED_ITEM = { id: 'pub1', status: 'published', data: { slug: 'a', title: 'A' } }; -describe('cms:verify — a malformed 200 is not a passing check', () => { +/** + * Each test spawns a real Node process, which costs roughly a second before the + * script under test runs at all. Several of those in parallel on a loaded + * machine overrun vitest's 5s default and fail as timeouts that read like logic + * errors — they are not. The work itself is milliseconds; this budget is for + * process startup. + */ +const TIMEOUT = 30_000; + +describe('cms:verify — a malformed 200 is not a passing check', { timeout: TIMEOUT }, () => { it('fails when the draft query answers 200 with an HTML error page', async () => { // The exact shape review round 3 reproduced: everything else behaves, but a // proxy returns an HTML error for one query. `body?.data ?? []` read that as @@ -157,33 +191,37 @@ describe('cms:verify — a malformed 200 is not a passing check', () => { }); }); -describe('cms:bootstrap — a token in .env is not proof it still works', () => { - it('rotates instead of reusing when the stored token is rejected', async () => { - // Review round 3: a revoked token was written back unchanged and bootstrap - // exited 0, leaving the website on 401 with no way to recover by rerunning. - let rotated = false; - - const url = await stubCms(({ method, url }) => { +describe('cms:bootstrap — a token in .env is not proof it still works', { timeout: TIMEOUT }, () => { + /** A stub CMS that is fully provisioned except for the key's token state. */ + function bootstrapStub(opts: { + /** The token the fixture treats as still valid. */ + liveToken: string; + onRotate: () => void; + }): Handler { + return ({ method, url }) => { const json = (data: unknown) => ({ status: 200, body: JSON.stringify({ data }) }); + const deny = () => ({ + status: 401, + body: JSON.stringify({ errors: [{ code: 'UNAUTHENTICATED' }] }), + }); - if (url.startsWith('/api/v1/setup/state')) return { status: 200, body: JSON.stringify({ state: 'initialized' }) }; + if (url.startsWith('/api/v1/setup/state')) { + return { status: 200, body: JSON.stringify({ state: 'initialized' }) }; + } if (url.startsWith('/api/v1/auth/login')) return json({ token: 'admin-token' }); - // Fields live on their own endpoint; bootstrap reads them from there. if (url.startsWith('/api/v1/collections/posts/fields')) { return json([{ name: 'title' }, { name: 'slug' }, { name: 'body' }]); } if (url.startsWith('/api/v1/collections/posts')) return json({ name: 'posts' }); if (url === '/api/v1/collections') { - // The real code the CMS returns; bootstrap now checks for exactly this - // rather than accepting any 409. return { status: 409, body: JSON.stringify({ errors: [{ code: 'COLLECTION_EXISTS' }] }) }; } if (url.includes('/access/grants/public/enable')) return json({ roleId: 'role-public' }); if (url.includes('/access/grants/public')) return json({}); if (url.includes('/rotate')) { - rotated = true; - return json({ token: 'lbk_pub_fresh' }); + opts.onRotate(); + return json({ token: opts.liveToken }); } if (url === '/api/v1/api-keys' && method === 'GET') { return json([ @@ -198,23 +236,87 @@ describe('cms:bootstrap — a token in .env is not proof it still works', () => } if (/\/api-keys\/key1$/.test(url)) return json({ roles: [{ roleId: 'role-public' }] }); - // The probe that decides reuse-vs-rotate: the stale token is refused, - // the fresh one works. + // The probe that decides reuse-vs-rotate, and the assertion that matters: + // only the live token authenticates. Everything else is refused the way + // the CMS refuses a revoked key. if (url.startsWith('/api/v1/items/posts')) { return { status: 200, body: JSON.stringify({ data: [] }) }; } return json({}); + }; + } + + it('rotates when the stored token is refused, and finishes successfully', async () => { + // The revoked-token case proper: a token IS present, and the server answers + // 401 for it. An earlier version of this test passed an empty string, which + // short-circuits inside tokenWorks() before any request — so it exercised + // the missing-token path while claiming to cover revocation. + let rotated = false; + const REVOKED = 'lbk_pub_revoked'; + const LIVE = 'lbk_pub_fresh'; + + const stub = bootstrapStub({ liveToken: LIVE, onRotate: () => { rotated = true; } }); + const url = await stubCms((req) => { + if (req.url.startsWith('/api/v1/items/posts')) { + const auth = String(req.headers.authorization ?? ''); + if (auth.includes(REVOKED)) { + return { status: 401, body: JSON.stringify({ errors: [{ code: 'UNAUTHENTICATED' }] }) }; + } + return { status: 200, body: JSON.stringify({ data: [] }) }; + } + return stub(req); + }); + + const result = await runScript('bootstrap.mjs', { + NEXT_PUBLIC_LUMIBASE_URL: url, + LUMIBASE_ADMIN_EMAIL: 'admin@example.com', + LUMIBASE_ADMIN_PASSWORD: 'Change-Me-N0w!', + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: REVOKED, }); + expect(rotated, `bootstrap did not rotate a refused token:\n${result.out}`).toBe(true); + expect(result.code, result.out).toBe(0); + expect(result.out).toMatch(/rotating/); + }); + + it('reuses the token when it still authenticates', async () => { + // The control. Without it, "rotates" above could pass because the script + // always rotates, which would be its own bug. + let rotated = false; + const LIVE = 'lbk_pub_live'; + + const url = await stubCms(bootstrapStub({ liveToken: LIVE, onRotate: () => { rotated = true; } })); + const result = await runScript('bootstrap.mjs', { NEXT_PUBLIC_LUMIBASE_URL: url, LUMIBASE_ADMIN_EMAIL: 'admin@example.com', LUMIBASE_ADMIN_PASSWORD: 'Change-Me-N0w!', - // Deliberately absent: this is the "token was lost" case, which must - // rotate rather than reuse nothing. - NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: '', + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: LIVE, }); - expect(rotated, `bootstrap did not rotate:\n${result.out}`).toBe(true); + expect(result.code, result.out).toBe(0); + expect(rotated, `bootstrap rotated a working token:\n${result.out}`).toBe(false); + expect(result.out).toMatch(/reusing the existing key/); + }); + + it('writes .env into its own working directory, never the caller\'s', async () => { + // The regression this file caused: the subprocess inherited vitest's cwd + // and updateEnvFile() wrote fixture credentials into the repository. + const url = await stubCms(bootstrapStub({ liveToken: 'lbk_pub_live', onRotate: () => {} })); + + const result = await runScript('bootstrap.mjs', { + NEXT_PUBLIC_LUMIBASE_URL: url, + LUMIBASE_ADMIN_EMAIL: 'admin@example.com', + LUMIBASE_ADMIN_PASSWORD: 'Change-Me-N0w!', + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: 'lbk_pub_live', + }); + + expect(result.code, result.out).toBe(0); + const written = await readFile(join(result.cwd, '.env'), 'utf8'); + expect(written).toMatch(/NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY=/); + + // And nothing landed next to the package being tested. + const strayEnv = join(dirname(fileURLToPath(import.meta.url)), '..', '.env'); + await expect(readFile(strayEnv, 'utf8')).rejects.toThrow(); }); }); From fe1b584e6f946f9dc691aaeae4b8b8b3736f5a55 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 11:46:19 +0700 Subject: [PATCH 25/26] docs(spec): record round 4, and correct the typecheck claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings came from round-3 fixes, which is worth stating plainly: each guard added is itself something that can be wrong, and both were. Also corrects something I had been reporting loosely. Earlier rounds quoted `turbo run typecheck` as 18/18; forced, @lumibase/docs reports 47 TS errors. They are pre-existing on main — verified by running the same command there — and untouched by this PR, but '18/18' was a cached number presented as a full run. Refs #332 --- .kiro/specs/nextjs-starter-contract/design.md | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.kiro/specs/nextjs-starter-contract/design.md b/.kiro/specs/nextjs-starter-contract/design.md index 494cceed..c91f9ed5 100644 --- a/.kiro/specs/nextjs-starter-contract/design.md +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -604,7 +604,56 @@ artifact, which is the behaviour users see until `create-lumibase` is published again. The release dependency in §2 is unchanged — this proves the mechanism, not that the public registry already has the template. -### 9.6 Divergences from the original contract +### 9.6 Review round 4 (`02615ce3`) — two findings, both valid + +Both were introduced by round-3 fixes, which is the useful part: each new guard +is itself something that can be wrong. + +1. **[P2] The behavioural tests wrote into the repository.** The subprocess + inherited vitest's cwd, and a successful bootstrap ends in `updateEnvFile()`, + which writes `.env` **relative to cwd** — so running the suite wrote fixture + credentials into `packages/create-lumibase/.env`. Confirmed by inspecting the + file. It is gitignored, so nothing could reach a commit, but that is luck: + a test that modifies the repository under test is a bug in the test. Each run + now gets a temporary directory, removed afterwards, and one test asserts the + file lands there and not beside the package. + + The reviewer also caught that the revoked-token case was mis-covered: the + test named for it passed an **empty** token, which short-circuits inside + `tokenWorks()` before any request — so it exercised the missing-token path + under the wrong name. It now passes a token the fixture answers 401 for, and + a second test covers the opposite direction (a working token must be reused, + not rotated) so "rotates" cannot pass because the script always rotates. + + Fixing this exposed a flake the same tests had introduced: each spawns a real + Node process (~1s before the script runs), and several in parallel overran + vitest's 5s default, failing as timeouts that read like logic errors. The + suite failed 1-in-3 runs; with an explicit startup budget it passed 5 + consecutive runs. + +2. **[P2] A missing collection was accepted as tenant isolation.** The + cross-site probe was passed `DENIED_OR_HIDDEN`, so it accepted 404. That set + exists for one case — an item id known to exist, where 404 can only mean + "hidden from you" and beats 403, which would confirm the id is real. Neither + half holds for a whole-collection read on another site: a site B with no + `posts` collection answers 404 too, so an empty second site would pass a + check that proves nothing. + + Reproduced with a fixture where site B answers `404 COLLECTION_NOT_FOUND`: + the old script printed `✔ denied with 404` and exited 0. It now requires + 401/403 — what the CMS answers for a key/site mismatch, refused before a + principal is built — and passes again once the fixture answers 401. + `DENIED_OR_HIDDEN` is now used at exactly one call site. + +Tests: 53 → 55. + +**Note on typecheck.** Earlier rounds reported `turbo run typecheck` as 18/18; +that was partly cache. Forced (`--force`), `@lumibase/docs` reports 47 TS errors +— **pre-existing on `main`**, verified by checking out `main` and running the +same command, and untouched by this PR. The two packages this PR changes +(`create-lumibase`, `lumibase`) typecheck clean without cache. + +### 9.7 Divergences from the original contract - **Redis added to the compose file.** Without it the Docker runtime falls back to `127.0.0.1:6379` and floods the log with **506 ECONNREFUSED lines**, burying From 71a719ab94f07dbe4d43cb93533a194d983efd60 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 12:19:52 +0700 Subject: [PATCH 26/26] test(create-lumibase): compare .env before and after, don't demand it be absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard asserted that the package's own .env does not exist. That tests a property of the machine, not of the code: a contributor with a perfectly ordinary .env would fail this test, and the failure would be about them rather than about anything being wrong. The claim is "the subprocess did not write here", so that is what is compared now — the file's contents before the run against after, with absent treated as a legitimate state rather than the required one. Verified both directions: with a pre-existing .env the suite passes and the file is byte-identical afterwards (md5 compared across three consecutive runs), and injecting the original regression — updateEnvFile resolving to the package directory again — makes this test fail. It catches the bug rather than merely passing. Refs #332 --- .../src/nextjs-scripts.behaviour.test.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts index 8b0fa047..94cc7aa4 100644 --- a/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts +++ b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts @@ -103,6 +103,23 @@ async function runScript( const PUBLISHED_ITEM = { id: 'pub1', status: 'published', data: { slug: 'a', title: 'A' } }; +/** This package's own `.env`, which no test may write to. */ +const PACKAGE_ENV = resolve(dirname(fileURLToPath(import.meta.url)), '..', '.env'); + +/** + * The current contents of that file, or `null` when it is absent. + * + * Both are legitimate states — a contributor may well have one — so the test + * compares this before and after rather than demanding the file not exist. + */ +async function readEnvSnapshot(): Promise { + try { + return await readFile(PACKAGE_ENV, 'utf8'); + } catch { + return null; + } +} + /** * Each test spawns a real Node process, which costs roughly a second before the * script under test runs at all. Several of those in parallel on a loaded @@ -302,6 +319,7 @@ describe('cms:bootstrap — a token in .env is not proof it still works', { time it('writes .env into its own working directory, never the caller\'s', async () => { // The regression this file caused: the subprocess inherited vitest's cwd // and updateEnvFile() wrote fixture credentials into the repository. + const before = await readEnvSnapshot(); const url = await stubCms(bootstrapStub({ liveToken: 'lbk_pub_live', onRotate: () => {} })); const result = await runScript('bootstrap.mjs', { @@ -315,8 +333,13 @@ describe('cms:bootstrap — a token in .env is not proof it still works', { time const written = await readFile(join(result.cwd, '.env'), 'utf8'); expect(written).toMatch(/NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY=/); - // And nothing landed next to the package being tested. - const strayEnv = join(dirname(fileURLToPath(import.meta.url)), '..', '.env'); - await expect(readFile(strayEnv, 'utf8')).rejects.toThrow(); + // And the package's own .env is untouched. + // + // "Untouched" is the claim, so that is what is compared. Asserting the file + // does not exist would test a property of the machine instead: a developer + // with a perfectly good .env would fail this, and the test would be wrong + // about them rather than about the code. + const after = await readEnvSnapshot(); + expect(after, 'the bootstrap subprocess wrote to the package\'s own .env').toBe(before); }); });