diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 000000000..56d8ef744 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,112 @@ +name: Deploy Demo + +# demo.sonicjs.com always runs the latest main. Every push to main redeploys the +# demo worker and resets its data (full wipe + reseed) so the public demo always +# reflects the newest build with a known dataset. +on: + push: + branches: + - main + paths: + - 'demo-app/**' + - 'packages/core/src/**' + - 'packages/core/migrations/**' + - 'www/public/images/blog/**' + - '.github/workflows/deploy-demo.yml' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 25 + + env: + DEMO_BASE_URL: https://demo.sonicjs.com + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build core package + run: npm run build:core + + - name: Type check demo app + run: npm run type-check --workspace=demo-app + + - name: Test demo app (reseed integration) + run: npm test --workspace=demo-app + + - name: Apply D1 migrations + run: cd demo-app && npx wrangler d1 migrations apply DB --remote + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Deploy demo to Cloudflare Workers + run: cd demo-app && npx wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Upload blog hero images to R2 + # Uploads all www/public/images/blog//hero.png files to R2 under + # blog//hero.png. Idempotent — re-uploads on every deploy so the + # bucket stays in sync if images are added or replaced. Runs before reseed + # so mediaSvc.createFromUpload finds the objects already in R2. + run: | + cd demo-app + for slug_dir in ../www/public/images/blog/*/; do + slug=$(basename "$slug_dir") + src="../www/public/images/blog/$slug/hero.png" + [ -f "$src" ] || continue + echo "Uploading blog/$slug/hero.png ..." + npx wrangler r2 object put sonicjs-demo-media/blog/$slug/hero.png \ + --file="$src" \ + --content-type=image/png \ + --remote + done + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Wait for deployment to be live + run: | + echo "Polling $DEMO_BASE_URL/auth/login ..." + for i in $(seq 1 30); do + code=$(curl -s -o /dev/null -w "%{http_code}" "$DEMO_BASE_URL/auth/login" || true) + if [ "$code" = "200" ]; then + echo "Demo is live (HTTP $code)." + exit 0 + fi + echo "Attempt $i: HTTP $code — retrying in 5s" + sleep 5 + done + echo "Demo did not become healthy in time." + exit 1 + + - name: Seed admin user (idempotent) + run: | + curl -fsS -X POST "$DEMO_BASE_URL/auth/seed-admin" -o /dev/null \ + && echo "Admin seeded." \ + || echo "Admin seed returned non-zero (may already exist) — continuing." + + - name: Reseed demo data (full wipe + rebuild) + run: | + status=$(curl -s -o /tmp/reseed.json -w "%{http_code}" \ + -X POST "$DEMO_BASE_URL/__demo/reseed" \ + -H "Authorization: Bearer ${DEMO_SEED_TOKEN}") + echo "Reseed HTTP $status" + cat /tmp/reseed.json || true + echo + test "$status" = "200" + env: + DEMO_SEED_TOKEN: ${{ secrets.DEMO_SEED_TOKEN }} diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index fa3c8f56f..8158c6fcc 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -194,7 +194,7 @@ jobs: run: | # Get changed files vs base branch (PR diff) or last commit (push) if [ "${{ github.event_name }}" = "pull_request_target" ]; then - CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...${{ github.event.pull_request.head.sha }} 2>/dev/null || git diff --name-only HEAD~1) + CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...${{ github.event.pull_request.head.sha }} 2>/dev/null || git diff --name-only HEAD~1 2>/dev/null || echo "") else CHANGED=$(git diff --name-only HEAD~1 2>/dev/null || echo "") fi diff --git a/demo-app/.dev.vars.example b/demo-app/.dev.vars.example new file mode 100644 index 000000000..cc1b362b9 --- /dev/null +++ b/demo-app/.dev.vars.example @@ -0,0 +1,16 @@ +# Copy to .dev.vars and fill in real values. .dev.vars is gitignored. +# Wrangler loads this file for `wrangler dev` (local dev only). +# +# Required: BETTER_AUTH_SECRET must be >= 16 chars. Auth init refuses to +# start without it and login returns 500. +# +# Generate a strong random secret: +# openssl rand -hex 32 +BETTER_AUTH_SECRET="replace-me-with-32-bytes-of-hex" + +# Bearer token guarding POST /__demo/reseed. The deploy workflow sends it as +# `Authorization: Bearer `. Any non-empty value works for local dev. +DEMO_SEED_TOKEN="replace-me-with-a-random-token" + +# Optional: set if Better Auth can't detect the base URL. +# BETTER_AUTH_URL="http://localhost:8787" diff --git a/demo-app/.gitignore b/demo-app/.gitignore new file mode 100644 index 000000000..d4a254392 --- /dev/null +++ b/demo-app/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +.wrangler/ +.mf/ + +# Environment files +.env +.env.local +.env.*.local +.dev.vars + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Test coverage +coverage/ +.nyc_output/ + +# Temporary files +tmp/ +temp/ diff --git a/demo-app/README.md b/demo-app/README.md new file mode 100644 index 000000000..7b4241229 --- /dev/null +++ b/demo-app/README.md @@ -0,0 +1,99 @@ +# SonicJS Demo App — demo.sonicjs.com + +The public demo. It **always runs the latest `main`** and **resets its data on +every deploy and every 2 hours**, so visitors can freely edit content, upload +media, and explore the admin without permanently changing anything. + +## What it includes + +- **4 example collections** (code-defined, no DB tables): `blog_post`, `page`, + `testimonial`, `faq`. +- **Sample content + images** — seeded blog posts, pages, testimonials, FAQs, + and SVG media assets uploaded to R2. +- **Demo-login prefill** — the login page is pre-filled with + `admin@sonicjs.com` / `sonicjs!` (the `demo-login` plugin, gated to this app). +- **Reset machinery** — the `demo-seed` plugin exposes `POST /__demo/reseed` + and a `0 */2 * * *` cron; both run the same `runReseed` (full wipe + rebuild). + +## How the reset works + +`runReseed(env)` (`src/plugins/demo-seed/reseed.ts`): + +1. Counts, then deletes **every** document for tenant `default` plus its derived + facet / reference / permission rows. +2. Purges the `demo-seed/` prefix in R2. +3. Upserts the seed document types (defensive FK guard). +4. Uploads the bundled SVG images to R2 and registers `media_asset` documents. +5. Recreates the 4 collections' sample content (published-on-create). +6. Re-activates the demo-login prefill. + +`document_types` and Better-Auth users are **not** documents, so they survive a +reset — the admin user is seeded once at deploy time via `/auth/seed-admin`. + +Both triggers are **hard-gated to `ENVIRONMENT === 'demo'`**; the HTTP route also +requires `Authorization: Bearer $DEMO_SEED_TOKEN`. This app can never wipe a +non-demo install. + +## One-time Cloudflare setup (operator) + +Provision the dedicated resources and paste their ids into `wrangler.toml` +(placeholders marked `REPLACE_WITH_...`): + +```bash +cd demo-app + +# D1 +npx wrangler d1 create sonicjs-demo +# → paste database_id into [[d1_databases]] + +# R2 +npx wrangler r2 bucket create sonicjs-demo-media + +# KV +npx wrangler kv namespace create sonicjs-demo-cache +# → paste id into [[kv_namespaces]] + +# Secrets +openssl rand -hex 32 | npx wrangler secret put BETTER_AUTH_SECRET +npx wrangler secret put DEMO_SEED_TOKEN # random token; also add as a GH Actions secret +``` + +DNS: point `demo.sonicjs.com` at this worker (the `sonicjs.com` zone is on the +same Cloudflare account, so the `custom_domain` route in `wrangler.toml` binds +directly). + +GitHub Actions secrets required by `.github/workflows/deploy-demo.yml`: +`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `DEMO_SEED_TOKEN`. + +## Local development + +```bash +# from repo root — build core first (the demo imports @sonicjs-cms/core) +npm run build:core + +cd demo-app +cp .dev.vars.example .dev.vars # set BETTER_AUTH_SECRET + DEMO_SEED_TOKEN +npm run db:migrate:local # apply 0001 + 0002 to local D1 +npm run seed:demo # full wipe + reseed local data (same runReseed) +npm run dev # wrangler dev +``` + +## Deploy + +Automatic on every push to `main` (see `.github/workflows/deploy-demo.yml`): +build core → type-check → migrate → deploy → seed admin → reseed. + +Manual: + +```bash +npm run deploy:demo # from repo root (builds core, deploys demo-app) +# then, against the live site: +curl -X POST https://demo.sonicjs.com/__demo/reseed \ + -H "Authorization: Bearer $DEMO_SEED_TOKEN" +``` + +## E2E + +`tests/e2e/82-demo-seed.spec.ts` — runs only when `DEMO_BASE_URL` is set +(skipped in the normal suite). Validates credential prefill, seeded public +content, and reseed-endpoint auth. diff --git a/demo-app/migrations/0001_core.sql b/demo-app/migrations/0001_core.sql new file mode 100644 index 000000000..4ac523bd5 --- /dev/null +++ b/demo-app/migrations/0001_core.sql @@ -0,0 +1,184 @@ +-- Migration 0001: Auth tables +-- auth_user, auth_session, auth_account, auth_verification + BA plugin tables + RBAC + auth support. +-- Only auth_* prefixed tables live here. All content lives in document_* tables (0002_documents.sql). + +-- ── auth_user ──────────────────────────────────────────────────────────────── +-- BA user model + SonicJS domain columns as BA additionalFields. +CREATE TABLE IF NOT EXISTS auth_user ( + id TEXT PRIMARY KEY, + name TEXT, + email TEXT NOT NULL UNIQUE, + email_verified INTEGER NOT NULL DEFAULT 0, + image TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + -- SonicJS additionalFields + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + -- Platform super-admin: bypasses the multi-tenant membership gate, uses global roles in every + -- tenant. Opt-in (default 0); intentionally NOT derived from the 'admin' role. + is_super_admin INTEGER NOT NULL DEFAULT 0, + avatar TEXT, + password_hash TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + last_login_at INTEGER, + phone TEXT, + bio TEXT, + timezone TEXT DEFAULT 'UTC', + language TEXT DEFAULT 'en', + email_notifications INTEGER DEFAULT 1, + theme TEXT DEFAULT 'dark', + invitation_token TEXT, + invited_by TEXT, + invited_at INTEGER, + accepted_invitation_at INTEGER, + failed_login_count INTEGER NOT NULL DEFAULT 0, + locked_until INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_auth_user_email ON auth_user(email); +CREATE INDEX IF NOT EXISTS idx_auth_user_role ON auth_user(role); +CREATE INDEX IF NOT EXISTS idx_auth_user_invitation_token ON auth_user(invitation_token); +CREATE INDEX IF NOT EXISTS idx_auth_user_locked_until ON auth_user(locked_until) WHERE locked_until IS NOT NULL; + +-- ── auth_session ───────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS auth_session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + ip_address TEXT, + user_agent TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_session_user_id ON auth_session(user_id); +CREATE INDEX IF NOT EXISTS idx_auth_session_token ON auth_session(token); +CREATE INDEX IF NOT EXISTS idx_auth_session_expires_at ON auth_session(expires_at); + +-- ── auth_account ───────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS auth_account ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + access_token_expires_at INTEGER, + refresh_token_expires_at INTEGER, + scope TEXT, + id_token TEXT, + password TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_account_user_id ON auth_account(user_id); +CREATE INDEX IF NOT EXISTS idx_auth_account_provider ON auth_account(provider_id, account_id); + +-- ── auth_verification ──────────────────────────────────────────────────────── +-- Covers email verification, password reset, magic-link tokens, OTP codes. +CREATE TABLE IF NOT EXISTS auth_verification ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_verification_identifier ON auth_verification(identifier); + +-- ── BA plugin tables ────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS auth_two_factor ( + id TEXT PRIMARY KEY, + secret TEXT NOT NULL, + backup_codes TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + verified INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_two_factor_user_id ON auth_two_factor(user_id); + +CREATE TABLE IF NOT EXISTS auth_tenant ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + logo TEXT, + metadata TEXT, + -- SonicJS tenant-resolution fields (BA organization additionalFields): + status TEXT NOT NULL DEFAULT 'active', + domain TEXT, + notes TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_tenant_domain ON auth_tenant(domain); + +CREATE TABLE IF NOT EXISTS auth_tenant_member ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES auth_tenant(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + email TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(tenant_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_auth_tenant_member_tenant ON auth_tenant_member(tenant_id); +CREATE INDEX IF NOT EXISTS idx_auth_tenant_member_user ON auth_tenant_member(user_id); + +CREATE TABLE IF NOT EXISTS auth_tenant_invitation ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES auth_tenant(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'pending', + expires_at INTEGER NOT NULL, + inviter_id TEXT REFERENCES auth_user(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_tenant_invitation_tenant ON auth_tenant_invitation(tenant_id); +CREATE INDEX IF NOT EXISTS idx_auth_tenant_invitation_email ON auth_tenant_invitation(email); + +CREATE TABLE IF NOT EXISTS auth_tenant_team ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + tenant_id TEXT NOT NULL REFERENCES auth_tenant(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- ── RBAC ───────────────────────────────────────────────────────────────────── +-- RBAC roles, verbs, and user-role assignments are document-backed (is_auth doc +-- types rbac_role / rbac_verb / rbac_user_roles — see services/rbac.ts). The +-- system roles/verbs/grants are seeded at bootstrap by RbacService.ensureSystemRbacSeed(). +-- No auth_rbac_* tables. + +-- ── Auth support tables ─────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS auth_password_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_password_history_user_id ON auth_password_history(user_id); + +CREATE TABLE IF NOT EXISTS auth_api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES auth_user(id), + permissions TEXT NOT NULL, + expires_at INTEGER, + last_used_at INTEGER, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_api_tokens_user ON auth_api_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_auth_api_tokens_token ON auth_api_tokens(token); + +-- User profiles moved to the document model: a `user_profile` document (is_auth type), +-- one per user, addressed by slug = userId. See services/document-types-seed.ts and +-- plugins/core-plugins/user-profiles/user-profile-document.ts. No auth_user_profiles table. diff --git a/demo-app/migrations/0002_documents.sql b/demo-app/migrations/0002_documents.sql new file mode 100644 index 000000000..abb4f7330 --- /dev/null +++ b/demo-app/migrations/0002_documents.sql @@ -0,0 +1,163 @@ +-- Migration 0002: Document Schema (v3 greenfield) +-- Contains only the new document data model tables, generated columns, and indexes. + +-- Document type registry +CREATE TABLE IF NOT EXISTS document_types ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + schema TEXT NOT NULL DEFAULT '{}', + queryable_fields TEXT NOT NULL DEFAULT '[]', + settings TEXT NOT NULL DEFAULT '{}', + plugin_id TEXT, + source TEXT NOT NULL DEFAULT 'code' CHECK (source IN ('code', 'plugin', 'system')), + schema_version INTEGER NOT NULL DEFAULT 1, + is_system INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_auth INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE INDEX IF NOT EXISTS idx_document_types_plugin ON document_types(plugin_id); +CREATE INDEX IF NOT EXISTS idx_document_types_active ON document_types(is_active); + +-- Documents: canonical document rows and historical versions. +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + root_id TEXT NOT NULL, + type_id TEXT NOT NULL REFERENCES document_types(id), + type_version INTEGER NOT NULL DEFAULT 1, + + version_of_id TEXT REFERENCES documents(id), + version_number INTEGER NOT NULL DEFAULT 1, + + is_current_draft INTEGER NOT NULL DEFAULT 1, + is_published INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')), + + parent_root_id TEXT NOT NULL DEFAULT '', + slug TEXT, + path TEXT, + title TEXT, + zone TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + visible INTEGER NOT NULL DEFAULT 1, + + published_at INTEGER, + scheduled_at INTEGER, + expires_at INTEGER, + deleted_at INTEGER, + + tenant_id TEXT NOT NULL DEFAULT 'default', + locale TEXT NOT NULL DEFAULT 'default', + translation_group_id TEXT NOT NULL DEFAULT '', + + data TEXT NOT NULL DEFAULT '{}', + metadata TEXT NOT NULL DEFAULT '{}', + + owner_id TEXT, + created_by TEXT, + updated_by TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Queryable scalar fields (VIRTUAL generated columns) and their q_* filter indexes +-- are AUTO-GENERATED at runtime from each document type's queryableFields config — +-- see DocumentTypeRegistry.register() -> ensureScalarSchema() (document-scalar-schema.ts). +-- Do not hand-add q_* columns/indexes here; declare the field in the type instead. + +-- Revision chain +CREATE INDEX IF NOT EXISTS idx_documents_root ON documents(root_id, version_number DESC); + +-- List / lifecycle +CREATE INDEX IF NOT EXISTS idx_documents_published ON documents(tenant_id, type_id, locale, is_published) + WHERE is_published = 1 AND deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_documents_drafts ON documents(tenant_id, type_id, status, is_current_draft) + WHERE is_current_draft = 1; +CREATE INDEX IF NOT EXISTS idx_documents_parent ON documents(tenant_id, parent_root_id, sort_order, is_published); +CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(tenant_id, path); +CREATE INDEX IF NOT EXISTS idx_documents_translation ON documents(translation_group_id, locale); +CREATE INDEX IF NOT EXISTS idx_documents_deleted ON documents(deleted_at); +CREATE INDEX IF NOT EXISTS idx_documents_scheduled ON documents(scheduled_at) WHERE scheduled_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_documents_expires ON documents(expires_at) WHERE expires_at IS NOT NULL; + +-- Stable keyset/cursor pagination for published lists +CREATE INDEX IF NOT EXISTS idx_documents_published_cursor + ON documents(tenant_id, type_id, updated_at DESC, id DESC) + WHERE is_published = 1 AND deleted_at IS NULL; + +-- (q_* generated-column filter indexes are auto-created at runtime — see note above.) + +-- Partial unique indexes: the hard concurrency guarantees for draft/publish invariants. +CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_one_current_draft + ON documents(root_id) WHERE is_current_draft = 1; +CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_one_published + ON documents(root_id) WHERE is_published = 1; +CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_unique_version + ON documents(root_id, version_number); +CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_unique_slug + ON documents(tenant_id, locale, type_id, parent_root_id, slug) + WHERE is_current_draft = 1 AND deleted_at IS NULL AND slug IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_one_translation_per_locale + ON documents(tenant_id, translation_group_id, locale) + WHERE is_current_draft = 1 AND translation_group_id <> ''; + +-- Document references: typed document-to-document edges. +CREATE TABLE IF NOT EXISTS document_references ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + from_root_id TEXT NOT NULL, + from_document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + ordinal INTEGER NOT NULL DEFAULT 0, + to_root_id TEXT NOT NULL, + ref_strength TEXT NOT NULL DEFAULT 'weak' CHECK (ref_strength IN ('strong', 'weak')), + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE INDEX IF NOT EXISTS idx_docref_to ON document_references(tenant_id, to_root_id); +CREATE INDEX IF NOT EXISTS idx_docref_from ON document_references(from_document_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_docref_unique + ON document_references(from_document_id, field_name, ordinal); + +-- Document facets: indexed rows for multi-valued scalar fields (e.g. tags arrays). +CREATE TABLE IF NOT EXISTS document_facets ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + root_id TEXT NOT NULL, + type_id TEXT NOT NULL, + field_name TEXT NOT NULL, + ordinal INTEGER NOT NULL DEFAULT 0, + value_text TEXT, + value_number REAL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE INDEX IF NOT EXISTS idx_facets_lookup ON document_facets(tenant_id, type_id, field_name, value_text); +CREATE INDEX IF NOT EXISTS idx_facets_doc ON document_facets(document_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_facets_unique + ON document_facets(document_id, field_name, ordinal); + +-- Document permissions: per-document ACL overrides. +CREATE TABLE IF NOT EXISTS document_permissions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + root_id TEXT NOT NULL, + principal_type TEXT NOT NULL CHECK (principal_type IN ('user', 'role', 'group', 'public', 'token')), + principal_id TEXT NOT NULL, + permission TEXT NOT NULL CHECK (permission IN ('read', 'create', 'update', 'delete', 'publish', 'manage')), + effect TEXT NOT NULL DEFAULT 'allow' CHECK (effect IN ('allow', 'deny')), + inherited INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + created_by TEXT +); + +CREATE INDEX IF NOT EXISTS idx_document_permissions_root ON document_permissions(tenant_id, root_id); +CREATE INDEX IF NOT EXISTS idx_document_permissions_principal + ON document_permissions(tenant_id, principal_type, principal_id, permission); +CREATE UNIQUE INDEX IF NOT EXISTS idx_document_permissions_unique + ON document_permissions(root_id, principal_type, principal_id, permission); diff --git a/demo-app/package.json b/demo-app/package.json new file mode 100644 index 000000000..51537fba0 --- /dev/null +++ b/demo-app/package.json @@ -0,0 +1,38 @@ +{ + "name": "demo-app", + "version": "0.1.0", + "private": true, + "description": "SonicJS public demo site (demo.sonicjs.com) — always runs latest main, data reset every promotion + every 2h", + "type": "module", + "scripts": { + "dev": "npm run dev:build && npm run dev:watch", + "dev:build": "cd ../packages/core && npm run build", + "dev:watch": "npm run dev:core & npx wait-on ../packages/core/dist/index.js && PORT=$(($(echo -n \"$(basename $(cd .. && pwd))\" | cksum | cut -d' ' -f1) % 1000 + 9000)) && echo \"Demo dev server: http://localhost:$PORT\" && wrangler dev --port $PORT --local 2>&1 | grep -v '\\[wrangler:inf\\]'", + "dev:core": "cd ../packages/core && npm run dev", + "build": "echo 'Worker build is handled by wrangler during deployment'", + "deploy": "wrangler deploy", + "db:migrate": "wrangler d1 migrations apply DB --remote", + "db:migrate:local": "wrangler d1 migrations apply DB --local", + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "seed:demo": "tsx scripts/seed-demo.ts", + "setup:db": "bash ../my-sonicjs-app/scripts/setup-worktree-db.sh" + }, + "dependencies": { + "@sonicjs-cms/core": "file:../packages/core" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250620.0", + "@types/node": "^20.19.1", + "hono": "^4.12.26", + "tsx": "^4.19.2", + "typescript": "^5.8.3", + "vitest": "^4.1.9", + "wrangler": "^4.65.0", + "zod": "^3.25.67" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/demo-app/scripts/seed-demo.ts b/demo-app/scripts/seed-demo.ts new file mode 100644 index 000000000..f75662d6d --- /dev/null +++ b/demo-app/scripts/seed-demo.ts @@ -0,0 +1,51 @@ +/** + * Local demo seed — dev parity with POST /__demo/reseed. + * + * Uses wrangler's platform proxy to get the local D1 + R2 bindings, then runs + * the exact same `runReseed` the deploy workflow and cron use. Idempotent + * (full wipe + rebuild). + * + * Run from demo-app/: + * npm run seed:demo + * # or: npx tsx scripts/seed-demo.ts + * + * Requires the local DB to be migrated first (npm run db:migrate:local). + */ + +import { getPlatformProxy } from 'wrangler' +import { runReseed, type DemoEnv } from '../src/plugins/demo-seed/reseed' + +async function main() { + const { env, dispose } = await getPlatformProxy() + const demoEnv = env as unknown as DemoEnv + + if (!demoEnv.DB) { + console.error('DB binding not found. Run `npm run db:migrate:local` and check wrangler.toml.') + await dispose() + process.exit(1) + } + if (!demoEnv.MEDIA_BUCKET) { + console.error('MEDIA_BUCKET binding not found. Check wrangler.toml.') + await dispose() + process.exit(1) + } + + try { + console.log('Reseeding demo data (full wipe + rebuild)...') + const summary = await runReseed(demoEnv) + console.log('Done:', summary) + } catch (error) { + console.error('Seed failed:', error) + await dispose() + process.exit(1) + } + + await dispose() +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error('Seed failed:', error) + process.exit(1) + }) diff --git a/demo-app/src/collections/blog-posts.collection.ts b/demo-app/src/collections/blog-posts.collection.ts new file mode 100644 index 000000000..686e631fe --- /dev/null +++ b/demo-app/src/collections/blog-posts.collection.ts @@ -0,0 +1,56 @@ +/** + * Demo — Blog Posts collection + * + * Code-defined (no DB table). Registered via registerCollections() in src/index.ts; + * core auto-registers a `document_type` row at bootstrap and exposes CRUD at + * /admin/content/blog_post. Seeded by the demo-seed plugin (runReseed). + */ + +import type { CollectionConfig } from '@sonicjs-cms/core' + +export default { + name: 'blog_post', + displayName: 'Blog Posts', + slug: 'blog-posts', + description: 'Articles and announcements shown on the demo site', + icon: '📝', + + schema: { + type: 'object', + properties: { + title: { type: 'string', title: 'Title', required: true, maxLength: 200 }, + slug: { type: 'slug', title: 'URL Slug', required: true, maxLength: 200 }, + excerpt: { type: 'textarea', title: 'Excerpt', maxLength: 300 }, + content: { type: 'richtext', title: 'Content', required: true }, + author: { type: 'string', title: 'Author', required: true, maxLength: 100 }, + heroImage: { type: 'media', title: 'Hero Image', description: 'References a media_asset document' }, + category: { + type: 'select', + title: 'Category', + enum: ['announcement', 'tutorial', 'product', 'engineering'], + enumLabels: ['Announcement', 'Tutorial', 'Product', 'Engineering'], + default: 'announcement', + }, + publishedAt: { type: 'datetime', title: 'Published Date' }, + featured: { type: 'boolean', title: 'Featured', default: false }, + }, + required: ['title', 'slug', 'content', 'author'], + }, + + listFields: ['title', 'author', 'category', 'featured', 'publishedAt'], + searchFields: ['title', 'excerpt', 'content', 'author'], + defaultSort: 'createdAt', + defaultSortOrder: 'desc', + + managed: true, + isActive: true, + + access: { + public: ['read'], + }, + + cache: { + enabled: true, + ttl: 5, + }, +} satisfies CollectionConfig diff --git a/demo-app/src/collections/faqs.collection.ts b/demo-app/src/collections/faqs.collection.ts new file mode 100644 index 000000000..77fb6cea1 --- /dev/null +++ b/demo-app/src/collections/faqs.collection.ts @@ -0,0 +1,50 @@ +/** + * Demo — FAQs collection + * + * Frequently asked questions. Code-defined; CRUD at /admin/content/faq. + * Seeded by the demo-seed plugin (runReseed). + */ + +import type { CollectionConfig } from '@sonicjs-cms/core' + +export default { + name: 'faq', + displayName: 'FAQs', + slug: 'faqs', + description: 'Frequently asked questions', + icon: '❓', + + schema: { + type: 'object', + properties: { + question: { type: 'string', title: 'Question', required: true, maxLength: 300 }, + answer: { type: 'richtext', title: 'Answer', required: true }, + category: { + type: 'select', + title: 'Category', + enum: ['general', 'billing', 'technical', 'account'], + enumLabels: ['General', 'Billing', 'Technical', 'Account'], + default: 'general', + }, + order: { type: 'number', title: 'Sort Order', default: 0 }, + }, + required: ['question', 'answer'], + }, + + listFields: ['question', 'category', 'order'], + searchFields: ['question', 'answer'], + defaultSort: 'createdAt', + defaultSortOrder: 'asc', + + managed: true, + isActive: true, + + access: { + public: ['read'], + }, + + cache: { + enabled: true, + ttl: 5, + }, +} satisfies CollectionConfig diff --git a/demo-app/src/collections/pages.collection.ts b/demo-app/src/collections/pages.collection.ts new file mode 100644 index 000000000..4f1ac3249 --- /dev/null +++ b/demo-app/src/collections/pages.collection.ts @@ -0,0 +1,46 @@ +/** + * Demo — Pages collection + * + * Static marketing pages (Home, About, Contact). Code-defined; CRUD at + * /admin/content/page. Seeded by the demo-seed plugin (runReseed). + */ + +import type { CollectionConfig } from '@sonicjs-cms/core' + +export default { + name: 'page', + displayName: 'Pages', + slug: 'pages', + description: 'Static marketing pages', + icon: '📄', + + schema: { + type: 'object', + properties: { + title: { type: 'string', title: 'Title', required: true, maxLength: 200 }, + slug: { type: 'slug', title: 'URL Slug', required: true, maxLength: 200 }, + body: { type: 'richtext', title: 'Body', required: true }, + heroImage: { type: 'media', title: 'Hero Image' }, + showInNav: { type: 'boolean', title: 'Show in Navigation', default: true }, + navOrder: { type: 'number', title: 'Nav Order', default: 0 }, + }, + required: ['title', 'slug', 'body'], + }, + + listFields: ['title', 'showInNav', 'navOrder'], + searchFields: ['title', 'body'], + defaultSort: 'createdAt', + defaultSortOrder: 'asc', + + managed: true, + isActive: true, + + access: { + public: ['read'], + }, + + cache: { + enabled: true, + ttl: 5, + }, +} satisfies CollectionConfig diff --git a/demo-app/src/collections/testimonials.collection.ts b/demo-app/src/collections/testimonials.collection.ts new file mode 100644 index 000000000..6c2c69524 --- /dev/null +++ b/demo-app/src/collections/testimonials.collection.ts @@ -0,0 +1,47 @@ +/** + * Demo — Testimonials collection + * + * Customer quotes shown on the demo site. Code-defined; CRUD at + * /admin/content/testimonial. Seeded by the demo-seed plugin (runReseed). + */ + +import type { CollectionConfig } from '@sonicjs-cms/core' + +export default { + name: 'testimonial', + displayName: 'Testimonials', + slug: 'testimonials', + description: 'Customer quotes and reviews', + icon: '💬', + + schema: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name', required: true, maxLength: 100 }, + role: { type: 'string', title: 'Role / Title', maxLength: 100 }, + company: { type: 'string', title: 'Company', maxLength: 100 }, + quote: { type: 'textarea', title: 'Quote', required: true, maxLength: 500 }, + avatar: { type: 'media', title: 'Avatar Image' }, + rating: { type: 'number', title: 'Rating (1–5)', min: 1, max: 5, default: 5 }, + featured: { type: 'boolean', title: 'Featured', default: false }, + }, + required: ['name', 'quote'], + }, + + listFields: ['name', 'company', 'rating', 'featured'], + searchFields: ['name', 'company', 'quote'], + defaultSort: 'createdAt', + defaultSortOrder: 'desc', + + managed: true, + isActive: true, + + access: { + public: ['read'], + }, + + cache: { + enabled: true, + ttl: 5, + }, +} satisfies CollectionConfig diff --git a/demo-app/src/index.ts b/demo-app/src/index.ts new file mode 100644 index 000000000..c82da6fc6 --- /dev/null +++ b/demo-app/src/index.ts @@ -0,0 +1,153 @@ +/** + * SonicJS Demo App — demo.sonicjs.com + * + * Public demo site. Always runs the latest `main` (built from the workspace core) + * and resets its data on every promotion + every 2 hours (see demoSeedPlugin). + * + * Phase 1: workspace scaffold — boots core, exports fetch + scheduled. + * Phases 2/3 wire demo collections, the demo-login prefill plugin, and the + * demo-seed (reseed route + 2h cron) plugin. + */ + +import type { SonicJSConfig } from '@sonicjs-cms/core'; +import { + SONICJS_VERSION, + collectCronSchedules, + createScheduledHandler, + createSonicJSApp, + emailReconciliationPlugin, + getHookSystem, + mediaPlugin, + registerCollections, + renderLoginPage, +} from '@sonicjs-cms/core'; + +// Code-defined demo collections. +import blogPostsCollection from './collections/blog-posts.collection'; +import pagesCollection from './collections/pages.collection'; +import testimonialsCollection from './collections/testimonials.collection'; +import faqsCollection from './collections/faqs.collection'; + +// Demo plugins. +import { demoLoginPlugin } from './plugins/demo-login'; +import { demoSeedPlugin } from './plugins/demo-seed'; + +// Register collections so they appear in the admin UI and auto-register a +// document_type at bootstrap. +registerCollections([ + blogPostsCollection, + pagesCollection, + testimonialsCollection, + faqsCollection, +]); + +// definePlugin() returns DefinedPlugin; SonicJSConfig.plugins.register is typed +// Plugin[]. The two diverge only in route element typing — a known core type-def +// gap (my-sonicjs-app hits the same mismatch). DefinedPlugin satisfies the +// runtime contract, so cast here to keep this app's type-check clean. +const demoPlugins = [mediaPlugin, demoLoginPlugin, demoSeedPlugin] as unknown as NonNullable['register']>; + +const config: SonicJSConfig = { + plugins: { + register: demoPlugins, + disableAll: false, + }, +}; + +const app = createSonicJSApp(config); + +// All plugins that declare crons, for the scheduled handler. +// Core crons (emailReconciliationPlugin) are wired automatically by createSonicJSApp. +const allCronPlugins = [emailReconciliationPlugin, ...(config.plugins?.register ?? [])]; + +const schedules = collectCronSchedules(allCronPlugins); +if (schedules.length > 0) { + console.log('[cron] Declared schedules:', schedules.join(', ')); +} + +// Browser cache TTL (max-age) and edge/shared cache TTL (s-maxage). s-maxage +// bounds how long Cloudflare's edge serves a stale copy after a login-template +// deploy — Cloudflare honors s-maxage for edge retention independent of the +// zone's Browser-Cache-TTL override — so no manual cache purge is needed on +// deploy. High enough that misses stay rare; low enough that a template change +// propagates within ~10 min. +const LOGIN_BROWSER_TTL = 300; // 5 min (browser) +const LOGIN_EDGE_TTL = 600; // 10 min (Cloudflare edge) + +// Pre-render the login form ONCE per isolate. The no-query login page is fully +// static for the demo: `demoLoginActive` is always true (the demo-login plugin +// is re-asserted active on every reseed) and `version` is a build-time constant. +// Rendering here — instead of letting the request flow through the Hono app — +// bypasses bootstrap (8+ cold D1 queries), plugin wiring, the Better Auth +// session lookup, and the handler's own demo-login D1 query. That serial chain +// is what makes the cold first load ~10s; a static string return is ~0ms. +// +// SECURITY (reviewed): this constant is built from renderLoginPage alone, so it +// can NEVER carry a Set-Cookie header. We must never feed app.fetch's live +// response into the cache — an authenticated admin hitting /auth/login can make +// Better Auth emit `Set-Cookie: session_token`, and caching that under a shared, +// URL-keyed entry with `Cache-Control: public` is a cross-user session-replay +// hazard. Only this cookie-free static constant is ever cached/served here. +const LOGIN_HTML = renderLoginPage({ version: SONICJS_VERSION }, true); + +/** Immutable headers for the static login response — public-cacheable, no cookies. */ +function loginHeaders(): Headers { + const h = new Headers(); + h.set('Content-Type', 'text/html; charset=UTF-8'); + h.set('Cache-Control', `public, max-age=${LOGIN_BROWSER_TTL}, s-maxage=${LOGIN_EDGE_TTL}`); + h.set('X-Content-Type-Options', 'nosniff'); + return h; +} + +/** + * Worker entry. Short-circuits GET/HEAD /auth/login (no query string) with a + * static, edge-cacheable login form — no D1, no bootstrap, no Set-Cookie. + * Every other request (including ?error=/?message=/?redirect= login variants, + * which need dynamic content) flows through the full SonicJS app. + */ +async function fetch( + request: Request, + env: Record, + ctx: ExecutionContext, +): Promise { + const url = new URL(request.url); + const isStaticLogin = + (request.method === 'GET' || request.method === 'HEAD') && + url.pathname === '/auth/login' && + url.search === ''; + + if (!isStaticLogin) return app.fetch(request, env, ctx); + + // Edge cache: serve a previously-stored copy without re-rendering. Safe to + // share across all visitors — the entry is the cookie-free static form. + // The cache key is version-scoped (`__v`) so a new deploy uses a fresh key and + // never serves a stale login template; old entries age out via max-age. This + // synthetic URL is only ever a cache key, never a real route. + const cache = caches.default; + const cacheKey = new Request( + `${url.origin}/auth/login?__v=${encodeURIComponent(SONICJS_VERSION)}`, + { method: 'GET' }, + ); + const cached = await cache.match(cacheKey); + if (cached) { + return request.method === 'HEAD' + ? new Response(null, { status: cached.status, headers: cached.headers }) + : cached; + } + + const stored = new Response(LOGIN_HTML, { status: 200, headers: loginHeaders() }); + ctx.waitUntil(cache.put(cacheKey, stored.clone())); + + return request.method === 'HEAD' + ? new Response(null, { status: 200, headers: loginHeaders() }) + : stored; +} + +export default { + fetch, + scheduled: createScheduledHandler({ + plugins: allCronPlugins, + getHooks: getHookSystem, + boot: app.boot, + }), +}; diff --git a/demo-app/src/plugins/demo-login/index.ts b/demo-app/src/plugins/demo-login/index.ts new file mode 100644 index 000000000..c9add5956 --- /dev/null +++ b/demo-app/src/plugins/demo-login/index.ts @@ -0,0 +1,59 @@ +/** + * Demo Login plugin + * + * Brings back credential autofill on the login page. The capability already + * lives in core: `renderLoginPage(data, demoLoginActive)` renders a "Demo Mode" + * notice + prefills admin@sonicjs.com / sonicjs! when `demoLoginActive` is true, + * and `routes/auth.ts` computes that flag from the active state of the plugin + * whose id is `demo-login-prefill` (the id MUST match the gate). + * + * This plugin's only job is to register itself as an ACTIVE plugin in the DB so + * the gate flips on. It is hard-gated to ENVIRONMENT === 'demo' so copying it + * into a real install never auto-enables credential prefill. + */ + +import { definePlugin, PluginServiceClass as PluginService } from '@sonicjs-cms/core' +import type { D1Database } from '@cloudflare/workers-types' + +/** MUST equal the id queried by routes/auth.ts to gate the login prefill. */ +export const DEMO_LOGIN_PLUGIN_ID = 'demo-login-prefill' + +/** + * Idempotently ensure the demo-login plugin row exists and is active. + * Shared so the demo-seed reseed path can re-assert it after a wipe. + */ +export async function ensureDemoLoginActive(db: D1Database): Promise { + const svc = new PluginService(db) + await svc.ensurePlugin(DEMO_LOGIN_PLUGIN_ID, { + displayName: 'Demo Login', + description: 'Prefills the login form with demo credentials (admin@sonicjs.com / sonicjs!).', + author: 'SonicJS', + version: '1.0.0', + }) + // ensurePlugin writes status:'active' on first install; activate again to stay + // idempotent if a prior run left it inactive. + await svc.activatePlugin(DEMO_LOGIN_PLUGIN_ID).catch(() => {}) +} + +export const demoLoginPlugin = definePlugin({ + id: DEMO_LOGIN_PLUGIN_ID, + name: 'Demo Login', + version: '1.0.0', + description: 'Prefills the login form with demo credentials for easy site demonstration.', + sonicjsVersionRange: '^3.0.0', + author: { name: 'SonicJS' }, + + async onBoot(ctx) { + // Defense in depth: only ever activate credential prefill on the demo site. + if (ctx.env?.ENVIRONMENT !== 'demo') return + const db = ctx.env?.DB as D1Database | undefined + if (!db) return + try { + await ensureDemoLoginActive(db) + } catch (e) { + console.warn('[demo-login] Could not activate demo login prefill:', e) + } + }, +}) + +export default demoLoginPlugin diff --git a/demo-app/src/plugins/demo-seed/__tests__/reseed.sqlite.test.ts b/demo-app/src/plugins/demo-seed/__tests__/reseed.sqlite.test.ts new file mode 100644 index 000000000..2a71c97fb --- /dev/null +++ b/demo-app/src/plugins/demo-seed/__tests__/reseed.sqlite.test.ts @@ -0,0 +1,172 @@ +// @ts-nocheck +/** + * Real-SQLite integration coverage for runReseed (R10). + * + * Unlike a pure-mock test, this executes the ACTUAL wipe + reseed SQL through + * DocumentsService / MediaDocumentService against a better-sqlite3 D1 shim and + * an in-memory R2 stub. It is the only thing that can verify: + * - the batch derived-row + documents wipe actually removes everything, + * - ensureTypes' partial-column INSERT is valid against the real schema, + * - the media insert computes q_media_* generated columns, + * - content + media are published-on-create, + * - a second run is a true reset (no accumulation), and + * - the ENVIRONMENT=demo path writes the exact plugin row routes/auth.ts gates on. + * + * Reuses core's real-DB harness (applies migrations 0001 + 0002, FK OFF to + * mirror D1). Run with: npm run build:core && npm test --workspace=demo-app. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { createTestD1 } from '../../../../../packages/core/src/__tests__/utils/d1-sqlite' +import { runReseed } from '../reseed' +import { SEED_COLLECTIONS } from '../../../seed/content' +import { DEMO_IMAGES, MEDIA_FOLDER } from '../../../seed/assets/images' +import { DEMO_LOGIN_PLUGIN_ID } from '../../demo-login' + +// Mirrors MediaDocumentService.MEDIA_QUERYABLE (the q_media_* columns createFromUpload +// projects). Bootstrap adds these at runtime via ensureDocumentGeneratedColumns; the +// harness ships only the base `documents` schema, so add them for media_asset here. +const MEDIA_QUERYABLE = [ + { name: 'mimeType', kind: 'scalar', type: 'text', column: 'q_media_mime' }, + { name: 'folder', kind: 'scalar', type: 'text', column: 'q_media_folder' }, + { name: 'size', kind: 'scalar', type: 'integer', column: 'q_media_size' }, + { name: 'tags', kind: 'facet', type: 'text' }, +] + +// Minimal in-memory R2 bucket — just the surface runReseed touches (put/list/delete). +function createR2Stub() { + const store = new Map() + return { + async put(key, body, opts) { + store.set(key, { body, httpMetadata: opts?.httpMetadata }) + return { key } + }, + async list(opts = {}) { + const prefix = opts.prefix ?? '' + const objects = [...store.keys()] + .filter((k) => k.startsWith(prefix)) + .map((key) => ({ key, size: store.get(key).body.byteLength })) + return { objects, truncated: false, cursor: undefined, delimitedPrefixes: [] } + }, + async delete(keys) { + for (const k of Array.isArray(keys) ? keys : [keys]) store.delete(k) + }, + _store: store, + } +} + +const EXPECTED_CONTENT = SEED_COLLECTIONS.reduce((n, c) => n + c.items.length, 0) +const EXPECTED_MEDIA = DEMO_IMAGES.length +const CONTENT_TYPES = SEED_COLLECTIONS.map((c) => c.typeId) + +const makeEnv = (db, bucket, environment) => ({ DB: db, MEDIA_BUCKET: bucket, ENVIRONMENT: environment }) + +const countCurrent = (db, typeId) => + db.raw + .prepare(`SELECT COUNT(*) n FROM documents WHERE type_id = ? AND tenant_id = 'default' AND is_current_draft = 1`) + .get(typeId).n + +describe('runReseed — real SQLite', () => { + let db + let bucket + + beforeEach(async () => { + db = createTestD1() + await db.applyScalarSchema('media_asset', MEDIA_QUERYABLE) + bucket = createR2Stub() + }) + afterEach(() => db.close()) + + it('seeds content + media into an empty DB and returns an accurate summary', async () => { + const summary = await runReseed(makeEnv(db, bucket)) + + expect(summary.wiped).toBe(0) + expect(summary.created).toBe(EXPECTED_CONTENT) + expect(summary.media).toBe(EXPECTED_MEDIA) + expect(summary.ms).toBeGreaterThanOrEqual(0) + + // Each collection's items exist and are published-on-create. + for (const c of SEED_COLLECTIONS) { + expect(countCurrent(db, c.typeId)).toBe(c.items.length) + const published = db.raw + .prepare(`SELECT COUNT(*) n FROM documents WHERE type_id = ? AND is_published = 1`) + .get(c.typeId).n + expect(published).toBe(c.items.length) + } + + // Media documents registered + bytes uploaded under the demo-seed/ prefix. + expect(countCurrent(db, 'media_asset')).toBe(EXPECTED_MEDIA) + expect(bucket._store.size).toBe(EXPECTED_MEDIA) + for (const key of bucket._store.keys()) { + expect(key.startsWith(`${MEDIA_FOLDER}/`)).toBe(true) + } + + // q_media_* generated columns computed from the JSON payload. + const m = db.raw + .prepare(`SELECT q_media_mime mime, q_media_folder folder FROM documents WHERE type_id = 'media_asset' LIMIT 1`) + .get() + expect(m.mime).toBe('image/svg+xml') + expect(m.folder).toBe(MEDIA_FOLDER) + }) + + it('ensureTypes upserts every seed document type (partial-column INSERT is schema-valid)', async () => { + await runReseed(makeEnv(db, bucket)) + const ids = db.raw + .prepare(`SELECT id FROM document_types ORDER BY id`) + .all() + .map((r) => r.id) + expect(ids).toEqual(expect.arrayContaining([...CONTENT_TYPES, 'media_asset'])) + }) + + it('is a full reset: a second run wipes the prior dataset (incl. visitor edits) and rebuilds 1:1', async () => { + await runReseed(makeEnv(db, bucket)) + + // Simulate visitor edits: a stray document + a stray R2 object under the seed prefix. + db.raw + .prepare( + `INSERT INTO documents ( + id, root_id, type_id, version_number, is_current_draft, is_published, status, + parent_root_id, slug, title, tenant_id, locale, translation_group_id, + data, metadata, created_at, updated_at + ) VALUES ('stray','stray','blog_post',1,1,1,'published','','stray','Stray', + 'default','default','','{}','{}',1,1)`, + ) + .run() + await bucket.put(`${MEDIA_FOLDER}/stray.svg`, new Uint8Array([1])) + + const before = db.raw.prepare(`SELECT COUNT(*) n FROM documents WHERE tenant_id = 'default'`).get().n + const summary = await runReseed(makeEnv(db, bucket)) + + expect(summary.wiped).toBe(before) // wiped everything that was present, stray included + expect(summary.created).toBe(EXPECTED_CONTENT) + + // Final content equals a single seed — no duplication, stray gone. + const content = CONTENT_TYPES.reduce((n, t) => n + countCurrent(db, t), 0) + expect(content).toBe(EXPECTED_CONTENT) + expect(countCurrent(db, 'media_asset')).toBe(EXPECTED_MEDIA) + + // Stray R2 object purged; only the seed media remains. + expect(bucket._store.size).toBe(EXPECTED_MEDIA) + }) + + it('activates the demo-login prefill only when ENVIRONMENT=demo (matches the auth.ts gate query)', async () => { + // Exactly the query routes/auth.ts runs to decide demoLoginActive. + const gate = () => + db.raw + .prepare( + `SELECT 1 FROM documents + WHERE type_id = 'plugin' AND slug = ? AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + AND json_extract(data, '$.status') = 'active' + LIMIT 1`, + ) + .get(DEMO_LOGIN_PLUGIN_ID) + + // Non-demo environment: prefill stays off (no plugin row written). + await runReseed(makeEnv(db, bucket, 'staging')) + expect(gate()).toBeUndefined() + + // Demo environment: the plugin row exists and is active — gate flips on. + await runReseed(makeEnv(db, bucket, 'demo')) + expect(gate()).toBeDefined() + }) +}) diff --git a/demo-app/src/plugins/demo-seed/index.ts b/demo-app/src/plugins/demo-seed/index.ts new file mode 100644 index 000000000..10c4f4060 --- /dev/null +++ b/demo-app/src/plugins/demo-seed/index.ts @@ -0,0 +1,81 @@ +/** + * Demo Seed plugin + * + * Owns the demo's data-reset machinery. Two triggers, one shared `runReseed`: + * 1. POST /__demo/reseed — called by the deploy workflow after each promotion + * to main. Hard-gated: ENVIRONMENT must be 'demo' AND a matching + * `Authorization: Bearer ` header. + * 2. Cron (every 2 hours, schedule in DEMO_RESEED_CRON) — so visitor edits never + * persist longer than ~2h even between deploys. Requires `[triggers] crons` + * in wrangler.toml. + * + * Both paths are env-gated to 'demo' so this plugin can never wipe a real install. + */ + +import { definePlugin } from '@sonicjs-cms/core' +import { runReseed, type DemoEnv } from './reseed' + +export const DEMO_RESEED_CRON = '0 */2 * * *' +const RESEED_HOOK_FAMILY = 'demo-reseed' + +export const demoSeedPlugin = definePlugin({ + id: 'demo-seed', + name: 'Demo Seed', + version: '1.0.0', + description: 'Resets the demo dataset on deploy and every 2 hours.', + sonicjsVersionRange: '^3.0.0', + author: { name: 'SonicJS' }, + + crons: [{ schedule: DEMO_RESEED_CRON, hookFamily: RESEED_HOOK_FAMILY }], + + // Synchronous route registration. `/__demo/*` is top-level (not under /api or + // /admin), so it dodges both catch-all routers. + register(app) { + app.post('/__demo/reseed', async (c: any) => { + const env = c.env as DemoEnv + + // Gate 1: demo environment only. + if (env.ENVIRONMENT !== 'demo') { + return c.json({ error: 'Reseed is only available in the demo environment.' }, 403) + } + + // Gate 2: bearer token. Refuse if no token is configured. + const token = env.DEMO_SEED_TOKEN + const provided = c.req.header('authorization') ?? '' + if (!token || provided !== `Bearer ${token}`) { + return c.json({ error: 'Unauthorized.' }, 401) + } + + try { + const summary = await runReseed(env) + console.log('[demo-seed] Reseed via HTTP complete:', summary) + return c.json({ ok: true, ...summary }) + } catch (e) { + console.error('[demo-seed] Reseed failed:', e) + return c.json({ error: 'Reseed failed.', details: e instanceof Error ? e.message : String(e) }, 500) + } + }) + }, + + async onCronTick(event, ctx) { + if (event.hookFamily !== RESEED_HOOK_FAMILY) return + const env = ctx.env as DemoEnv | undefined + if (!env?.DB) { + console.warn('[demo-seed] No DB binding in cron env — skipping.') + return + } + // Defense in depth: never wipe a non-demo install from the cron path. + if (env.ENVIRONMENT !== 'demo') { + console.warn('[demo-seed] ENVIRONMENT is not "demo" — skipping cron reseed.') + return + } + try { + const summary = await runReseed(env) + console.log('[demo-seed] Reseed via cron complete:', summary) + } catch (e) { + console.error('[demo-seed] Cron reseed failed:', e) + } + }, +}) + +export default demoSeedPlugin diff --git a/demo-app/src/plugins/demo-seed/reseed.ts b/demo-app/src/plugins/demo-seed/reseed.ts new file mode 100644 index 000000000..1fa033526 --- /dev/null +++ b/demo-app/src/plugins/demo-seed/reseed.ts @@ -0,0 +1,228 @@ +/** + * Demo reseed — the single source of truth for "reset the demo to a known state". + * + * Called by BOTH triggers in the demo-seed plugin: + * - POST /__demo/reseed (deploy workflow, after each promotion to main) + * - the 2-hour cron (so visitor edits never persist longer than ~2h) + * + * Full wipe semantics: every document for tenant `default` (content, media, + * plugin rows, email logs) plus its derived facet/reference/permission rows is + * deleted, then the demo content + media + demo-login activation are rebuilt. + * document_types and Better-Auth users are NOT documents, so they survive + * (admin@sonicjs.com is seeded once by the deploy workflow via /auth/seed-admin). + * + * All writes go through DocumentsService / MediaDocumentService (R1/R4) — no + * hand-written document SQL except the explicit derived-row wipe and the + * defensive document_types upsert. + */ + +import type { D1Database, R2Bucket } from '@cloudflare/workers-types' +import { DocumentsService, MediaDocumentService } from '@sonicjs-cms/core' + +import { BLOG_HERO_IMAGES, DEMO_IMAGES, MEDIA_FOLDER } from '../../seed/assets/images' +import { SEED_COLLECTIONS } from '../../seed/content' +import { ensureDemoLoginActive } from '../demo-login' + +const TENANT = 'default' + +/** Worker bindings the reseed needs. */ +export interface DemoEnv { + DB: D1Database + MEDIA_BUCKET: R2Bucket + ENVIRONMENT?: string + DEMO_SEED_TOKEN?: string +} + +export interface ReseedSummary { + wiped: number + created: number + media: number + ms: number +} + +/** document_types the seed writes into. Upserted defensively so the reseed works + * even on the cron path or a fresh DB where bootstrap hasn't registered them. + * `source` must satisfy the document_types CHECK ('code'|'plugin'|'system'); the + * real bootstrap registers both collection types and media_asset as 'system' + * (autoRegisterCollectionDocumentTypes / bootstrapDocumentTypes), so match that — + * 'user' is not a legal source and INSERT OR IGNORE would silently drop the row. */ +const SEED_TYPES: Array<{ id: string; displayName: string; source: 'code' | 'plugin' | 'system' }> = [ + { id: 'blog_post', displayName: 'Blog Posts', source: 'system' }, + { id: 'page', displayName: 'Pages', source: 'system' }, + { id: 'testimonial', displayName: 'Testimonials', source: 'system' }, + { id: 'faq', displayName: 'FAQs', source: 'system' }, + { id: 'media_asset', displayName: 'Media Asset', source: 'system' }, + { id: 'rbac_user_roles', displayName: 'RBAC User Roles', source: 'system' }, +] + +const TYPE_SETTINGS = JSON.stringify({ + baseGrants: { + public: ['read'], + admin: ['read', 'create', 'update', 'delete', 'publish', 'manage'], + }, +}) + +/** Delete every R2 object under a prefix (paginated). */ +async function purgePrefix(bucket: R2Bucket, prefix: string): Promise { + let cursor: string | undefined + do { + const listed = await bucket.list(cursor ? { prefix, cursor } : { prefix }) + if (listed.objects.length > 0) { + await bucket.delete(listed.objects.map((o) => o.key)) + } + cursor = listed.truncated ? listed.cursor : undefined + } while (cursor) +} + +/** INSERT OR IGNORE each seed document_type so the FK on documents.type_id holds. */ +async function ensureTypes(db: D1Database): Promise { + const stmts = SEED_TYPES.map((t) => + db + .prepare( + `INSERT OR IGNORE INTO document_types (id, name, display_name, source, settings, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, strftime('%s','now'), strftime('%s','now'))`, + ) + .bind(t.id, t.id, t.displayName, t.source, TYPE_SETTINGS), + ) + await db.batch(stmts) +} + +/** Full wipe + rebuild of the demo dataset. Idempotent. */ +export async function runReseed(env: DemoEnv): Promise { + const start = Date.now() + const db = env.DB + + // 1. Count then wipe all tenant documents + derived rows (R7: delete derived + // explicitly; don't rely on FK cascade). + const countRow = await db + .prepare(`SELECT COUNT(*) AS n FROM documents WHERE tenant_id = ?`) + .bind(TENANT) + .first<{ n: number }>() + const wiped = countRow?.n ?? 0 + + await db.batch([ + db + .prepare(`DELETE FROM document_facets WHERE document_id IN (SELECT id FROM documents WHERE tenant_id = ?)`) + .bind(TENANT), + db + .prepare(`DELETE FROM document_references WHERE from_document_id IN (SELECT id FROM documents WHERE tenant_id = ?)`) + .bind(TENANT), + db.prepare(`DELETE FROM document_permissions WHERE tenant_id = ?`).bind(TENANT), + db.prepare(`DELETE FROM documents WHERE tenant_id = ?`).bind(TENANT), + ]) + + // 2. Purge previously-seeded media from R2 so objects don't accumulate. + await purgePrefix(env.MEDIA_BUCKET, `${MEDIA_FOLDER}/`) + + // 3. Ensure document types exist before any insert. + await ensureTypes(db) + + // 4. Seed media: upload SVG bytes to R2 + register media_asset documents. + const mediaSvc = new MediaDocumentService(db, TENANT) + let media = 0 + for (const image of DEMO_IMAGES) { + const bytes = new TextEncoder().encode(image.svg) + await env.MEDIA_BUCKET.put(image.r2Key, bytes, { + httpMetadata: { contentType: image.mime }, + }) + await mediaSvc.createFromUpload( + { + filename: image.filename, + originalName: image.filename, + mimeType: image.mime, + size: bytes.byteLength, + width: image.width, + height: image.height, + folder: MEDIA_FOLDER, + r2Key: image.r2Key, + alt: image.alt, + }, + 'system', + ) + media++ + } + + // 5. Register static blog hero PNGs as media_asset documents. + // The R2 objects live under `blog/` (not purged above). We just re-register + // the D1 documents on every reseed so the media library stays populated. + for (const image of BLOG_HERO_IMAGES) { + try { + await mediaSvc.createFromUpload( + { + filename: image.filename, + originalName: image.filename, + mimeType: 'image/png', + size: image.size, + width: image.width, + height: image.height, + folder: 'blog', + r2Key: image.r2Key, + alt: image.alt, + }, + 'system', + ) + media++ + } catch (e) { + // Skip if R2 object not yet uploaded — wrangler upload script handles first-time setup. + console.warn(`[demo-seed] Skipped blog image ${image.r2Key} (not in R2 yet):`, e) + } + } + + // 6. Seed collection content (published-on-create). + const docs = new DocumentsService(db, { tenantId: TENANT }) + let created = 0 + for (const collection of SEED_COLLECTIONS) { + for (const item of collection.items) { + await docs.create( + { + typeId: collection.typeId, + tenantId: TENANT, + locale: 'default', + parentRootId: '', + slug: item.slug, + title: item.title, + sortOrder: 0, + visible: true, + data: item.data, + metadata: {}, + publishOnCreate: true, + }, + 'system', + ) + created++ + } + } + + // 7. Re-seed RBAC user role assignments so admin and editor can access the portal. + // These are documents (wiped in step 1), not auth_user rows (which survive). + // Without them requireRbac() returns false for every admin route. + await db.batch([ + db.prepare( + `INSERT OR REPLACE INTO documents + (id, root_id, type_id, tenant_id, slug, title, locale, + parent_root_id, version_number, is_current_draft, is_published, + data, metadata, sort_order, visible, created_at, updated_at) + VALUES ('ur-admin-001','ur-admin-001','rbac_user_roles','default','admin-user-id','admin-user-id','default', + '',1,1,1,'{"roleIds":["role-admin"]}','{}',0,1,strftime('%s','now'),strftime('%s','now'))`, + ), + db.prepare( + `INSERT OR REPLACE INTO documents + (id, root_id, type_id, tenant_id, slug, title, locale, + parent_root_id, version_number, is_current_draft, is_published, + data, metadata, sort_order, visible, created_at, updated_at) + VALUES ('ur-editor-001','ur-editor-001','rbac_user_roles','default','editor-user-eddie','editor-user-eddie','default', + '',1,1,1,'{"roleIds":["role-editor"]}','{}',0,1,strftime('%s','now'),strftime('%s','now'))`, + ), + ]) + + // 8. Re-assert the demo-login prefill (its plugin document was wiped in step 1). + if (env.ENVIRONMENT === 'demo') { + try { + await ensureDemoLoginActive(db) + } catch (e) { + console.warn('[demo-seed] Could not re-activate demo login:', e) + } + } + + return { wiped, created, media, ms: Date.now() - start } +} diff --git a/demo-app/src/seed/assets/images.ts b/demo-app/src/seed/assets/images.ts new file mode 100644 index 000000000..7c9b7d861 --- /dev/null +++ b/demo-app/src/seed/assets/images.ts @@ -0,0 +1,118 @@ +/** + * Demo seed images. + * + * Bundled as SVG strings (not binary blobs) so they stay tiny, diff-able, and + * need no base64 decoding. At reseed time each is encoded to bytes, uploaded to + * R2 (MEDIA_BUCKET) under the `demo-seed/` prefix, and registered as a + * media_asset document. Content references them by their derived public URL + * (`/files/demo-seed/`), served by the core media route. + */ + +/** R2 prefix / media folder for all demo-seeded images. Purged on every reseed. */ +export const MEDIA_FOLDER = 'demo-seed' + +const r2Key = (filename: string) => `${MEDIA_FOLDER}/${filename}` + +/** Public URL the core media route serves the R2 object at. */ +export const mediaUrl = (filename: string) => `/files/${r2Key(filename)}` + +export interface DemoImage { + filename: string + r2Key: string + svg: string + mime: string + width: number + height: number + alt: string +} + +function banner(title: string, c1: string, c2: string): string { + return ` + + + + + ${title} + SonicJS Demo +` +} + +function avatar(initials: string, bg: string): string { + return ` + + ${initials} +` +} + +function img(filename: string, svg: string, width: number, height: number, alt: string): DemoImage { + return { filename, r2Key: r2Key(filename), svg, mime: 'image/svg+xml', width, height, alt } +} + +export const DEMO_IMAGES: DemoImage[] = [ + // Blog hero banners + img('blog-getting-started.svg', banner('Getting Started', '#6366f1', '#8b5cf6'), 1200, 600, 'Getting started with SonicJS'), + img('blog-edge-first.svg', banner('Edge-First CMS', '#0ea5e9', '#2563eb'), 1200, 600, 'Edge-first content management'), + img('blog-document-model.svg', banner('The Document Model', '#10b981', '#0d9488'), 1200, 600, 'The SonicJS document model'), + // Page hero + img('page-home.svg', banner('Build at the Edge', '#f59e0b', '#ef4444'), 1200, 600, 'SonicJS home hero'), + // Testimonial avatars + img('avatar-jane.svg', avatar('JR', '#6366f1'), 240, 240, 'Avatar for Jane Rivera'), + img('avatar-marcus.svg', avatar('MC', '#0ea5e9'), 240, 240, 'Avatar for Marcus Chen'), + img('avatar-amara.svg', avatar('AO', '#10b981'), 240, 240, 'Avatar for Amara Okafor'), +] + +/** + * Static blog hero images from the www marketing site. + * Uploaded once to R2 under the `blog/` prefix (NOT purged on reseed). + * Registered as media_asset documents on every reseed (since D1 docs are wiped). + */ +export interface BlogHeroImage { + slug: string + r2Key: string + filename: string + size: number + width: number + height: number + alt: string +} + +const blogHero = (slug: string, size: number, alt: string): BlogHeroImage => ({ + slug, + r2Key: `blog/${slug}/hero.png`, + filename: `${slug}-hero.png`, + size, + width: slug === 'using-emdash-with-sonicjs' ? 1536 : 1792, + height: 1024, + alt, +}) + +export const BLOG_HERO_IMAGES: BlogHeroImage[] = [ + blogHero('best-open-source-project-for-ai-coding-practice', 4021954, 'Best open source project for AI coding practice'), + blogHero('building-a-blog-with-sonicjs', 2604450, 'Building a blog with SonicJS'), + blogHero('building-rest-api-with-sonicjs', 2576722, 'Building a REST API with SonicJS'), + blogHero('creating-custom-collections-in-sonicjs', 3088447, 'Creating custom collections in SonicJS'), + blogHero('custom-public-routes-in-sonicjs', 2577213, 'Custom public routes in SonicJS'), + blogHero('deploy-sonicjs-to-cloudflare-workers', 2144636, 'Deploy SonicJS to Cloudflare Workers'), + blogHero('directus-vs-payload-vs-sonicjs', 2872910, 'Directus vs Payload vs SonicJS'), + blogHero('directus-vs-sanity-vs-sonicjs', 3474484, 'Directus vs Sanity vs SonicJS'), + blogHero('getting-started-with-sonicjs', 2330529, 'Getting started with SonicJS'), + blogHero('nestjs-vs-sonicjs-vs-hono', 3591775, 'NestJS vs SonicJS vs Hono'), + blogHero('sanity-vs-contentful-vs-sonicjs', 3119998, 'Sanity vs Contentful vs SonicJS'), + blogHero('sonicjs-authentication-complete-guide', 2907544, 'SonicJS authentication complete guide'), + blogHero('sonicjs-caching-strategy', 2428640, 'SonicJS caching strategy'), + blogHero('sonicjs-d1-database-deep-dive', 2997264, 'SonicJS D1 database deep dive'), + blogHero('sonicjs-file-uploads-with-r2', 2354505, 'SonicJS file uploads with R2'), + blogHero('sonicjs-plugin-architecture-deep-dive', 2358528, 'SonicJS plugin architecture deep dive'), + blogHero('sonicjs-plugins-extending-your-cms', 3250996, 'SonicJS plugins — extending your CMS'), + blogHero('sonicjs-vs-ghost', 2396152, 'SonicJS vs Ghost'), + blogHero('sonicjs-vs-strapi', 2224674, 'SonicJS vs Strapi'), + blogHero('sonicjs-vs-wordpress', 3121720, 'SonicJS vs WordPress'), + blogHero('strapi-vs-contentful-vs-sonicjs', 2924986, 'Strapi vs Contentful vs SonicJS'), + blogHero('strapi-vs-directus-vs-sonicjs', 2467750, 'Strapi vs Directus vs SonicJS'), + blogHero('strapi-vs-payload-vs-sonicjs', 2805602, 'Strapi vs Payload vs SonicJS'), + blogHero('strapi-vs-sanity-vs-sonicjs', 2340446, 'Strapi vs Sanity vs SonicJS'), + blogHero('using-emdash-with-sonicjs', 1916519, 'Using em dash with SonicJS'), + blogHero('using-sonicjs-with-astro', 2093913, 'Using SonicJS with Astro'), + blogHero('using-sonicjs-with-nextjs', 2456378, 'Using SonicJS with Next.js'), + blogHero('why-edge-first-cms-is-the-future', 3183607, 'Why edge-first CMS is the future'), +] diff --git a/demo-app/src/seed/content.ts b/demo-app/src/seed/content.ts new file mode 100644 index 000000000..dcfba4a2d --- /dev/null +++ b/demo-app/src/seed/content.ts @@ -0,0 +1,214 @@ +/** + * Demo seed content. + * + * Sample documents for each demo collection. Pure data — no timestamps via + * Date.now() so the seed stays deterministic; `publishedAt` uses fixed ISO + * strings. Image fields reference the derived public URLs of the seeded + * media assets (see assets/images.ts). + */ + +import { mediaUrl } from './assets/images' + +export interface SeedItem { + slug: string + title: string + data: Record +} + +export interface SeedCollection { + typeId: string + items: SeedItem[] +} + +const blogPosts: SeedItem[] = [ + { + slug: 'getting-started-with-sonicjs', + title: 'Getting Started with SonicJS', + data: { + title: 'Getting Started with SonicJS', + slug: 'getting-started-with-sonicjs', + excerpt: 'Spin up a Cloudflare-native headless CMS in minutes.', + content: + '

SonicJS runs entirely on the Cloudflare developer platform — Workers, D1, R2, and KV. ' + + 'This guide walks you through your first collection and your first published document.

' + + '

Everything you see on this demo site is seeded automatically and resets every two hours.

', + author: 'SonicJS Team', + heroImage: mediaUrl('blog-getting-started.svg'), + category: 'tutorial', + publishedAt: '2026-01-15T09:00:00.000Z', + featured: true, + }, + }, + { + slug: 'why-edge-first-content', + title: 'Why Edge-First Content Wins', + data: { + title: 'Why Edge-First Content Wins', + slug: 'why-edge-first-content', + excerpt: 'Serving content from 300+ locations changes what is possible.', + content: + '

When your CMS lives at the edge, every read is milliseconds from the visitor. ' + + 'No origin round-trips, no cold regional databases — just fast, cached documents.

', + author: 'Marcus Chen', + heroImage: mediaUrl('blog-edge-first.svg'), + category: 'engineering', + publishedAt: '2026-02-03T14:30:00.000Z', + featured: true, + }, + }, + { + slug: 'inside-the-document-model', + title: 'Inside the SonicJS Document Model', + data: { + title: 'Inside the SonicJS Document Model', + slug: 'inside-the-document-model', + excerpt: 'One unified repository for every content type, version, and reference.', + content: + '

Instead of a table per feature, SonicJS stores everything as documents. ' + + 'Versions, references, and per-document permissions all live in one place, ' + + 'queryable through generated columns and facets.

', + author: 'Amara Okafor', + heroImage: mediaUrl('blog-document-model.svg'), + category: 'product', + publishedAt: '2026-02-20T11:15:00.000Z', + featured: false, + }, + }, +] + +const pages: SeedItem[] = [ + { + slug: 'home', + title: 'Home', + data: { + title: 'Build at the Edge', + slug: 'home', + body: + '

Build at the Edge

' + + '

SonicJS is a Cloudflare-native headless CMS. This is a live demo — feel free to ' + + 'explore the admin, edit content, and upload media. Everything resets every two hours.

', + heroImage: mediaUrl('page-home.svg'), + showInNav: true, + navOrder: 1, + }, + }, + { + slug: 'about', + title: 'About', + data: { + title: 'About This Demo', + slug: 'about', + body: + '

About This Demo

' + + '

demo.sonicjs.com always runs the latest version from the main branch. ' + + 'Log in with the prefilled demo credentials to see the full admin experience.

', + showInNav: true, + navOrder: 2, + }, + }, + { + slug: 'contact', + title: 'Contact', + data: { + title: 'Contact', + slug: 'contact', + body: + '

Contact

' + + '

Questions about SonicJS? Visit the docs or join the community on GitHub and Discord.

', + showInNav: true, + navOrder: 3, + }, + }, +] + +const testimonials: SeedItem[] = [ + { + slug: 'jane-rivera', + title: 'Jane Rivera', + data: { + name: 'Jane Rivera', + role: 'CTO', + company: 'Northwind Labs', + quote: 'SonicJS let us ship a global content platform without standing up a single server.', + avatar: mediaUrl('avatar-jane.svg'), + rating: 5, + featured: true, + }, + }, + { + slug: 'marcus-chen', + title: 'Marcus Chen', + data: { + name: 'Marcus Chen', + role: 'Lead Engineer', + company: 'Pixelforge', + quote: 'The document model is the cleanest content architecture I have worked with.', + avatar: mediaUrl('avatar-marcus.svg'), + rating: 5, + featured: true, + }, + }, + { + slug: 'amara-okafor', + title: 'Amara Okafor', + data: { + name: 'Amara Okafor', + role: 'Product Manager', + company: 'Brightwave', + quote: 'Our editors love how fast the admin feels, and our developers love the API.', + avatar: mediaUrl('avatar-amara.svg'), + rating: 4, + featured: false, + }, + }, +] + +const faqs: SeedItem[] = [ + { + slug: 'what-is-sonicjs', + title: 'What is SonicJS?', + data: { + question: 'What is SonicJS?', + answer: '

SonicJS is an open-source, Cloudflare-native headless CMS built on Hono, Workers, and D1.

', + category: 'general', + order: 1, + }, + }, + { + slug: 'how-much-does-it-cost', + title: 'How much does it cost?', + data: { + question: 'How much does it cost?', + answer: '

SonicJS is free and open source. You only pay for the Cloudflare resources you use.

', + category: 'billing', + order: 2, + }, + }, + { + slug: 'what-database-does-it-use', + title: 'What database does it use?', + data: { + question: 'What database does it use?', + answer: '

Cloudflare D1 (SQLite) with a unified document repository for all content.

', + category: 'technical', + order: 3, + }, + }, + { + slug: 'are-the-demo-credentials-real', + title: 'Are the demo credentials real?', + data: { + question: 'Are the demo credentials real?', + answer: '

Yes — this demo prefills admin@sonicjs.com / sonicjs!. All data resets every two hours.

', + category: 'account', + order: 4, + }, + }, +] + +export const SEED_COLLECTIONS: SeedCollection[] = [ + { typeId: 'blog_post', items: blogPosts }, + { typeId: 'page', items: pages }, + { typeId: 'testimonial', items: testimonials }, + { typeId: 'faq', items: faqs }, +] diff --git a/demo-app/tsconfig.json b/demo-app/tsconfig.json new file mode 100644 index 000000000..fe5a17f67 --- /dev/null +++ b/demo-app/tsconfig.json @@ -0,0 +1,49 @@ +{ + "compilerOptions": { + // Language and Environment + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + + // Emit + "noEmit": true, + "sourceMap": true, + + // Type Checking + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + + // Module Resolution + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + + // Cloudflare Workers Types + "types": ["@cloudflare/workers-types", "@types/node"], + + // Skip type checking for node_modules + "skipLibCheck": true, + + // Project Structure + "rootDir": ".", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "src/**/__tests__/**", + "src/**/*.test.ts" + ] +} diff --git a/demo-app/wrangler.toml b/demo-app/wrangler.toml new file mode 100644 index 000000000..5be0b4e85 --- /dev/null +++ b/demo-app/wrangler.toml @@ -0,0 +1,68 @@ +name = "sonicjs-demo" +main = "src/index.ts" +compatibility_date = "2025-05-05" +compatibility_flags = ["nodejs_compat"] + +# Cloudflare account (same account as the core worker + sonicjs.com zone) +account_id = "f9d6328dc3115e621758a741dda3d5c4" + +workers_dev = true + +# Custom domain — demo.sonicjs.com. Zone DNS must point at this worker. +# (Same CF account as account_id above, so custom_domain binding is valid.) +routes = [ + { pattern = "demo.sonicjs.com", custom_domain = true } +] + +# D1 Database — dedicated to the demo. Provision once: +# wrangler d1 create sonicjs-demo +# then paste the returned database_id below. +[[d1_databases]] +binding = "DB" +database_name = "sonicjs-demo" +database_id = "dc87c94a-14e2-4bc1-a77c-ab382191e7a9" +migrations_dir = "./migrations" + +# R2 Bucket for demo media. Provision once: +# wrangler r2 bucket create sonicjs-demo-media +[[r2_buckets]] +binding = "MEDIA_BUCKET" +bucket_name = "sonicjs-demo-media" + +# KV Cache — dedicated namespace. Provision once: +# wrangler kv namespace create sonicjs-demo-cache +# then paste the returned id below. +[[kv_namespaces]] +binding = "CACHE_KV" +id = "91de57b1e6af4cef8bb6a2786179bc14" + +# Cloudflare Email Service binding (send_email) +[[send_email]] +name = "EMAIL" + +# Reseed cron — every 2 hours. Wipes + reseeds demo data so visitor edits +# never persist longer than ~2h. Handler: demoSeedPlugin.onCronTick → runReseed. +[triggers] +crons = ["0 */2 * * *"] + +# Environment variables +[vars] +# ENVIRONMENT must be "demo" — the reseed route/cron are hard-gated to this value +# so they can never wipe a non-demo install. +ENVIRONMENT = "demo" +BETTER_AUTH_URL = "https://demo.sonicjs.com" +CORS_ORIGINS = "https://demo.sonicjs.com" +DEFAULT_FROM_EMAIL = "demo@sonicjs.com" + +# Secrets (set via `wrangler secret put`, NOT here): +# BETTER_AUTH_SECRET — required, >= 16 chars (openssl rand -hex 32) +# DEMO_SEED_TOKEN — bearer token the deploy workflow uses to call POST /__demo/reseed + +# Smart Placement — paid plan feature that co-locates the Worker with D1 to +# minimize latency on the bootstrap D1 round-trips that drive first-load time. +[placement] +mode = "smart" + +# Observability +[observability] +enabled = true diff --git a/docs/ai/plans/demo-app-plan.md b/docs/ai/plans/demo-app-plan.md new file mode 100644 index 000000000..989357654 --- /dev/null +++ b/docs/ai/plans/demo-app-plan.md @@ -0,0 +1,217 @@ +# Demo App Plan — `demo.sonicjs.com` + +**Goal:** A new in-repo Worker app that always runs the latest `main`, ships a few +example collections + sample content, prefills demo login credentials, and +**resets/reseeds its data on every promotion to main** so visitor edits never +persist past the next release. + +**Branch:** `lane711/demo-app-seed-plan` · **Base:** `origin/main` · **PRs:** `gh pr create --base main` + +--- + +## 1. What already exists (no rebuild needed) + +| Capability | Where | Status | +|---|---|---| +| Credential autofill on login | `packages/core/src/templates/pages/auth-login.template.ts:139` (renders prefill JS) + `routes/auth.ts:84` (gate) | **Live** — gated on a `plugins` row `id='demo-login-prefill' AND status='active'` | +| Old hook-based demo plugin | `packages/core/src/plugins/core-plugins/demo-login/` (`demoLoginPlugin`, id `demo-login-plugin`) | **Dead** — relies on `template:render`/`page:before-render` hooks the login route never emits, AND id mismatches the gate (`demo-login-prefill`). Do not revive the hooks. | +| App skeleton to copy | `my-sonicjs-app/` (`src/index.ts`, `wrangler.toml`, `scripts/`, collections) | reference | +| Collection pattern | `my-sonicjs-app/src/collections/blog-posts.collection.ts` (`CollectionConfig`, `registerCollections([...])`) | reference | +| Doc write path | `DocumentsService.create/publish` (R1/R4-compliant) | reuse for seeding | +| Deploy-on-main CI model | `.github/workflows/deploy-www.yml` (push→main, paths filter, wrangler) | template for demo workflow | + +**Key insight:** "bring back the demo plugin" = ensure the `plugins` row +`demo-login-prefill` / `active` exists in the demo DB. The template gate does the +rest. We will register a real, minimal plugin whose id matches the gate so it's +discoverable in the admin plugin list (not just a naked seed row). + +--- + +## 2. Architecture decision + +**New workspace `demo-app/`** (sibling of `my-sonicjs-app/`), added to root +`package.json` `workspaces`. It depends on the **workspace** core +(`"@sonicjs-cms/core": "file:../packages/core"`), so a deploy built from `main` +HEAD always runs the latest core — that is the "always update on each release" +guarantee (we redeploy on every push to main rather than chasing npm). + +Rejected alternatives: +- Reusing `my-sonicjs-app` for demo → couples dev sandbox to a public site; different DB/secrets/reseed policy. No. +- Pinning `@sonicjs-cms/core@latest` from npm → lags publish; demo would trail main. No. + +--- + +## 3. Deliverables + +### 3.1 `demo-app/` workspace +``` +demo-app/ + package.json # name "demo-app", deps: file:../packages/core; scripts: deploy, seed:demo, setup:db + wrangler.toml # name=sonicjs-demo, custom_domain demo.sonicjs.com, own DB/R2/KV bindings + tsconfig.json + migrations/ # byte-identical copies of packages/core/migrations (R9) — 0001, 0002 + src/ + index.ts # createSonicJSApp + registerCollections([...demo]) + demoSeedPlugin + demo-login plugin + scheduled handler + collections/ + blog-posts.collection.ts + pages.collection.ts + testimonials.collection.ts + faqs.collection.ts + plugins/ + demo-seed/ + index.ts # demoSeedPlugin: POST /__demo/reseed route + cron(0 */2 * * *) + onCronTick → runReseed + reseed.ts # runReseed(env): wipe-all → reseed collections → seed media → admin → demo-login row + demo-login/ + index.ts # plugin id 'demo-login-prefill' (matches auth.ts gate); activation upserts active row + seed/ + content.ts # sample docs per collection (titles/bodies) + assets/ # bundled sample images as base64 TS modules + scripts/ + seed-demo.ts # local seed (getPlatformProxy) — dev parity, calls runReseed +``` + +### 3.2 Example collections (code-only, `registerCollections`) +A few, telling a CMS story: +- **Blog Posts** (`blog_post`) — reuse existing schema; 3–4 posts (mix published/draft). +- **Pages** (`page`) — Home / About / Contact; 3 published. +- **Testimonials** (`testimonial`) — name, role, quote, avatar?; 3–4 published. +- **FAQs** (`faq`) — question, answer, category; 4–5 published. + +All `managed: true`, `access.public: ['read']` (non-PII → safe per R8/checklist §5). +`autoRegisterCollectionDocumentTypes` registers their `document_type` rows at boot. + +### 3.3 Demo-login activation +- Add `demo-login` plugin registered in `demo-app/src/index.ts` config with **id `demo-login-prefill`** (matches the gate). Its `onActivate`/seed ensures the `plugins` row is `active`. +- Reseed step also upserts the `plugins` row (`id=demo-login-prefill`, `status=active`) defensively. +- Cleanup (separate small PR, optional): delete the dead `core-plugins/demo-login` hook plugin or fix its id; out of scope for v1. + +### 3.4 Reseed-on-promotion + 2-hour cron ← core requirement +Reseed logic factored into one shared `runReseed(env): Promise` used by +**both** an HTTP endpoint (CI calls after deploy) **and** a cron (every 2h), +shipped as `demoSeedPlugin` mounted only in the demo app. + +**`runReseed(env)` — FULL WIPE (Q2 resolved → wipe ALL):** + 1. Wipe **all** content for tenant `default`: `DELETE` from `document_facets`, + `document_references`, `document_permissions`, then `documents` + (R7 — delete derived rows explicitly, don't trust cascade). Not type-scoped — + a pure demo, so nuke everything and rebuild. + 2. Purge the R2 media bucket prefix used by the demo (list + delete), so reseed + media doesn't accumulate across runs. + 3. Reseed each demo collection via `DocumentsService.create` + `.publish` + (correct versioning/facets/q_* — R1/R4/R6; never hand-write doc SQL — R4). + 4. **Seed media (images now):** upload bundled sample images to `MEDIA_BUCKET`, + create `media_asset` documents via `MediaDocumentService.createFromUpload` + (`MediaUploadMeta`: filename/mimeType/size/width/height/folder/r2Key/alt). + Blog posts + testimonials reference these images (avatar / hero). + 5. Ensure admin user (`admin@sonicjs.com` / `sonicjs!`) — idempotent. + 6. Upsert `plugins` row `demo-login-prefill` = active. + 7. Return `{ wiped, created, media, ms }`. + +**HTTP trigger:** `POST /__demo/reseed`, requires `Authorization: Bearer +${DEMO_SEED_TOKEN}`. **Hard-gated:** refuses unless `env.ENVIRONMENT === 'demo'` +(prevents accidental wipe if the plugin ever lands in a real install). + +**Cron trigger (every 2 hours):** plugin declares +`crons: [{ schedule: '0 */2 * * *', hookFamily: 'demo-reseed' }]`; `onCronTick` +branches on `hookFamily` and calls `runReseed(ctx.env)`. Also env-gated to demo. +Requires `[triggers] crons = ["0 */2 * * *"]` in `wrangler.toml` (cron declared +in code is inert without the wrangler trigger — see email-reconciliation plugin). +This guarantees visitor edits reset at most 2h later even between deploys. + +**Sample images:** bundle a handful of small images committed under +`demo-app/src/seed/assets/` as base64 modules (Workers can't read FS at runtime), +decode to `Uint8Array`, `MEDIA_BUCKET.put(r2Key, bytes)`. Keep them small (a few +KB each) to stay well under Worker memory + bundle limits. + +Why an endpoint/cron through `DocumentsService` (not a SQL file or +`wrangler d1 execute`): document-model writes (versions, facets, refs, generated +`q_*` cols, derived `version_number`) are too error-prone to hand-author in SQL +(R5/R6). Running in the real Worker runtime also exercises D1's real +100-param/100-col limits and the R2 binding. + +### 3.5 CI: `.github/workflows/deploy-demo.yml` +``` +on: + push: { branches: [main], paths: ['demo-app/**','packages/core/**','.github/workflows/deploy-demo.yml'] } + workflow_dispatch: +steps: + - checkout, setup-node 20, npm ci + - npm run build:core + - wrangler deploy (cwd demo-app) # CLOUDFLARE_API_TOKEN/ACCOUNT_ID secrets + - wrangler d1 migrations apply --remote + - wait-for-health: poll https://demo.sonicjs.com/health until 200 + - curl -XPOST https://demo.sonicjs.com/__demo/reseed -H "Authorization: Bearer $DEMO_SEED_TOKEN" +``` +`paths` includes `packages/core/**` so any core change on main redeploys+reseeds +the demo → "always latest". + +### 3.6 Infra (one-time, manual or noted for operator) +- D1 `sonicjs-demo`, R2 `sonicjs-demo-media`, KV cache namespace — create + paste ids into `wrangler.toml`. +- Custom domain `demo.sonicjs.com` — **confirmed same CF account** (`f9d6328…`), so `routes = [{ pattern = "demo.sonicjs.com", custom_domain = true }]` works once zone DNS points in. +- `[triggers] crons = ["0 */2 * * *"]` — activates the 2-hour reseed cron. +- Secrets: `BETTER_AUTH_SECRET`, `DEMO_SEED_TOKEN` via `wrangler secret put` (and `DEMO_SEED_TOKEN` as a GH Actions secret). +- `[vars] ENVIRONMENT = "demo"`. + +### 3.7 Tests +- **Integration (real SQLite, R10):** `demo-app` or core `*.integration.test.ts` for the reseed plugin — asserts wipe→reseed leaves exactly the expected published doc counts, demo-login row active, second run is idempotent. +- **E2E (R11, ≥68):** `tests/e2e/68-demo-login-prefill.spec.ts` — login page shows "Demo Mode" notice + prefilled email/password when the plugin row is active. (Write spec; CI runs it — do not run locally.) + +--- + +## 4. Phased execution (after approval) + +1. **Scaffold workspace** — `demo-app/` dir, `package.json`, `wrangler.toml` (with `[triggers] crons` + custom_domain + `ENVIRONMENT=demo`), `tsconfig`, migrations copy, root workspaces + scripts. Type-check. ← **starting now** +2. **Collections + index** — 4 collection configs, `registerCollections`, demo-login plugin (id `demo-login-prefill`) wired, scheduled handler exported. +3. **Reseed plugin** — `demoSeedPlugin`: `runReseed` (wipe-all + reseed + media + admin + demo-login row), HTTP route, 2h cron `onCronTick`, env gate. Sample-content + image-asset modules. +4. **Local seed script** — `scripts/seed-demo.ts` (getPlatformProxy → runReseed) for dev parity + `setup:db`. +5. **Tests** — reseed integration test (wipe→reseed counts, media docs, demo-login active, idempotent) + E2E 68 spec. +6. **CI workflow** — `deploy-demo.yml` (deploy → migrate → health-poll → curl reseed). +7. **Infra doc** — `demo-app/README.md` with the one-time CF setup checklist (§3.6). +8. **Review section** appended here. + +Commit implementation + tests together (CLAUDE.md E2E workflow). + +--- + +## 5. Risks / open questions + +**Resolved:** Q1 collections = Blog/Pages/Testimonials/FAQs ✓ · Q2 reset = **wipe ALL** tenant `default` ✓ · R1 zone = **same CF account** ✓ · Media = **seed real images now** ✓ · Cron = **reseed every 2h** ✓ + +- **R-2 Reseed timing:** new deployment must be live before the post-deploy curl. Mitigated by health poll + retry; flag if `wrangler deploy` returns before propagation. +- **R-3 Demo-login id mismatch / dead plugin:** v1 uses id `demo-login-prefill` (matches `auth.ts` gate) + seeds the active row. The old `core-plugins/demo-login` hook plugin stays dead; deleting it is a later cleanup PR. +- **R-5 Plugins-table availability — CORRECTED:** the legacy `plugins` table does **not** exist on greenfield (only `0001`+`0002`; no `CREATE TABLE plugins` anywhere). The `auth.ts` demo-login gate was querying a non-existent table → always caught → prefill permanently dead. Fixed by repointing the gate at the **document-model** plugin status (`documents` `type_id='plugin'`, `data.status='active'`), which is what `PluginService.ensurePlugin`/`activatePlugin` actually write. Demo-login activation is now a public-API call — no vestigial table, doc-model aligned. +- **R-6 Cron + HTTP both wipe:** both go through one env-gated `runReseed`; double-fire (deploy curl + cron overlap) is harmless (idempotent rebuild) but log a run id to spot overlap. +- **R-7 Image assets in bundle:** base64 modules inflate the Worker bundle; keep total seed images tiny (target < ~100 KB combined) or move to R2-seeded-once + skip purge for a fixed set. Decide during phase 3. + +--- + +## 6. Review + +All 7 phases implemented. Core + demo-app type-check clean. + +### Shipped +- **Phase 1 — workspace scaffold:** `demo-app/` workspace (`package.json`, `wrangler.toml` with custom_domain + `[triggers] crons` + `ENVIRONMENT=demo`, `tsconfig`, `.gitignore`, `.dev.vars.example`), migrations `0001`/`0002` copied byte-identical, root `workspaces` + `dev:demo`/`deploy:demo` scripts. `index.ts` boots core + exports `fetch`/`scheduled`. +- **Phase 2 — collections + demo-login:** 4 collections (`blog_post`, `page`, `testimonial`, `faq`) with `media` fields; `demo-login` plugin (id `demo-login-prefill`, env-gated self-activation via `PluginService`); **core gate fix** in `routes/auth.ts` (doc-model query — see R-5). +- **Phase 3 — reseed plugin:** `runReseed(env)` (wipe-all → R2 purge → ensure types → seed media to R2 + `media_asset` docs → seed 4 collections → re-activate demo-login). `demo-seed` plugin: token+env-gated `POST /__demo/reseed` + `0 */2 * * *` cron `onCronTick`. Both share `runReseed`. +- **Phase 4 — local seed:** `scripts/seed-demo.ts` (getPlatformProxy → `runReseed`), `seed:demo` script. +- **Phase 5 — tests:** `tests/e2e/82-demo-seed.spec.ts` (prefill, seeded API, reseed auth; skipped unless `DEMO_BASE_URL`). **Real-DB integration test** `demo-app/src/plugins/demo-seed/__tests__/reseed.sqlite.test.ts` (R10) — reuses core's better-sqlite3 D1 shim + an in-memory R2 stub; asserts summary counts, published-on-create, `q_media_*` generated cols, full-reset idempotency, and the exact `routes/auth.ts` demo-login gate query. Wired into CI (`npm test --workspace=demo-app` before deploy). +- **Phase 6 — CI:** `.github/workflows/deploy-demo.yml` (push-to-main → build core → type-check → migrate → deploy → seed-admin → reseed, with health poll). +- **Phase 7 — docs:** `demo-app/README.md` (operator setup + how-it-works). + +### Key design decisions +- **Images = SVG strings** (not binary base64) — tiny, diff-able, no decode; encoded to bytes → R2 → `media_asset` docs; referenced via `/files/demo-seed/`. Resolves R-7 (combined assets ≈ few KB). +- **Wipe = all tenant-`default` documents** (incl. plugin docs), then re-assert demo-login. `document_types` + auth users survive. +- **Media path** reuses `MediaDocumentService.createFromUpload` — newly re-exported from core (`MediaDocumentService` + `MediaUploadMeta`) so the demo (a real consumer) doesn't duplicate media type/queryable logic. +- **`runReseed` self-ensures document types** (INSERT OR IGNORE) so it works on the cron path and in the local script without relying on bootstrap order. + +### Core changes (outside demo-app) +- `packages/core/src/routes/auth.ts` — demo-login gate → document-model query (fixes a latent dead gate; affects all installs but only flips true where a `demo-login-prefill` plugin doc is active). +- `packages/core/src/index.ts` — re-export `MediaDocumentService` + `MediaUploadMeta`. + +### Post-review hardening +- **Real-DB integration test for `runReseed` shipped** (R10) — and it caught a real bug: `ensureTypes` seeded the 4 collection `document_types` with `source:'user'`, which violates the `document_types` CHECK (`'code'|'plugin'|'system'`); `INSERT OR IGNORE` then silently dropped those rows. Masked in production (bootstrap registers the types as `'system'` first) but broke the fresh-DB fallback (local `npm run seed:demo`). Fixed to `source:'system'`, matching `autoRegisterCollectionDocumentTypes`. + +### Follow-ups / not done +- **Public demo front-end** — this ships the CMS + seeded data + admin; a themed public site rendering the collections is separate scope. +- Deleting the dead `core-plugins/demo-login` hook plugin (R-3) — later cleanup. +- Operator must fill `REPLACE_WITH_*` ids in `wrangler.toml` + set secrets before first deploy. diff --git a/package.json b/package.json index 994092c6c..d1ad2124c 100644 --- a/package.json +++ b/package.json @@ -6,18 +6,21 @@ "workspaces": [ "packages/*", "www", - "my-sonicjs-app" + "my-sonicjs-app", + "demo-app" ], "scripts": { "predev": "test -d node_modules/@sonicjs-cms || npm install", "dev": "npm run dev --workspace=my-sonicjs-app", "dev:www": "npm run dev --workspace=www", + "dev:demo": "npm run dev --workspace=demo-app", "build": "npm run build:core && npm run build --workspace=my-sonicjs-app", "build:www": "npm run build --workspace=www", "plugins:generate": "node packages/scripts/generate-plugin-registry.mjs", "build:core": "npm run plugins:generate && npm run build --workspace=@sonicjs-cms/core", "deploy": "npm run deploy --workspace=my-sonicjs-app", "deploy:www": "npm run deploy --workspace=www", + "deploy:demo": "npm run build:core && npm run deploy --workspace=demo-app", "deploy:stats": "npm run deploy:production --workspace=sonicjs-stats", "test": "npm run test --workspace=@sonicjs-cms/core", "test:cov": "npm run test:cov --workspace=@sonicjs-cms/core", diff --git a/packages/core/src/__tests__/routes/admin-content-docbacked.integration.test.ts b/packages/core/src/__tests__/routes/admin-content-docbacked.integration.test.ts index ea920a820..99a8fbfa2 100644 --- a/packages/core/src/__tests__/routes/admin-content-docbacked.integration.test.ts +++ b/packages/core/src/__tests__/routes/admin-content-docbacked.integration.test.ts @@ -120,7 +120,8 @@ describe('admin-content Option B (document-backed blog_post) — integration', ( body: form({ _method: 'PUT', collection_id: COLL, title: 'Post updateme v2', slug: 'updateme', content: '

v2

', author: 'Ada', difficulty: 'beginner', status: 'published' }), }) expect([200, 302]).toContain(res.status) - // A new version exists and exactly one published row, now at v2. + // versioning=true on blog_post: publish() keeps the old published row as history. + // Two rows exist — v1 (old published, now a historical version) and v2 (new current draft + published). expect(db.raw.prepare("SELECT COUNT(*) n FROM documents WHERE root_id=?").get(rootId).n).toBe(2) expect(db.raw.prepare("SELECT COUNT(*) n FROM documents WHERE root_id=? AND is_published=1").get(rootId).n).toBe(1) expect(db.raw.prepare("SELECT version_number v FROM documents WHERE root_id=? AND is_published=1").get(rootId).v).toBe(2) diff --git a/packages/core/src/__tests__/routes/api-content-crud-documents.integration.test.ts b/packages/core/src/__tests__/routes/api-content-crud-documents.integration.test.ts index d186119c5..1f34fc9b1 100644 --- a/packages/core/src/__tests__/routes/api-content-crud-documents.integration.test.ts +++ b/packages/core/src/__tests__/routes/api-content-crud-documents.integration.test.ts @@ -67,6 +67,8 @@ describe('api-content-crud → documents (decommission step)', () => { const created = (await (await app.request('/api/content', json('POST', { collectionId: 'blog_post', title: 'V1', slug: 'v', status: 'published', data: {} }))).json()).data const res = await app.request(`/api/content/${created.id}`, json('PUT', { data: { body: 'v2' }, status: 'published' })) expect(res.status).toBe(200) + // versioning=true on blog_post: publish() keeps the old published row as history. + // Two rows exist — v1 (old published, now a historical version) and v2 (new current draft + published). expect(db.raw.prepare('SELECT COUNT(*) n FROM documents WHERE root_id=?').get(created.id).n).toBe(2) expect(db.raw.prepare('SELECT version_number v FROM documents WHERE root_id=? AND is_published=1').get(created.id).v).toBe(2) }) diff --git a/packages/core/src/db/migrations-bundle.ts b/packages/core/src/db/migrations-bundle.ts index 876594707..c518abb25 100644 --- a/packages/core/src/db/migrations-bundle.ts +++ b/packages/core/src/db/migrations-bundle.ts @@ -1,7 +1,7 @@ /** * AUTO-GENERATED FILE - DO NOT EDIT * Generated by: scripts/generate-migrations.ts - * Generated at: 2026-07-06T20:53:50.482Z + * Generated at: 2026-07-07T00:15:50.967Z * * This file contains all migration SQL bundled for use in Cloudflare Workers * where filesystem access is not available at runtime. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4c8dbd91d..51fb7cd4e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,10 @@ export { bootstrapDocumentTypes } from './services/document-types-seed' export { DocumentRepository } from './services/document-repository' export { DocumentsService } from './services/documents' +// Media-as-documents adapter — re-exported for apps that seed/manage media assets +export { MediaDocumentService } from './services/media-documents' +export type { MediaUploadMeta } from './services/media-documents' + // Cloudflare email provider export { CloudflareEmailProvider } from './services/email/providers/cloudflare' export type { CloudflareEmailProviderOptions, CFSendEmailBinding } from './services/email/providers/cloudflare' @@ -94,6 +98,7 @@ export type { EmailLogRow } from './services/email/types' export { emailReconciliationPlugin } from './plugins/core-plugins/email-reconciliation' export { redirectPlugin, createRedirectPlugin } from './plugins/redirect-management' export { helloWorldPlugin, createHelloWorldPlugin } from './plugins/core-plugins/hello-world-plugin' +export { mediaPlugin, createMediaPlugin } from './plugins/core-plugins/media' // ============================================================================ // Placeholders - To be populated in Phase 2 @@ -209,6 +214,8 @@ export { renderFilterBar, // Admin layout (catalyst) renderAdminLayoutCatalyst, + // Auth pages + renderLoginPage, } from './templates' export type { @@ -223,6 +230,7 @@ export type { Filter, FilterOption, AdminLayoutCatalystData, + LoginPageData, } from './templates' // Types - Week 1 (COMPLETED) diff --git a/packages/core/src/middleware/bootstrap.ts b/packages/core/src/middleware/bootstrap.ts index bf98a99cb..9c4030e22 100644 --- a/packages/core/src/middleware/bootstrap.ts +++ b/packages/core/src/middleware/bootstrap.ts @@ -21,6 +21,10 @@ type Bindings = { // Track if bootstrap has been run in this worker instance let bootstrapComplete = false; +// KV key used to persist the bootstrap-complete flag across cold starts. +// Version-scoped so a new deploy (new SONICJS_VERSION) always re-runs bootstrap. +const bootstrapKvKey = () => `_bootstrap:v${SONICJS_VERSION}` + /** * Verify security-critical environment configuration at startup. * Logs warnings in development, throws in production to prevent @@ -120,6 +124,25 @@ export function bootstrapMiddleware(config: SonicJSConfig = {}, allPlugins?: Arr const gitBranch = (c.env as any).GIT_BRANCH as string | undefined; setBranchLabel(isLocalhost && gitBranch ? gitBranch : undefined); + // KV fast-path: if a previous cold start already ran full bootstrap and + // stored the flag, skip all D1 work. Version-scoped — a new deploy clears it. + const kvStore = (c.env as any).CACHE_KV as KVNamespace | undefined; + if (kvStore) { + try { + const kvFlag = await kvStore.get(bootstrapKvKey()); + if (kvFlag === '1') { + // Still populate in-memory registry (pure JS, no D1). + try { + const configs = await loadCollectionConfigs(); + getCollectionRegistry().register(configs); + } catch { /* non-fatal */ } + bootstrapComplete = true; + console.log("[Bootstrap] Skipped via KV cache — already initialized"); + return next(); + } + } catch { /* KV unavailable — fall through to full bootstrap */ } + } + try { console.log("[Bootstrap] Starting system initialization..."); @@ -202,8 +225,14 @@ export function bootstrapMiddleware(config: SonicJSConfig = {}, allPlugins?: Arr console.log("[Bootstrap] Plugin bootstrap skipped (disableAll is true)"); } - // Mark bootstrap as complete for this worker instance + // Mark bootstrap as complete for this worker instance + persist to KV so + // future cold starts can skip the D1 work (1 KV read vs 8+ D1 queries). bootstrapComplete = true; + if (kvStore) { + try { + await kvStore.put(bootstrapKvKey(), '1', { expirationTtl: 3600 }); + } catch { /* non-fatal */ } + } console.log("[Bootstrap] System initialization completed"); // Fire project snapshot telemetry (fire-and-forget, never blocks boot) diff --git a/packages/core/src/plugins/core-plugins/media/manifest.json b/packages/core/src/plugins/core-plugins/media/manifest.json index 5c93c4d54..1bf0e7e77 100644 --- a/packages/core/src/plugins/core-plugins/media/manifest.json +++ b/packages/core/src/plugins/core-plugins/media/manifest.json @@ -60,5 +60,6 @@ }, "iconEmoji": "📸", "is_core": true, + "defaultActive": true, "defaultSettings": {} } diff --git a/packages/core/src/plugins/manifest-registry.ts b/packages/core/src/plugins/manifest-registry.ts index 944052bbe..d4f2986b6 100644 --- a/packages/core/src/plugins/manifest-registry.ts +++ b/packages/core/src/plugins/manifest-registry.ts @@ -2,7 +2,7 @@ * Plugin Registry - AUTO-GENERATED * * Generated by: packages/scripts/generate-plugin-registry.mjs - * Generated at: 2026-07-02T23:43:29.620Z + * Generated at: 2026-07-06T19:26:28.616Z * Source: All manifest.json files in src/plugins/ * * DO NOT EDIT MANUALLY - run the generator script instead. @@ -221,6 +221,7 @@ export const PLUGIN_REGISTRY: Record = { "category": "media", "iconEmoji": "📸", "is_core": true, + "defaultActive": true, "permissions": [ "manage:media", "upload:files" diff --git a/packages/core/src/routes/auth.ts b/packages/core/src/routes/auth.ts index e6ef5ee86..eead8fda0 100644 --- a/packages/core/src/routes/auth.ts +++ b/packages/core/src/routes/auth.ts @@ -93,12 +93,21 @@ authRoutes.get('/login', async (c) => { redirect: redirect && redirect.startsWith('/') ? redirect : undefined, } - // Check if demo login plugin is active + // Check if the demo-login plugin is active. Plugins are stored as documents + // (type_id='plugin') in the document repository — PluginService.activatePlugin + // sets data.status='active'. The legacy `plugins` table does not exist on + // greenfield installs, so query the document model directly. const db = c.env.DB let demoLoginActive = false try { - const plugin = await db.prepare('SELECT * FROM plugins WHERE id = ? AND status = ?') - .bind('demo-login-prefill', 'active') + const plugin = await db.prepare( + `SELECT 1 FROM documents + WHERE type_id = 'plugin' AND slug = ? AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + AND json_extract(data, '$.status') = 'active' + LIMIT 1` + ) + .bind('demo-login-prefill') .first() demoLoginActive = !!plugin } catch (error) { diff --git a/packages/core/src/templates/index.ts b/packages/core/src/templates/index.ts index 032cd7022..e2f6d02e1 100644 --- a/packages/core/src/templates/index.ts +++ b/packages/core/src/templates/index.ts @@ -40,6 +40,8 @@ export { renderLogo } from './components/logo.template' // Page templates - Admin export { renderCheckboxPage } from './pages/admin-checkboxes.template' export type { CheckboxPageData } from './pages/admin-checkboxes.template' +export { renderLoginPage } from './pages/auth-login.template' +export type { LoginPageData } from './pages/auth-login.template' export { renderFormsDocsPage } from './pages/admin-forms-docs.template' export type { FormsDocsPageData } from './pages/admin-forms-docs.template' export { renderFormsExamplesPage } from './pages/admin-forms-examples.template' diff --git a/tests/e2e/82-demo-seed.spec.ts b/tests/e2e/82-demo-seed.spec.ts new file mode 100644 index 000000000..f48a61af0 --- /dev/null +++ b/tests/e2e/82-demo-seed.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test' + +/** + * Demo app (demo.sonicjs.com) E2E. + * + * These tests run ONLY against a deployed/local demo instance — set + * DEMO_BASE_URL (e.g. https://demo.sonicjs.com or http://localhost:9xxx). They + * are skipped in the normal my-sonicjs-app CI run because they assert demo-only + * behavior (credential prefill, seeded content, the reseed endpoint). + * + * Validates: + * 1. The demo-login plugin prefills admin credentials on the login page. + * 2. The public content API serves the seeded collections. + * 3. POST /__demo/reseed is rejected without the bearer token. + */ + +const DEMO_BASE_URL = process.env.DEMO_BASE_URL + +test.describe('Demo app', () => { + test.skip(!DEMO_BASE_URL, 'Set DEMO_BASE_URL to run demo E2E against a demo instance') + + test('login page prefills demo credentials', async ({ page }) => { + await page.goto(`${DEMO_BASE_URL}/auth/login`) + + // The core renderLoginPage prefills the email/password inputs when the + // demo-login-prefill plugin is active. + const email = page.locator('input[type="email"], input[name="email"]').first() + const password = page.locator('input[type="password"], input[name="password"]').first() + + await expect(email).toHaveValue('admin@sonicjs.com') + await expect(password).toHaveValue('sonicjs!') + }) + + test('public API serves seeded blog posts', async ({ request }) => { + const res = await request.get(`${DEMO_BASE_URL}/api/blog_post`) + expect(res.ok()).toBeTruthy() + const body = await res.json() + const items = body.data ?? body.documents ?? body + expect(Array.isArray(items) ? items.length : 0).toBeGreaterThan(0) + }) + + test('reseed endpoint rejects requests without the bearer token', async ({ request }) => { + const res = await request.post(`${DEMO_BASE_URL}/__demo/reseed`) + // 401 (no/invalid token) or 403 (non-demo) — never a successful wipe. + expect([401, 403]).toContain(res.status()) + }) +}) diff --git a/tests/e2e/95-login-page-cache.spec.ts b/tests/e2e/95-login-page-cache.spec.ts new file mode 100644 index 000000000..d3dd5418c --- /dev/null +++ b/tests/e2e/95-login-page-cache.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test' + +/** + * Login page CDN-caching invariants (demo-app static short-circuit). + * + * The demo Worker serves GET /auth/login (no query string) as a static, + * pre-rendered, edge-cacheable form — bypassing bootstrap/D1/Better-Auth so it + * loads instantly. These tests pin the SECURITY + correctness invariants that + * make that safe: + * 1. The cacheable login response must NEVER carry a Set-Cookie header (a + * cached Set-Cookie would be replayed cross-user — account-takeover risk). + * 2. It must be publicly cacheable (Cache-Control: public). + * 3. Dynamic variants (?error=, ?redirect=) must NOT be served the cached + * static page — they fall through to the live handler. + * 4. Login still works end-to-end through the (non-cached) POST path. + */ +test.describe('Login page CDN caching', () => { + test('GET /auth/login is static, public-cacheable, and sets no cookie', async ({ request }) => { + const res = await request.get('/auth/login') + expect(res.status()).toBe(200) + + const headers = res.headers() + // Publicly cacheable + expect(headers['cache-control'] || '').toContain('public') + // SECURITY: the shared, cacheable login page must never set a cookie + expect(headers['set-cookie']).toBeUndefined() + + // It is the real login form + const body = await res.text() + expect(body).toContain('id="login-form"') + expect(body).toContain('/auth/login/form') + }) + + test('?error= variant falls through to the dynamic handler (not the cached static page)', async ({ request }) => { + const res = await request.get('/auth/login?error=Invalid%20credentials') + expect(res.status()).toBe(200) + const body = await res.text() + // Dynamic render surfaces the error text; the static cached page never would + expect(body).toContain('Invalid credentials') + }) + + test('login still works end-to-end through the non-cached POST path', async ({ page }) => { + await page.goto('/auth/login') + // Demo prefills admin creds; submit and expect to reach the admin area + await page.fill('input[name="email"]', 'admin@sonicjs.com') + await page.fill('input[name="password"]', 'sonicjs!') + await page.click('#login-form button[type="submit"]') + await page.waitForURL(/\/admin(\/|$)/, { timeout: 15_000 }) + expect(page.url()).toContain('/admin') + }) +})