diff --git a/.kiro/specs/admin-setup-wizard/setup-impact.md b/.kiro/specs/admin-setup-wizard/setup-impact.md index 3fbd300fe..cdbd42cc7 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 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 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 new file mode 100644 index 000000000..c91f9ed54 --- /dev/null +++ b/.kiro/specs/nextjs-starter-contract/design.md @@ -0,0 +1,663 @@ +# Design Document — Next.js starter contract (#332, handoff A-02) + +> **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. +> +> This contract is kept as the design record. What was actually run, and the +> evidence for it, is in §9. +> +> Baseline: main `6a20441af5dde899b976479f0ed7f8d1a9341dee`. +> Every claim below was verified against the source tree, the registry, or a +> running instance. + +## 1. Overview + +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. + +Principles: + +- **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. The Next.js template + +Add a third template, `nextjs`, keeping `default` and `cloudflare` untouched. + +`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. + +The edits, deliberately minimal: + +| Location | Change | +|---|---| +| `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`) | + +**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. + +⚠️ **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` (via the new tarball/`dist`) works today; `lumibase init` reaches +parity **after the next publish**. No code change can make that happen sooner. + +## 3. Two backend paths + +### 3.1 Path A — connect an existing CMS/Studio instance + +The consumer needs only a base URL, a site id and a publishable key. No +provisioning. + +### 3.2 Path B — Docker, CMS and Studio in one image + +⚠️ **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. + +Verified by running the images themselves (`ls /app/studio`): + +| 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 | + +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 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. + +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` 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 Bootstrapping the first admin and site + +- `POST /api/v1/setup/complete` (`apps/cms/src/modules/setup/routes.ts:317-379`), + mounted publicly outside tenant/auth (`apps/cms/src/index.ts:173`). + Body: `account{email,password,firstName,lastName}`, `adminPath`, `setupToken?` + (`routes.ts:40-79`). +- 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 and public client + +### 4.1 Collection + +One `posts` collection with a minimal `title` / `slug` / `body` field set. + +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 A seed that is safe to re-run + +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 + +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`), + 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`). + ⚠️ 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`). + +⇒ A `read` grant without a row filter means the public client **reads drafts**. + +**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. + +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 The admin token + +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. Files, environment and commands + +### 5.1 File grant requested + +| File | Added/Changed | +|---|---| +| `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 Environment variable contract + +| Variable | Side | Role | +|---|---|---| +| `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 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 +# 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 +``` + +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 + +`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`), 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. What was actually run — evidence + +The whole lifecycle ran against a real instance (cold install outside the +monorepo → Docker → bootstrap → seed → website), with no mocks: + +| Item | Result | +|---|---| +| 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 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 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 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 `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 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 + 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. diff --git a/.kiro/steering/out-of-scope-backlog.md b/.kiro/steering/out-of-scope-backlog.md index 3c53c3079..7a289064e 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 f7782de22..62bdec231 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 diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index ba7676ae1..f971c541a 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/src/index.ts b/packages/create-lumibase/src/index.ts index 98a9654ea..9c75277d5 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/nextjs-scripts.behaviour.test.ts b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts new file mode 100644 index 000000000..94cc7aa4d --- /dev/null +++ b/packages/create-lumibase/src/nextjs-scripts.behaviour.test.ts @@ -0,0 +1,345 @@ +/** + * 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 { 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'; + +const run = promisify(execFile); + +const scriptsDir = resolve( + dirname(fileURLToPath(import.meta.url)), + '../templates/nextjs/scripts', +); + +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()))), + ...workdirs.splice(0).map((d) => rm(d, { recursive: true, force: true })), + ]); +}); + +/** 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 ?? '', + headers: req.headers as Record, + }); + 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. + * + * 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; 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, cwd }; + } catch (err) { + const e = err as { code?: number; stdout?: string; stderr?: string }; + return { code: e.code ?? 1, out: (e.stdout ?? '') + (e.stderr ?? ''), cwd }; + } +} + +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 + * 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 + // "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', { 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/auth/login')) return json({ token: 'admin-token' }); + 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: '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')) { + opts.onRotate(); + return json({ token: opts.liveToken }); + } + 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, 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!', + NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY: LIVE, + }); + + 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 before = await readEnvSnapshot(); + 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 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); + }); +}); 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 000000000..f6c976769 --- /dev/null +++ b/packages/create-lumibase/src/nextjs-template.test.ts @@ -0,0 +1,296 @@ +/** + * 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 — 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 — 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 + // 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 — 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 + // 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/src/scaffold.ts b/packages/create-lumibase/src/scaffold.ts index 1ecc7a9e9..61ec0eccc 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 1387b0aa9..ea679295d 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 1396fff45..9767d4267 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(); 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 000000000..ac91adffe --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/README.md.hbs @@ -0,0 +1,176 @@ +# {{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. + +## 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`. +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 (by list, by direct id, and by +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 +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 + +| 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. 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 + +Two CMS bugs shape this starter. Both are upstream, not in the template: + +- **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 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 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 + +## 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 000000000..9f5a83491 --- /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 000000000..9d0ac4b49 --- /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 000000000..baf31e404 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/app/globals.css @@ -0,0 +1,126 @@ +: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); +} + +.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; + 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 000000000..2a0bc8f65 --- /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 000000000..c56c23878 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/app/page.tsx @@ -0,0 +1,100 @@ +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:bootstrap — first admin, public read access + and a browser-safe key +
  6. +
  7. + npm run cms:seed — sample posts +
  8. +
+ +

+ 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. +

+
+ ); +} + +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 000000000..bc0933d78 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/docker-compose.yml @@ -0,0 +1,96 @@ +# 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 + # Loopback only. Nothing outside this machine needs the database, and the + # password here is a development default. + ports: + - "127.0.0.1:${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 + # Loopback only; Redis has no auth configured here. + ports: + - "127.0.0.1:${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 — 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 + # 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 + # 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: + - "127.0.0.1:${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 000000000..1ed6f0c60 --- /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 000000000..d5456a15d --- /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 000000000..56bb55e6c --- /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 000000000..e4fe8b6f1 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/scripts/bootstrap.mjs @@ -0,0 +1,376 @@ +/** + * 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'); +} + +/** 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}}' }, + }); + console.log(' done'); + } catch (err) { + // 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; + } + } + + // 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.', + ); + } +} + +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; +} + +/** + * 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; +} + +/** + * Is this token actually usable right now? + * + * 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. + * + * 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. + */ +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?.publishable && !k?.revokedAt && isOwnedByThisProject(k), + ); + + let keyId; + let plaintext; + + if (mine && (await tokenWorks(process.env.NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY))) { + console.log(' reusing the existing key (token verified)'); + keyId = mine.id; + plaintext = process.env.NEXT_PUBLIC_LUMIBASE_PUBLISHABLE_KEY; + } else if (mine) { + 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.'); + } 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], + // What makes this key findable by THIS project and no other. + metadata: { starterOwner: OWNER_TAG }, + }, + }); + 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'); + } + + // 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 }, + }); + } + + // 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; +} + +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 ensurePublishableKey(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 000000000..b33f581df --- /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 000000000..6600c3e62 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/scripts/seed.mjs @@ -0,0 +1,80 @@ +/** + * Seed sample posts. Safe to run repeatedly. + * + * npm run cms:seed + * + * 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`. + */ + +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`); + + let created = 0; + for (const post of POSTS) { + // 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; + } + + 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 000000000..632b86c52 --- /dev/null +++ b/packages/create-lumibase/templates/nextjs/scripts/verify.mjs @@ -0,0 +1,297 @@ +/** + * Prove the public setup is actually safe. + * + * npm run cms:verify + * + * 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 — by list, by direct id, or by asking. + * 3. The key cannot write. + * 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 + * 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. + * + * 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]); + +/** + * 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; + +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(); + + 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 = 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 + 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)`, + ); + + // 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) { + skip('the draft is unreachable by direct id', 'LUMIBASE_ADMIN_TOKEN not set'); + } else { + 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( + 'the draft is unreachable by direct id', + 'no draft to test against — run: npm run cms:seed', + ); + } else { + await expectDenied( + `the draft is unreachable by direct id (${draft.id})`, + () => asPublic(`/api/v1/items/${COLLECTION}/${draft.id}`), + DENIED_OR_HIDDEN, + ); + } + } + } + + // 2c — asking for drafts explicitly must not produce any. + // + // 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 = 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}`); + } 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 + await expectDenied('publishable key cannot create items', () => + asPublic(`/api/v1/items/${COLLECTION}`, { + method: 'POST', + body: { data: { title: 'should not exist', slug: 'should-not-exist' } }, + }), + ); + + // 4 — cannot cross tenants. + // + // Two different probes, because they exercise different things and only one + // of them is dangerous: + // + // (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) { + // 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})`, + () => + api(`/api/v1/items/${COLLECTION}?limit=1`, { + token: key, + headers: { origin: PUBLIC_ORIGIN, 'x-lumi-site': otherSite }, + }), + ); + } 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('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' }, + }), + ); + } + + if (failures > 0) { + console.error(`\n✖ ${failures} check(s) failed.\n`); + process.exit(1); + } + 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) => { + 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 000000000..f13bc9038 --- /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"] +}