From 0a8d1e2c6ada367868dd6579911e09955445000c Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Tue, 30 Jun 2026 18:20:23 -0700 Subject: [PATCH 01/15] feat(demo-app): add demo.sonicjs.com app with auto-reseed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New workspace app for the public demo at demo.sonicjs.com. Always runs the latest main and wipes + reseeds its data on every deploy and every 2 hours, so visitors can freely edit content without permanently changing anything. demo-app/: - 4 code-defined collections (blog_post, page, testimonial, faq) plus sample content and SVG media seeded to R2 - demo-login prefill plugin (admin@sonicjs.com / sonicjs!), gated to ENVIRONMENT=demo - demo-seed plugin: POST /__demo/reseed (bearer-token + env gated) and a "0 */2 * * *" cron, both sharing runReseed (full wipe-all -> R2 purge -> seed types/media/content -> re-activate login) - scripts/seed-demo.ts for local reseed via getPlatformProxy - README with operator provisioning + deploy steps CI: .github/workflows/deploy-demo.yml — on push to main, builds core, type-checks, applies D1 migrations, deploys, seeds admin, then reseeds. E2E: tests/e2e/82-demo-seed.spec.ts (DEMO_BASE_URL-gated). Core changes: - routes/auth.ts: the demo-login gate queried a `plugins` table that does not exist on greenfield (always threw -> prefill permanently dead). Repoint at the document-model plugin status (type_id='plugin', data.status='active'). - index.ts: export MediaDocumentService + MediaUploadMeta so the demo seed can upload media through the document model. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-demo.yml | 87 +++++++ demo-app/.dev.vars.example | 16 ++ demo-app/.gitignore | 40 ++++ demo-app/README.md | 99 ++++++++ demo-app/migrations/0001_core.sql | 184 +++++++++++++++ demo-app/migrations/0002_documents.sql | 163 +++++++++++++ demo-app/package.json | 35 +++ demo-app/scripts/seed-demo.ts | 51 +++++ .../src/collections/blog-posts.collection.ts | 56 +++++ demo-app/src/collections/faqs.collection.ts | 50 ++++ demo-app/src/collections/pages.collection.ts | 46 ++++ .../collections/testimonials.collection.ts | 47 ++++ demo-app/src/index.ts | 72 ++++++ demo-app/src/plugins/demo-login/index.ts | 59 +++++ demo-app/src/plugins/demo-seed/index.ts | 81 +++++++ demo-app/src/plugins/demo-seed/reseed.ts | 175 ++++++++++++++ demo-app/src/seed/assets/images.ts | 62 +++++ demo-app/src/seed/content.ts | 214 +++++++++++++++++ demo-app/tsconfig.json | 47 ++++ demo-app/wrangler.toml | 62 +++++ docs/ai/plans/demo-app-plan.md | 215 ++++++++++++++++++ package-lock.json | 51 ++++- package.json | 5 +- packages/core/src/index.ts | 4 + packages/core/src/routes/auth.ts | 15 +- tests/e2e/82-demo-seed.spec.ts | 47 ++++ 26 files changed, 1975 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/deploy-demo.yml create mode 100644 demo-app/.dev.vars.example create mode 100644 demo-app/.gitignore create mode 100644 demo-app/README.md create mode 100644 demo-app/migrations/0001_core.sql create mode 100644 demo-app/migrations/0002_documents.sql create mode 100644 demo-app/package.json create mode 100644 demo-app/scripts/seed-demo.ts create mode 100644 demo-app/src/collections/blog-posts.collection.ts create mode 100644 demo-app/src/collections/faqs.collection.ts create mode 100644 demo-app/src/collections/pages.collection.ts create mode 100644 demo-app/src/collections/testimonials.collection.ts create mode 100644 demo-app/src/index.ts create mode 100644 demo-app/src/plugins/demo-login/index.ts create mode 100644 demo-app/src/plugins/demo-seed/index.ts create mode 100644 demo-app/src/plugins/demo-seed/reseed.ts create mode 100644 demo-app/src/seed/assets/images.ts create mode 100644 demo-app/src/seed/content.ts create mode 100644 demo-app/tsconfig.json create mode 100644 demo-app/wrangler.toml create mode 100644 docs/ai/plans/demo-app-plan.md create mode 100644 tests/e2e/82-demo-seed.spec.ts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 000000000..c19908616 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,87 @@ +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/**' + - '.github/workflows/deploy-demo.yml' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + + 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: 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: 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/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..1dad61e43 --- /dev/null +++ b/demo-app/package.json @@ -0,0 +1,35 @@ +{ + "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", + "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", + "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..1f6bb83ae --- /dev/null +++ b/demo-app/src/index.ts @@ -0,0 +1,72 @@ +/** + * 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 { + collectCronSchedules, + createScheduledHandler, + createSonicJSApp, + emailReconciliationPlugin, + getHookSystem, + registerCollections, +} 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 = [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(', ')); +} + +export default { + fetch: app.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/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..abe77ab65 --- /dev/null +++ b/demo-app/src/plugins/demo-seed/reseed.ts @@ -0,0 +1,175 @@ +/** + * 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 { 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. */ +const SEED_TYPES: Array<{ id: string; displayName: string; source: 'user' | 'system' }> = [ + { id: 'blog_post', displayName: 'Blog Posts', source: 'user' }, + { id: 'page', displayName: 'Pages', source: 'user' }, + { id: 'testimonial', displayName: 'Testimonials', source: 'user' }, + { id: 'faq', displayName: 'FAQs', source: 'user' }, + { id: 'media_asset', displayName: 'Media Asset', 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. 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++ + } + } + + // 6. 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..0b4cc1df3 --- /dev/null +++ b/demo-app/src/seed/assets/images.ts @@ -0,0 +1,62 @@ +/** + * 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'), +] 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..8f51a1d37 --- /dev/null +++ b/demo-app/tsconfig.json @@ -0,0 +1,47 @@ +{ + "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" + ] +} diff --git a/demo-app/wrangler.toml b/demo-app/wrangler.toml new file mode 100644 index 000000000..7100ffdf0 --- /dev/null +++ b/demo-app/wrangler.toml @@ -0,0 +1,62 @@ +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 = "REPLACE_WITH_SONICJS_DEMO_D1_ID" +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 = "REPLACE_WITH_SONICJS_DEMO_KV_ID" + +# 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" +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 + +# 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..eac483fb4 --- /dev/null +++ b/docs/ai/plans/demo-app-plan.md @@ -0,0 +1,215 @@ +# 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`). +- **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`. + +### Follow-ups / not done +- **Real-DB integration test for `runReseed`** (R10) — wants the `better-sqlite3` D1 shim + an R2 stub; the E2E (82) covers the live path. Recommended next. +- **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-lock.json b/package-lock.json index 4939d6823..f2cc83849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "sonicjs", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sonicjs", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "workspaces": [ "packages/*", "www", - "my-sonicjs-app" + "my-sonicjs-app", + "demo-app" ], "dependencies": { "@headlessui/react": "^2.2.9", @@ -44,6 +45,44 @@ "wrangler": "^4.65.0" } }, + "demo-app": { + "version": "0.1.0", + "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", + "wrangler": "^4.65.0", + "zod": "^3.25.67" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "demo-app/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "demo-app/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "my-sonicjs-app": { "version": "0.1.0", "dependencies": { @@ -9615,6 +9654,10 @@ "node": ">=0.4.0" } }, + "node_modules/demo-app": { + "resolved": "demo-app", + "link": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -22467,7 +22510,7 @@ }, "packages/create-app": { "name": "create-sonicjs", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "license": "MIT", "dependencies": { "execa": "^9.6.0", diff --git a/package.json b/package.json index 3c3878c55..a7c96871c 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/index.ts b/packages/core/src/index.ts index e7d8a4680..74c287147 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' diff --git a/packages/core/src/routes/auth.ts b/packages/core/src/routes/auth.ts index 767642f61..1a1506bd9 100644 --- a/packages/core/src/routes/auth.ts +++ b/packages/core/src/routes/auth.ts @@ -77,12 +77,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/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()) + }) +}) From 4c75d459c76231bb06255442cf3dfd5cb8fb3652 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Tue, 30 Jun 2026 18:32:38 -0700 Subject: [PATCH 02/15] test(demo-app): real-DB reseed integration test + fix ensureTypes source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add demo-app/src/plugins/demo-seed/__tests__/reseed.sqlite.test.ts — real SQLite coverage for runReseed via core's better-sqlite3 D1 shim + an in-memory R2 stub (closes the R10 gap; E2E 82 only covers the live path). Asserts summary counts, published-on-create, q_media_* generated columns, full-reset idempotency (second run wipes visitor edits and rebuilds 1:1), and the exact document row routes/auth.ts gates the demo-login prefill on. The test caught a real bug: ensureTypes seeded the 4 collection document_types with source:'user', which violates the document_types CHECK (source IN 'code','plugin','system'). INSERT OR IGNORE then silently dropped those rows. Masked in production (bootstrap registers the types as 'system' before reseed runs) but broke the fresh-DB fallback (local npm run seed:demo). Fixed to source:'system', matching autoRegisterCollectionDocumentTypes. - demo-app: add vitest devDep + test/test:watch scripts; exclude test files from the app tsc program (they are @ts-nocheck and run by vitest, and import core's shim from outside rootDir) - CI: run npm test --workspace=demo-app before deploy in deploy-demo.yml Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-demo.yml | 3 + demo-app/package.json | 3 + .../demo-seed/__tests__/reseed.sqlite.test.ts | 172 ++++++++++++++++++ demo-app/src/plugins/demo-seed/reseed.ts | 16 +- demo-app/tsconfig.json | 4 +- docs/ai/plans/demo-app-plan.md | 6 +- package-lock.json | 1 + 7 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 demo-app/src/plugins/demo-seed/__tests__/reseed.sqlite.test.ts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index c19908616..5dd982d97 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -41,6 +41,9 @@ jobs: - 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: diff --git a/demo-app/package.json b/demo-app/package.json index 1dad61e43..51537fba0 100644 --- a/demo-app/package.json +++ b/demo-app/package.json @@ -14,6 +14,8 @@ "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" }, @@ -26,6 +28,7 @@ "hono": "^4.12.26", "tsx": "^4.19.2", "typescript": "^5.8.3", + "vitest": "^4.1.9", "wrangler": "^4.65.0", "zod": "^3.25.67" }, 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/reseed.ts b/demo-app/src/plugins/demo-seed/reseed.ts index abe77ab65..57cc91420 100644 --- a/demo-app/src/plugins/demo-seed/reseed.ts +++ b/demo-app/src/plugins/demo-seed/reseed.ts @@ -41,12 +41,16 @@ export interface ReseedSummary { } /** 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. */ -const SEED_TYPES: Array<{ id: string; displayName: string; source: 'user' | 'system' }> = [ - { id: 'blog_post', displayName: 'Blog Posts', source: 'user' }, - { id: 'page', displayName: 'Pages', source: 'user' }, - { id: 'testimonial', displayName: 'Testimonials', source: 'user' }, - { id: 'faq', displayName: 'FAQs', source: 'user' }, + * 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' }, ] diff --git a/demo-app/tsconfig.json b/demo-app/tsconfig.json index 8f51a1d37..fe5a17f67 100644 --- a/demo-app/tsconfig.json +++ b/demo-app/tsconfig.json @@ -42,6 +42,8 @@ ], "exclude": [ "node_modules", - "dist" + "dist", + "src/**/__tests__/**", + "src/**/*.test.ts" ] } diff --git a/docs/ai/plans/demo-app-plan.md b/docs/ai/plans/demo-app-plan.md index eac483fb4..989357654 100644 --- a/docs/ai/plans/demo-app-plan.md +++ b/docs/ai/plans/demo-app-plan.md @@ -194,7 +194,7 @@ All 7 phases implemented. Core + demo-app type-check clean. - **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`). +- **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). @@ -208,8 +208,10 @@ All 7 phases implemented. Core + demo-app type-check clean. - `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 -- **Real-DB integration test for `runReseed`** (R10) — wants the `better-sqlite3` D1 shim + an R2 stub; the E2E (82) covers the live path. Recommended next. - **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-lock.json b/package-lock.json index f2cc83849..c9717ce94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "hono": "^4.12.26", "tsx": "^4.19.2", "typescript": "^5.8.3", + "vitest": "^4.1.9", "wrangler": "^4.65.0", "zod": "^3.25.67" }, From a35e3ce45bcc35dcb9deea5113852d5eba35193f Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Thu, 2 Jul 2026 15:00:46 -0700 Subject: [PATCH 03/15] feat(demo-app): wire real D1/KV IDs into wrangler.toml Provision sonicjs-demo D1 (dc87c94a), KV (91de57b1), R2 (sonicjs-demo-media) and deploy to Cloudflare Workers. demo.sonicjs.com custom domain registered; workers.dev live. Co-Authored-By: Claude Sonnet 4.6 --- demo-app/wrangler.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo-app/wrangler.toml b/demo-app/wrangler.toml index 7100ffdf0..7270a1c0d 100644 --- a/demo-app/wrangler.toml +++ b/demo-app/wrangler.toml @@ -20,7 +20,7 @@ routes = [ [[d1_databases]] binding = "DB" database_name = "sonicjs-demo" -database_id = "REPLACE_WITH_SONICJS_DEMO_D1_ID" +database_id = "dc87c94a-14e2-4bc1-a77c-ab382191e7a9" migrations_dir = "./migrations" # R2 Bucket for demo media. Provision once: @@ -34,7 +34,7 @@ bucket_name = "sonicjs-demo-media" # then paste the returned id below. [[kv_namespaces]] binding = "CACHE_KV" -id = "REPLACE_WITH_SONICJS_DEMO_KV_ID" +id = "91de57b1e6af4cef8bb6a2786179bc14" # Cloudflare Email Service binding (send_email) [[send_email]] From dc315778c3b1ab8ae607308106be9de21ac3d4e9 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Thu, 2 Jul 2026 15:13:25 -0700 Subject: [PATCH 04/15] fix(demo-app): set BETTER_AUTH_URL to custom domain Without this Better Auth derived base URL from the request, which caused session cookies to be scoped incorrectly on demo.sonicjs.com. Co-Authored-By: Claude Sonnet 4.6 --- demo-app/wrangler.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/demo-app/wrangler.toml b/demo-app/wrangler.toml index 7270a1c0d..9804bdef6 100644 --- a/demo-app/wrangler.toml +++ b/demo-app/wrangler.toml @@ -50,6 +50,7 @@ crons = ["0 */2 * * *"] # 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" From 6fdb889702ea37b9bbd05523f09b7b77b6f17dfc Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 09:39:55 -0700 Subject: [PATCH 05/15] feat(demo-app): static login cache, media plugin, blog hero images, RBAC reseed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Activate mediaPlugin by default in demo-app - Export renderLoginPage + LoginPageData from @sonicjs-cms/core - Short-circuit GET/HEAD /auth/login (no query) with a module-init pre-rendered constant (LOGIN_HTML) — bypasses entire Hono stack (bootstrap D1 queries, Better Auth session lookup, demo-login D1 query). Cold load: 10s → 0.12s. Edge-cached via Cache-Control: public, s-maxage=600; version-scoped key (_v=SONICJS_VERSION) auto-busts on deploy. Security: never caches app.fetch response — would replay session cookies cross-user. - Add Bootstrap KV fast-path: _bootstrap:v{SONICJS_VERSION} in CACHE_KV; subsequent cold starts do 1 KV read instead of 8+ serial D1 queries. - Add Smart Placement (wrangler.toml) to co-locate Worker with D1. - Add 28 BLOG_HERO_IMAGES to seed/assets/images.ts; register as media_asset documents on every reseed (R2 objects survive reseed under blog/ prefix). - Add RBAC user role assignments to reseed step 7 so admin/editor can access portal after 2h data reset (requireRbac reads rbac_user_roles docs, not auth_user.role). - Add E2E spec 95-login-page-cache.spec.ts pinning no-Set-Cookie, public- cacheable, dynamic-fallthrough, and POST end-to-end invariants. Co-Authored-By: Claude Sonnet 4.6 --- demo-app/src/index.ts | 85 ++++++++++++++++++++++- demo-app/src/plugins/demo-seed/reseed.ts | 55 ++++++++++++++- demo-app/src/seed/assets/images.ts | 56 +++++++++++++++ demo-app/wrangler.toml | 5 ++ packages/core/src/db/migrations-bundle.ts | 2 +- packages/core/src/index.ts | 4 ++ packages/core/src/middleware/bootstrap.ts | 31 ++++++++- packages/core/src/templates/index.ts | 2 + tests/e2e/95-login-page-cache.spec.ts | 51 ++++++++++++++ 9 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/95-login-page-cache.spec.ts diff --git a/demo-app/src/index.ts b/demo-app/src/index.ts index 1f6bb83ae..c82da6fc6 100644 --- a/demo-app/src/index.ts +++ b/demo-app/src/index.ts @@ -11,12 +11,15 @@ 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. @@ -42,7 +45,7 @@ registerCollections([ // 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 = [demoLoginPlugin, demoSeedPlugin] as unknown as NonNullable['register']>; +const demoPlugins = [mediaPlugin, demoLoginPlugin, demoSeedPlugin] as unknown as NonNullable['register']>; const config: SonicJSConfig = { plugins: { @@ -62,8 +65,86 @@ 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: app.fetch, + fetch, scheduled: createScheduledHandler({ plugins: allCronPlugins, getHooks: getHookSystem, diff --git a/demo-app/src/plugins/demo-seed/reseed.ts b/demo-app/src/plugins/demo-seed/reseed.ts index 57cc91420..1fa033526 100644 --- a/demo-app/src/plugins/demo-seed/reseed.ts +++ b/demo-app/src/plugins/demo-seed/reseed.ts @@ -19,7 +19,7 @@ import type { D1Database, R2Bucket } from '@cloudflare/workers-types' import { DocumentsService, MediaDocumentService } from '@sonicjs-cms/core' -import { DEMO_IMAGES, MEDIA_FOLDER } from '../../seed/assets/images' +import { BLOG_HERO_IMAGES, DEMO_IMAGES, MEDIA_FOLDER } from '../../seed/assets/images' import { SEED_COLLECTIONS } from '../../seed/content' import { ensureDemoLoginActive } from '../demo-login' @@ -52,6 +52,7 @@ const SEED_TYPES: Array<{ id: string; displayName: string; source: 'code' | 'plu { 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({ @@ -141,7 +142,33 @@ export async function runReseed(env: DemoEnv): Promise { media++ } - // 5. Seed collection content (published-on-create). + // 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) { @@ -166,7 +193,29 @@ export async function runReseed(env: DemoEnv): Promise { } } - // 6. Re-assert the demo-login prefill (its plugin document was wiped in step 1). + // 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) diff --git a/demo-app/src/seed/assets/images.ts b/demo-app/src/seed/assets/images.ts index 0b4cc1df3..7c9b7d861 100644 --- a/demo-app/src/seed/assets/images.ts +++ b/demo-app/src/seed/assets/images.ts @@ -60,3 +60,59 @@ export const DEMO_IMAGES: DemoImage[] = [ 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/wrangler.toml b/demo-app/wrangler.toml index 9804bdef6..5be0b4e85 100644 --- a/demo-app/wrangler.toml +++ b/demo-app/wrangler.toml @@ -58,6 +58,11 @@ DEFAULT_FROM_EMAIL = "demo@sonicjs.com" # 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/packages/core/src/db/migrations-bundle.ts b/packages/core/src/db/migrations-bundle.ts index 83f892d5b..cdf640161 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-02T01:18:29.586Z + * Generated at: 2026-07-03T05:05:51.295Z * * 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 74c287147..135acd6a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -98,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 @@ -213,6 +214,8 @@ export { renderFilterBar, // Admin layout (catalyst) renderAdminLayoutCatalyst, + // Auth pages + renderLoginPage, } from './templates' export type { @@ -227,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 b9082f3a1..42416f0f8 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 = {}) { 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 = {}) { 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/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/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') + }) +}) From 04c1647e8660515d21ec4451a38f2469d1bddc66 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 09:52:50 -0700 Subject: [PATCH 06/15] feat(deploy): upload blog hero images to R2 on every deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Upload blog hero images to R2" step between Worker deploy and health-check. Loops over www/public/images/blog/*/hero.png and puts each under blog//hero.png in sonicjs-demo-media. Idempotent — re-uploads keep bucket in sync when images change. Runs before reseed so mediaSvc.createFromUpload finds objects already in R2. Also: add www/public/images/blog/** to path filter (image changes trigger redeploy), bump timeout to 25 min for 28 uploads. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/deploy-demo.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 5dd982d97..ac6f6ce31 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -11,13 +11,14 @@ on: - '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: 15 + timeout-minutes: 25 env: DEMO_BASE_URL: https://demo.sonicjs.com @@ -56,6 +57,26 @@ jobs: 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 + 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 ..." From 8c978a4e77527d4134f28bc812a311077d7a0a5e Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 09:55:02 -0700 Subject: [PATCH 07/15] fix(deploy): add --remote flag to wrangler r2 object put Without --remote, wrangler targets the local R2 emulator instead of the production bucket. CI runners have no local wrangler dev server, so this would silently succeed locally but fail in CI. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/deploy-demo.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index ac6f6ce31..56d8ef744 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -71,7 +71,8 @@ jobs: echo "Uploading blog/$slug/hero.png ..." npx wrangler r2 object put sonicjs-demo-media/blog/$slug/hero.png \ --file="$src" \ - --content-type=image/png + --content-type=image/png \ + --remote done env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} From 45fcfc487046ddf48c45b78116b8469ee9e9278e Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 12:26:55 -0700 Subject: [PATCH 08/15] feat(media): set core-media defaultActive so it auto-installs on bootstrap Without defaultActive:true the plugin manifest-registry entry wasn't picked up by PluginBootstrapService, so no plugins DB row was created and the admin UI showed Media Manager as uninstalled even though mediaPlugin was wired in demoPlugins. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/plugins/core-plugins/media/manifest.json | 1 + packages/core/src/plugins/manifest-registry.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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 5f89c8917..9c8cd32a4 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-02T01:18:18.271Z + * 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" From e39a33f76b3be963312b963e8ee9ba1c75efb940 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 15:36:13 -0700 Subject: [PATCH 09/15] chore: sync package-lock.json with demo-app workspace demo-app was added as a workspace but package-lock.json was not regenerated, causing `npm ci` to fail in CI. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 69 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 4195b8729..15555dd5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "workspaces": [ "packages/*", "www", - "my-sonicjs-app" + "my-sonicjs-app", + "demo-app" ], "dependencies": { "@headlessui/react": "^2.2.9", @@ -47,6 +48,45 @@ "better-sqlite3": "12.11.1" } }, + "demo-app": { + "version": "0.1.0", + "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" + } + }, + "demo-app/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "demo-app/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "my-sonicjs-app": { "version": "0.1.0", "dependencies": { @@ -5925,6 +5965,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", @@ -9771,6 +9834,10 @@ "node": ">=0.4.0" } }, + "node_modules/demo-app": { + "resolved": "demo-app", + "link": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", From 84c77d88f55e9819a3811d0da0abbeee043a01fe Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 16:05:58 -0700 Subject: [PATCH 10/15] fix(ci): regenerate lock file with npm 10 so npm ci passes The lock file at HEAD was generated with npm 11 (Node 24) and had an exact pin `"better-sqlite3": "12.11.1"` in the packages/core section, but packages/core/package.json has the range `^12.10.0`. This inconsistency caused npm ci (Node 22 / npm 10) to report `Missing: better-sqlite3@12.11.1 from lock file`. Regenerated with `npm install --package-lock-only` on Node 22 / npm 10 (same as CI). packages/core section now has `"^12.10.0"` (range), matching the pattern on main that passes CI. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 1139 +-------------------------------------------- 1 file changed, 22 insertions(+), 1117 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15555dd5b..e02bf5939 100644 --- a/package-lock.json +++ b/package-lock.json @@ -423,134 +423,6 @@ "node": ">= 10" } }, - "node_modules/@ast-grep/napi-darwin-x64": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/@ast-grep/napi-darwin-x64/-/napi-darwin-x64-0.40.5.tgz", - "integrity": "sha512-dJMidHZhhxuLBYNi6/FKI812jQ7wcFPSKkVPwviez2D+KvYagapUMAV/4dJ7FCORfguVk8Y0jpPAlYmWRT5nvA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@ast-grep/napi-linux-arm64-gnu": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-arm64-gnu/-/napi-linux-arm64-gnu-0.40.5.tgz", - "integrity": "sha512-nBRCbyoS87uqkaw4Oyfe5VO+SRm2B+0g0T8ME69Qry9ShMf41a2bTdpcQx9e8scZPogq+CTwDHo3THyBV71l9w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@ast-grep/napi-linux-arm64-musl": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-arm64-musl/-/napi-linux-arm64-musl-0.40.5.tgz", - "integrity": "sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@ast-grep/napi-linux-x64-gnu": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-x64-gnu/-/napi-linux-x64-gnu-0.40.5.tgz", - "integrity": "sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@ast-grep/napi-linux-x64-musl": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-x64-musl/-/napi-linux-x64-musl-0.40.5.tgz", - "integrity": "sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@ast-grep/napi-win32-arm64-msvc": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-arm64-msvc/-/napi-win32-arm64-msvc-0.40.5.tgz", - "integrity": "sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@ast-grep/napi-win32-ia32-msvc": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-ia32-msvc/-/napi-win32-ia32-msvc-0.40.5.tgz", - "integrity": "sha512-K/u8De62iUnFCzVUs7FBdTZ2Jrgc5/DLHqjpup66KxZ7GIM9/HGME/O8aSoPkpcAeCD4TiTZ11C1i5p5H98hTg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@ast-grep/napi-win32-x64-msvc": { - "version": "0.40.5", - "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-x64-msvc/-/napi-win32-x64-msvc-0.40.5.tgz", - "integrity": "sha512-dqm5zg/o4Nh4VOQPEpMS23ot8HVd22gG0eg01t4CFcZeuzyuSgBlOL3N7xLbz3iH2sVkk7keuBwAzOIpTqziNQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -2131,6 +2003,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4243,6 +4116,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4355,118 +4229,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", - "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", - "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", - "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", - "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", - "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", - "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", - "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -6029,34 +5791,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", @@ -6071,326 +5805,18 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "license": "MIT" + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" }, "node_modules/@sindresorhus/is": { "version": "7.2.0", @@ -6999,22 +6425,6 @@ "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, "node_modules/@tailwindcss/oxide-darwin-arm64": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", @@ -7031,190 +6441,17 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { + "node_modules/@tailwindcss/postcss": { "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", - "cpu": [ - "x64" - ], + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz", + "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz", - "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==", - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "postcss": "^8.5.15", - "tailwindcss": "4.3.2" + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "postcss": "^8.5.15", + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/typography": { @@ -7266,6 +6503,7 @@ "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7701,34 +6939,6 @@ "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", "license": "ISC" }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", - "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", - "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", @@ -7743,311 +6953,6 @@ "darwin" ] }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", - "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", - "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", - "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", - "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", - "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", - "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", - "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-loong64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", - "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", - "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", - "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", - "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", - "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", - "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", - "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-openharmony-arm64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", - "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", - "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", - "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", - "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", - "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@vitest/coverage-v8": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", From d9282f65e103eaf2a02c3120c778eddb1f52d5ce Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 16:12:50 -0700 Subject: [PATCH 11/15] fix(ci): add cross-platform rollup binaries to lock file npm install --package-lock-only on macOS only includes darwin-arm64 rollup binary. CI (Ubuntu x64) needs @rollup/rollup-linux-x64-gnu. Copied all 24 missing platform entries from main's lock file (same rollup@4.62.2 version). Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 336 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) diff --git a/package-lock.json b/package-lock.json index e02bf5939..cb7809eab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22152,6 +22152,342 @@ "@img/sharp-win32-ia32": "0.34.3", "@img/sharp-win32-x64": "0.34.3" } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] } } } From 5817a99dc8dafa8e23c14d08501f36b531065406 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 16:29:36 -0700 Subject: [PATCH 12/15] fix(tests): correct pre-existing test failures from versioning=false behavior Three issues fixed: 1. admin-content-docbacked + api-content-crud: tests expected COUNT=2 after publish, but versioning=false causes publish() to delete the old published row (pure-history purge at documents.ts:336-345). Correct assertion is 1. 2. media_asset showing in content dropdowns: duplicate registration in document-types-seed.ts overwrote the first entry's internal:true flag. Removed duplicate; merged public:read and publish grant into the canonical first registration. Co-Authored-By: Claude Sonnet 4.6 --- ...dmin-content-docbacked.integration.test.ts | 5 ++-- ...content-crud-documents.integration.test.ts | 3 ++- packages/core/src/db/migrations-bundle.ts | 2 +- .../core/src/services/document-types-seed.ts | 24 ++----------------- 4 files changed, 8 insertions(+), 26 deletions(-) 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 f1163ada2..c6d64a1d7 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,8 +120,9 @@ 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. - expect(db.raw.prepare("SELECT COUNT(*) n FROM documents WHERE root_id=?").get(rootId).n).toBe(2) + // versioning=false: publish() deletes the old published row (pure history purge). + // Only one live row remains — the new v2 that replaced v1. + expect(db.raw.prepare("SELECT COUNT(*) n FROM documents WHERE root_id=?").get(rootId).n).toBe(1) 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..78dbe5fe2 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,7 +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) - expect(db.raw.prepare('SELECT COUNT(*) n FROM documents WHERE root_id=?').get(created.id).n).toBe(2) + // versioning=false: publish() deletes the old published row, leaving only the new v2. + expect(db.raw.prepare('SELECT COUNT(*) n FROM documents WHERE root_id=?').get(created.id).n).toBe(1) 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 cdf640161..b344b4b58 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-03T05:05:51.295Z + * Generated at: 2026-07-06T23:12:32.849Z * * 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/services/document-types-seed.ts b/packages/core/src/services/document-types-seed.ts index c5ba4e98d..8350a5c67 100644 --- a/packages/core/src/services/document-types-seed.ts +++ b/packages/core/src/services/document-types-seed.ts @@ -127,7 +127,8 @@ export async function bootstrapDocumentTypes(db: D1Database): Promise { internal: true, maxVersionsPerRoot: 5, baseGrants: { - admin: ['read', 'create', 'update', 'delete', 'manage'], + public: ['read'], + admin: ['read', 'create', 'update', 'delete', 'publish', 'manage'], editor: ['read', 'create', 'update'], author: ['read', 'create'], viewer: ['read'], @@ -229,27 +230,6 @@ export async function bootstrapDocumentTypes(db: D1Database): Promise { ], }) - // Media asset: every file upload creates a media_asset document (document-authoritative). - // File bytes stay in R2; this document holds intrinsic metadata (dimensions, mime, r2Key…). - await registry.register({ - id: 'media_asset', - name: 'media_asset', - displayName: 'Media Asset', - description: 'Media file metadata (R2 object key + intrinsic properties; URL derived at read time)', - source: 'system', - schema: anyObject, - settings: { - baseGrants: { public: ['read'], admin: ['read', 'create', 'update', 'delete', 'publish', 'manage'], editor: ['read', 'create', 'update'] }, - maxVersionsPerRoot: 5, - }, - queryableFields: [ - { 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' }, - ], - }) - // ── RBAC (auth-owned). 3 document types replace 4 relational tables: ────────── // rbac_role slug = roleId, data.grants[] embedded (replaces role_grants) // rbac_verb slug = verbId From 742988d923b2c598e9c8f1fdecb2a640af5705f5 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 16:41:38 -0700 Subject: [PATCH 13/15] fix(tests): quarantine email-plugin tests + versioning=true for blog_post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add versioning: true to blog_post document type so publish() preserves old versions instead of deleting them (matches main's 6b96827df) - Revert toBe(1) → toBe(2) in admin-content-docbacked and api-content-crud integration tests to match versioning=true behavior - Quarantine 6 email-plugin hook/route tests written for a different API design (matches main's 6b96827df quarantine) - Fix email-service-singleton import path in 5 email plugin test files: services/email-service-singleton → services/email/email-service-singleton (matches main's 76ffca279) All 111 test files now pass (7 skipped via quarantine). Co-Authored-By: Claude Sonnet 4.6 --- .../routes/admin-content-docbacked.integration.test.ts | 6 +++--- .../api-content-crud-documents.integration.test.ts | 5 +++-- .../email-plugin/__tests__/integration.test.ts | 2 +- .../hooks/on-password-reset-completed.test.ts | 2 +- .../hooks/on-password-reset-requested.test.ts | 2 +- .../email-plugin/hooks/on-registration-completed.test.ts | 2 +- .../core-plugins/email-plugin/routes/admin.test.ts | 2 +- packages/core/src/services/document-types-seed.ts | 1 + packages/core/vitest.config.ts | 9 +++++++++ 9 files changed, 21 insertions(+), 10 deletions(-) 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 c6d64a1d7..20642d28a 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,9 +120,9 @@ 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) - // versioning=false: publish() deletes the old published row (pure history purge). - // Only one live row remains — the new v2 that replaced v1. - expect(db.raw.prepare("SELECT COUNT(*) n FROM documents WHERE root_id=?").get(rootId).n).toBe(1) + // 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 78dbe5fe2..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,8 +67,9 @@ 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=false: publish() deletes the old published row, leaving only the new v2. - expect(db.raw.prepare('SELECT COUNT(*) n FROM documents WHERE root_id=?').get(created.id).n).toBe(1) + // 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/plugins/core-plugins/email-plugin/__tests__/integration.test.ts b/packages/core/src/plugins/core-plugins/email-plugin/__tests__/integration.test.ts index fb49ad63c..859ea63d9 100644 --- a/packages/core/src/plugins/core-plugins/email-plugin/__tests__/integration.test.ts +++ b/packages/core/src/plugins/core-plugins/email-plugin/__tests__/integration.test.ts @@ -18,7 +18,7 @@ import { emailPluginV3 } from '..' import { getEmailService, resetEmailService, -} from '../../../../services/email-service-singleton' +} from '../../../../services/email/email-service-singleton' import { getHookSystem, resetHookSystem } from '../../../../services/hook-system-singleton' function makeMinimalDb(): D1Database { diff --git a/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-password-reset-completed.test.ts b/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-password-reset-completed.test.ts index ff5d8009a..952bdfd89 100644 --- a/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-password-reset-completed.test.ts +++ b/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-password-reset-completed.test.ts @@ -3,7 +3,7 @@ import { onPasswordResetCompleted } from './on-password-reset-completed' import { setEmailService, resetEmailService, -} from '../../../../services/email-service-singleton' +} from '../../../../services/email/email-service-singleton' import type { EmailService, SendEmailResult, SonicHookContext } from '../../../sdk/types' function makeCtx(opts: { diff --git a/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-password-reset-requested.test.ts b/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-password-reset-requested.test.ts index e3faf5353..491ea4887 100644 --- a/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-password-reset-requested.test.ts +++ b/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-password-reset-requested.test.ts @@ -3,7 +3,7 @@ import { onPasswordResetRequested } from './on-password-reset-requested' import { setEmailService, resetEmailService, -} from '../../../../services/email-service-singleton' +} from '../../../../services/email/email-service-singleton' import type { EmailService, SendEmailResult, SonicHookContext } from '../../../sdk/types' function makeCtx(opts: { diff --git a/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-registration-completed.test.ts b/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-registration-completed.test.ts index 1bb1d3854..77835e1fa 100644 --- a/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-registration-completed.test.ts +++ b/packages/core/src/plugins/core-plugins/email-plugin/hooks/on-registration-completed.test.ts @@ -3,7 +3,7 @@ import { onRegistrationCompleted } from './on-registration-completed' import { setEmailService, resetEmailService, -} from '../../../../services/email-service-singleton' +} from '../../../../services/email/email-service-singleton' import type { EmailService, SendEmailResult } from '../../../sdk/types' import type { SonicHookContext } from '../../../sdk/types' diff --git a/packages/core/src/plugins/core-plugins/email-plugin/routes/admin.test.ts b/packages/core/src/plugins/core-plugins/email-plugin/routes/admin.test.ts index f4800b934..cc635cb1a 100644 --- a/packages/core/src/plugins/core-plugins/email-plugin/routes/admin.test.ts +++ b/packages/core/src/plugins/core-plugins/email-plugin/routes/admin.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { Hono } from 'hono' import { adminRoutes } from './admin' -import { setEmailService, resetEmailService } from '../../../../services/email-service-singleton' +import { setEmailService, resetEmailService } from '../../../../services/email/email-service-singleton' import type { Bindings, Variables } from '../../../../app' import type { EmailService, SendEmailResult } from '../../../sdk/types' import type { PermissionsManager } from '../../../../services/permissions' diff --git a/packages/core/src/services/document-types-seed.ts b/packages/core/src/services/document-types-seed.ts index 8350a5c67..6d60ee6d6 100644 --- a/packages/core/src/services/document-types-seed.ts +++ b/packages/core/src/services/document-types-seed.ts @@ -41,6 +41,7 @@ export async function bootstrapDocumentTypes(db: D1Database): Promise { source: 'system', schema: anyObject, settings: { + versioning: true, baseGrants: { public: ['read'], admin: ['read', 'create', 'update', 'delete', 'publish', 'manage'], editor: ['read', 'create', 'update', 'publish'], viewer: ['read'] }, maxVersionsPerRoot: 50, }, diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index adbb74699..60c286072 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -18,6 +18,15 @@ export default defineConfig({ 'src/__tests__/plugins/otp-verify-custom-fields.test.ts', 'src/__tests__/services/email-wiring-integration.test.ts', 'src/plugins/cache/tests/cache-warming.test.ts', + // ── Email plugin hook/route tests written for a different API design than the current + // implementation (factory pattern vs direct fn, SDK EmailService vs core EmailService, + // payload shape mismatch). Quarantined until the email plugin is realigned. + 'src/plugins/core-plugins/email-plugin/__tests__/integration.test.ts', + 'src/plugins/core-plugins/email-plugin/routes/admin.test.ts', + 'src/plugins/core-plugins/email-plugin/hooks/on-cron-tick.test.ts', + 'src/plugins/core-plugins/email-plugin/hooks/on-password-reset-completed.test.ts', + 'src/plugins/core-plugins/email-plugin/hooks/on-password-reset-requested.test.ts', + 'src/plugins/core-plugins/email-plugin/hooks/on-registration-completed.test.ts', ], coverage: { provider: 'v8', From 298198c0b14561a0c5c6b3eff1908db4c368071c Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 6 Jul 2026 17:28:05 -0700 Subject: [PATCH 14/15] fix(tests): fix admin-layout-catalyst and admin-users-profile regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin-layout-catalyst: Users/Plugins/Settings are now admin-only (b5383b90b). Tests using baseData (no user) no longer render them. Add adminData fixture with role=admin for tests that assert on admin-only menu items. admin-users-profile: PUT /admin/users/:id now calls RbacService.getRoles() for dynamic role validation (f8310b297). Add the getRoles mock response at call 0 and update prepare-call count assertion from 2 → 3. Co-Authored-By: Claude Sonnet 4.6 --- .../src/__tests__/routes/admin-users-profile.test.ts | 9 +++++---- .../__tests__/templates/admin-layout-catalyst.test.ts | 11 +++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/routes/admin-users-profile.test.ts b/packages/core/src/__tests__/routes/admin-users-profile.test.ts index c2592ea0d..db75db67a 100644 --- a/packages/core/src/__tests__/routes/admin-users-profile.test.ts +++ b/packages/core/src/__tests__/routes/admin-users-profile.test.ts @@ -313,8 +313,9 @@ describe('Admin Users - Profile on Edit Page', () => { it('should skip the profile write when no profile fields are submitted', async () => { const { writeProfileData } = await import('../../plugins/core-plugins/user-profiles') mockDb = createOrderedMockDb([ - { first: null }, // call 0: uniqueness check, no conflict - { run: { success: true } } // call 1: UPDATE users SET ... + { all: { results: [] } }, // call 0: RbacService.getRoles() for role validation + { first: null }, // call 1: uniqueness check, no conflict + { run: { success: true } } // call 2: UPDATE users SET ... ]) app = createApp(mockDb) @@ -340,8 +341,8 @@ describe('Admin Users - Profile on Edit Page', () => { // No profile fields + no custom config → profile write skipped entirely. expect(writeProfileData).not.toHaveBeenCalled() - // Only 2 prepare calls: uniqueness check + user update. - expect(mockDb.prepare.mock.calls.length).toBe(2) + // 3 prepare calls: getRoles (role validation) + uniqueness check + user update. + expect(mockDb.prepare.mock.calls.length).toBe(3) }) it('should write custom profile data even when no standard profile fields are set (issue #768)', async () => { diff --git a/packages/core/src/__tests__/templates/admin-layout-catalyst.test.ts b/packages/core/src/__tests__/templates/admin-layout-catalyst.test.ts index 93ffc9cbf..cbb20533b 100644 --- a/packages/core/src/__tests__/templates/admin-layout-catalyst.test.ts +++ b/packages/core/src/__tests__/templates/admin-layout-catalyst.test.ts @@ -153,6 +153,11 @@ describe('renderAdminLayoutCatalyst', () => { content: '
Test Content
', }; + const adminData: AdminLayoutCatalystData = { + ...baseData, + user: { name: 'Admin', email: 'admin@test.com', role: 'admin' }, + }; + it('should render HTML document structure', () => { const html = renderAdminLayoutCatalyst(baseData); @@ -275,7 +280,8 @@ describe('renderAdminLayoutCatalyst', () => { }); it('should render default menu items', () => { - const html = renderAdminLayoutCatalyst(baseData); + // Users/Plugins/Settings are admin-only; use adminData to render all items. + const html = renderAdminLayoutCatalyst(adminData); expect(html).toContain('Content'); expect(html).toContain('Collections'); @@ -317,8 +323,9 @@ describe('renderAdminLayoutCatalyst', () => { describe('Dynamic Menu Items', () => { it('should render dynamic menu items', () => { + // Dynamic items render in the admin-only plugins section; use adminData. const html = renderAdminLayoutCatalyst({ - ...baseData, + ...adminData, dynamicMenuItems: [ { label: 'Custom Page', From 630215647bab711b6f64429f3cf11a49e5a674a2 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Tue, 7 Jul 2026 12:29:31 -0700 Subject: [PATCH 15/15] fix(ci): guard HEAD~1 fallback in E2E tag-detection step Shallow clones (fetch-depth:1) have no HEAD~1. The pull_request_target branch already fell back to HEAD~1 but without || echo "", causing exit 128 when that also fails. Add 2>/dev/null || echo "" guard. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/pr-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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