diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 000000000..21e4fb526 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,15 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) +node_modules/* + +dist/* +build/* +out/* +.next/* +.vercel/* +.netlify/* +.turbo/* +.vite/* +.webpack/* +.rollup.cache/* +.rpt2_cache/* +.rts2_cache_cjs/* diff --git a/.github/DEPLOYMENT.md b/.github/DEPLOYMENT.md index 825b30565..e3f879201 100644 --- a/.github/DEPLOYMENT.md +++ b/.github/DEPLOYMENT.md @@ -17,9 +17,12 @@ Complete reference for the deployment system. See [README.md](README.md) for qui | `wranglerConfig` | string | `"wrangler.jsonc"` | Wrangler config file path | | `wranglerEnv` | string | `"production"` | Wrangler environment | | `healthCheckPath` | string | `"/"` | Path for health check | -| `requiresSecrets` | string[] | See below | Required GitHub secrets | +| `requiresSecrets` | string[] | `[]` | _(Optional)_ Extra secrets not in wrangler.jsonc (e.g. build-time) | -**Default Secrets:** `["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"]` +> **SSOT:** `wrangler.jsonc` is the single source of truth for resource secrets. Placeholders in `env.production` / +> `env.preview` are auto-detected by the substitution script. `requiresSecrets` is optional and additive — only needed +> for secrets that don't appear in wrangler (e.g. build-time env vars). Base secrets (`CLOUDFLARE_API_TOKEN`, +> `CLOUDFLARE_ACCOUNT_ID`) are always verified automatically. ## Framework Examples @@ -45,8 +48,7 @@ Complete reference for the deployment system. See [README.md](README.md) for qui "buildCommand": "build", "workerBuildCommand": null, "outputDirectory": "dist", - "verifyPaths": ["dist", "cloudflare-worker.ts"], - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "D1_DATABASE_ID", "KV_NAMESPACE_ID"] + "verifyPaths": ["dist", "cloudflare-worker.ts"] } ``` @@ -187,20 +189,23 @@ node .github/scripts/discover-deployable-apps.mjs ## Wrangler Configuration -The workflow generates `wrangler.production.jsonc` by substituting placeholders in your `wrangler.jsonc`: +The workflow generates `wrangler.production.jsonc` (or `wrangler.preview.jsonc` for PR preview) by running +`.github/scripts/substitute-wrangler-secrets.py` **before** any wrangler deploy command. Order: Build → Generate config +(substitute secrets) → Verify → Deploy. The `cloudflare/wrangler-action` deploy step uses +`--config wrangler.production.jsonc` (or `wrangler.preview.jsonc`). Build steps do not use wrangler config. -**Source (wrangler.jsonc):** +**Convention:** Placeholder values in `wrangler.jsonc` are `ALL_CAPS_SNAKE_CASE` strings (e.g. `D1_DATABASE_ID`). The +substitution script **auto-detects** these from the target `env.production` or `env.preview` section and substitutes +each from the full GitHub Secrets bag (`${{ toJson(secrets) }}`). No key list, no per-secret env wiring, no mapping +table. + +**Source (wrangler.jsonc env.production):** ```jsonc { "d1_databases": [ { - "database_id": "PRODUCTION_D1_DATABASE_ID", - }, - ], - "kv_namespaces": [ - { - "id": "PRODUCTION_KV_NAMESPACE_ID", + "database_id": "D1_DATABASE_ID", // placeholder = GitHub Secret name }, ], } @@ -212,23 +217,28 @@ The workflow generates `wrangler.production.jsonc` by substituting placeholders { "d1_databases": [ { - "database_id": "abc123...", // ← From GitHub secret - }, - ], - "kv_namespaces": [ - { - "id": "xyz789...", // ← From GitHub secret + "database_id": "abc123...", // ← substituted from GitHub Secret }, ], } ``` -**Supported placeholders:** +**Default placeholders:** + +- env.production: `D1_DATABASE_ID`, `KV_NAMESPACE_ID` +- env.preview: `D1_PREVIEW_DATABASE_ID`, `KV_PREVIEW_NAMESPACE_ID` + +**Multi-app:** Same placeholder name across apps → same GitHub Secret → shared resource. Different names → isolated. +Prefixing (e.g. `APP_1_D1_DATABASE_ID`) is a convention for clarity, not a requirement. -- `PRODUCTION_D1_DATABASE_ID` → `${{ secrets.D1_DATABASE_ID }}` -- `PRODUCTION_KV_NAMESPACE_ID` → `${{ secrets.KV_NAMESPACE_ID }}` +To add a new resource placeholder: set the GitHub Secret name as the value in `wrangler.jsonc` env section, add the +secret to GitHub. That's it — the script auto-detects and substitutes. -Add more by editing the workflow's "Generate production wrangler config" step. +`requiresSecrets` / `requiresPreviewSecrets` in `cloudflare-config.json` are **optional** and **additive** — used only +for early fail-fast verification. The Verify step **derives** placeholders from `wrangler.jsonc` (single source of +truth), then merges with base secrets (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) and any extra from +`requiresSecrets`. No drift: wrangler placeholders are always correct. Add `requiresSecrets` only for secrets not in +wrangler (e.g. build-time). ## Error Messages Reference @@ -318,14 +328,16 @@ Make sure your app has a wrangler.jsonc file in its root directory. ### Secret Substitution Failed +From `.github/scripts/substitute-wrangler-secrets.py`: + ``` ❌ ERROR: Secret substitution incomplete -The following placeholders were not replaced: - PRODUCTION_D1_DATABASE_ID + Placeholders detected in env.production but no matching GitHub Secret: + • D1_DATABASE_ID -This usually means the corresponding GitHub secrets are not set. -Check that all required secrets are configured in repository settings. + Add the missing secrets in GitHub repository settings: + Settings → Secrets and variables → Actions → New repository secret ``` ## Deployment Targets @@ -345,10 +357,11 @@ Worker deployment indicators: ``` ottabase/ ├── .github/ +│ ├── scripts/ +│ │ ├── discover-deployable-apps.mjs +│ │ └── substitute-wrangler-secrets.py # Substitutes secrets into wrangler config │ ├── workflows/ │ │ └── deploy.yml # Main workflow -│ ├── scripts/ -│ │ └── discover-deployable-apps.mjs │ ├── README.md # Quick start │ └── DEPLOYMENT.md # This file │ @@ -365,41 +378,36 @@ ottabase/ ## Extending the System -### Add Custom Secrets +### Add Custom Secrets or a Second App -**1. Add to app config:** +The substitution script auto-detects `ALL_CAPS_SNAKE_CASE` placeholder values from the target `env` section in +`wrangler.jsonc` and substitutes them from GitHub Secrets. You never edit the Python script or the workflow files. There +are only **2 places** to update: -```json -{ - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "MY_CUSTOM_SECRET"] -} -``` +#### Example: Adding `APP_1` with its own isolated D1 database -**2. Update workflow:** Add case in "Verify required secrets" step: +**1. `wrangler.jsonc`** — use the secret name as the placeholder value: -```yaml -"MY_CUSTOM_SECRET") - if [ -z "${{ secrets.MY_CUSTOM_SECRET }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - fi - ;; +```jsonc +// apps/app-1/wrangler.jsonc → env.production +"d1_databases": [{ + "binding": "OBCF_D1", + "database_name": "app1-db", + "database_id": "APP_1_D1_DATABASE_ID" // ← auto-detected as placeholder +}] ``` -**3. Use in wrangler config:** +**2. GitHub repo → Settings → Secrets** — add `APP_1_D1_DATABASE_ID` with the actual D1 UUID. -```jsonc -{ - "vars": { - "MY_VAR": "PRODUCTION_MY_CUSTOM_SECRET", - }, -} -``` +Done. No workflow edits, no `cloudflare-config.json` edits, no Python script edits. -**4. Add substitution:** +> **Sharing rule:** If two apps both use `D1_DATABASE_ID` as their placeholder, they resolve to the same GitHub Secret → +> same database. If App 1 uses `APP_1_D1_DATABASE_ID`, it gets its own isolated resource. Just naming. -```yaml -sed -e "s/PRODUCTION_MY_CUSTOM_SECRET/${{ secrets.MY_CUSTOM_SECRET }}/g" -``` +#### Optional: Early verification + +Add secret names to `requiresSecrets` in `cloudflare-config.json` for fail-fast checking **before** the build runs. This +is optional — if omitted, missing secrets are caught later at substitution time. ### Add New App Type diff --git a/.github/README.md b/.github/README.md index b49a37afd..d74af53b2 100644 --- a/.github/README.md +++ b/.github/README.md @@ -51,7 +51,10 @@ Already configured; push to `main` or open PRs as usual. | `outputDirectory` | `"dist"` | Dir to verify after build | | `verifyPaths` | `["dist", "cloudflare-worker.ts"]` | Paths that must exist after build | | `wranglerConfig` | `"wrangler.jsonc"` | Wrangler config file | -| `requiresSecrets` | See below | GitHub secrets required for this app | +| `requiresSecrets` | `[]` | _(Optional)_ Extra secrets not in wrangler.jsonc (e.g. build-time) | + +> **SSOT:** Placeholders in `wrangler.jsonc` `env.production` / `env.preview` are auto-detected. `requiresSecrets` is +> only for secrets that don't appear in wrangler. ### Minimal examples @@ -84,33 +87,45 @@ Already configured; push to `main` or open PRs as usual. "buildCommand": "build", "outputDirectory": "dist", "verifyPaths": ["dist", "cloudflare-worker.ts"], - "wranglerConfig": "wrangler.jsonc", - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "D1_DATABASE_ID", "KV_NAMESPACE_ID"] + "wranglerConfig": "wrangler.jsonc" } ``` ### Wrangler placeholders -In `wrangler.jsonc` use placeholders; the workflow substitutes them from GitHub secrets: +In `wrangler.jsonc`, `ALL_CAPS_SNAKE_CASE` placeholder values in `env.production` and `env.preview` are +**auto-detected** by `substitute-wrangler-secrets.py` and substituted from GitHub Secrets. No explicit key list or +per-secret workflow wiring needed — just set the placeholder and the secret. + +**Default (env.production):** `D1_DATABASE_ID`, `KV_NAMESPACE_ID` **Default (env.preview):** `D1_PREVIEW_DATABASE_ID`, +`KV_PREVIEW_NAMESPACE_ID` -- `PRODUCTION_D1_DATABASE_ID` → `D1_DATABASE_ID` -- `PRODUCTION_KV_NAMESPACE_ID` → `KV_NAMESPACE_ID` -- `YOUR_CLOUDFLARE_ACCOUNT_ID` → `CLOUDFLARE_ACCOUNT_ID` +**Multi-app:** Same placeholder name across apps → same GitHub Secret → shared resource. Different names → isolated. +Prefixing (e.g. `APP_1_D1_DATABASE_ID`) is a convention for clarity, not a requirement. -Generated file is `wrangler.production.jsonc` (or `wrangler.preview.jsonc` for PR preview); source file is not modified. +Generated files: `wrangler.production.jsonc` / `wrangler.preview.jsonc` (gitignored). ## Secrets **Settings → Secrets and variables → Actions** -### Required (production and PR preview) +### Required for production deploy + +| Secret | Where to get it | +| ----------------------- | ------------------------------------ | +| `CLOUDFLARE_API_TOKEN` | Cloudflare → My Profile → API Tokens | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare → Workers & Pages | +| `D1_DATABASE_ID` | `pnpm cf:setup` output (ottabase-db) | +| `KV_NAMESPACE_ID` | `pnpm cf:setup` output (OBCF_KV) | + +### Required for PR preview deploy + +| Secret | Where to get it | +| ------------------------- | -------------------------------------------- | +| `D1_PREVIEW_DATABASE_ID` | `pnpm cf:setup` output (ottabase-db-preview) | +| `KV_PREVIEW_NAMESPACE_ID` | `pnpm cf:setup` output (OBCF_KV_preview) | -| Secret | Where to get it | -| ----------------------- | --------------------------------------------------- | -| `CLOUDFLARE_API_TOKEN` | Cloudflare → My Profile → API Tokens | -| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare → Workers & Pages | -| `D1_DATABASE_ID` | `wrangler d1 create ` (if using D1) | -| `KV_NAMESPACE_ID` | `wrangler kv:namespace create ` (if using KV) | +PR preview uses isolated preview D1/KV/R2 so production data is never touched. ### Optional @@ -143,9 +158,10 @@ Generated file is `wrangler.production.jsonc` (or `wrangler.preview.jsonc` for P ## PR preview (pr-preview.yml) - **Triggers:** PR opened, synchronized, reopened, or closed. -- **Open/sync/reopen:** Builds packages, builds app(s), deploys preview worker(s) named e.g. `my-app-pr-123`. Preview - URL: `https://..workers.dev`. -- **Closed:** Deletes the preview worker for that PR. +- **Open/sync/reopen:** Builds packages, builds app(s), deploys preview worker(s) named e.g. `my-app-pr-123` using + **env.preview** bindings (ottabase-db-preview D1, OBCF_KV_preview, ottabase-bucket-preview). Preview URL: + `https://..workers.dev`. +- **Closed:** Deletes the preview worker (preview D1/KV persist; shared across PRs). - **Skip:** If PR title or description contains `#skippr` or `#skipdeploy`, preview build and deploy are skipped. See [Skip deployment](#skip-deployment). @@ -190,7 +206,7 @@ pnpm preview # if available | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | App not discovered | `deployable: true` in `cloudflare-config.json`; `package.json` has required scripts; `wrangler.jsonc` present if no cloudflare-config | | Build fails | Actions logs; locally: `pnpm --filter=@ottabase/my-app run build` | -| Deploy fails | Required secrets set; `wrangler.jsonc` valid; no remaining `PRODUCTION_*` in generated config | +| Deploy fails | Required secrets set; `wrangler.jsonc` valid; no unsubstituted placeholders in generated config | | Preview not created | PR without `#skippr` / `#skipdeploy`; secrets set; app in `APPS_TO_DEPLOY` or default | Errors in workflows include what failed, why, and how to fix (e.g. missing secrets with links to Cloudflare). @@ -205,7 +221,8 @@ Errors in workflows include what failed, why, and how to fix (e.g. missing secre │ ├── build-packages.yml # Reusable: build packages │ └── ci.yml # Lint, type-check, test, build ├── scripts/ -│ └── discover-deployable-apps.mjs +│ ├── discover-deployable-apps.mjs +│ └── substitute-wrangler-secrets.py # Substitutes secrets into wrangler config ├── README.md # This file └── DEPLOYMENT.md # Full reference (config, errors, extending) ``` diff --git a/.github/instructions/agent.instructions.md b/.github/instructions/agent.instructions.md index 2e8d017cd..a23a0577a 100644 --- a/.github/instructions/agent.instructions.md +++ b/.github/instructions/agent.instructions.md @@ -1,5 +1,22 @@ # Ottabase Monorepo - AI Coding Agent Instructions +> **Reference:** See `AGENTS.MD` at repo root for full architecture and package details. + +## AI Guidelines (MUST FOLLOW) + +1. **OttaORM First**: All data models MUST inherit from `BaseModel`. Do not write raw SQL or vanilla Drizzle queries + unless absolutely necessary for performance. Logic belongs in the Model class, not in "Controllers" or "Services". +2. **Schema Integrity**: Drizzle schema lives in `apps/*/ottabase/db/schema.ts`. Export all tables there. Package tables + come from package schemas (e.g. `@ottabase/shortlinks/schema`) and are wired via `schemas-helper.ts`. +3. **Workspace Protocol**: Always use `workspace:*` for internal package dependencies. Use `catalog:` for shared + external dependencies (React, Drizzle, etc.). +4. **Don't Reinvent**: Check `@ottabase/utils` for internal helpers (date, string, currency) and `@ottabase/ui-shadcn` + for UI components before creating new ones. +5. **Edge Runtime**: This is a Cloudflare Workers project. Avoid Node.js-only APIs (fs, child_process) in app code. +6. **Fat Models**: Encapsulate logic in models. E.g., `user.activate()` instead of `authService.activate(user)`. + +--- + ## Architecture Overview Monorepo using **pnpm workspaces** + **Turborepo**. TanStack Router + Vite app with Cloudflare Workers deployment. @@ -8,8 +25,7 @@ Monorepo using **pnpm workspaces** + **Turborepo**. TanStack Router + Vite app w ottabase/ ├── apps/ │ └── ottabase-template-app-tanstack/ # Primary app (TanStack + Workers) -├── packages/ # Shared code -└── turbo.json +└── packages/ # Shared code ``` ## Local Development @@ -112,10 +128,10 @@ curl -X POST http://localhost:3004/api/ottaorm/init **Capabilities:** -- ✅ Create new tables -- ✅ Add columns to existing tables -- ⚠️ New NOT NULL columns need DEFAULT values -- ❌ Cannot rename/drop columns (use custom migration) +- Create new tables +- Add columns to existing tables +- New NOT NULL columns need DEFAULT values +- Cannot rename/drop columns (use custom migration) ## Client Hooks (TanStack Query) @@ -126,11 +142,14 @@ import { createModelHooks } from '@ottabase/ottaorm/client'; export const { useList: useTodos, useDetail: useTodo, + useFind: useTodoBySlug, // Find by field/value (e.g., slug, email) useCreate: useCreateTodo, useUpdate: useUpdateTodo, useDelete: useDeleteTodo, useInfiniteList: useTodosInfinite, } = createModelHooks({ entity: 'todos' }); + +// Usage: const { data: todo } = useTodoBySlug("slug", "my-todo-slug"); ``` ## Key Packages @@ -140,12 +159,13 @@ export const { | `@ottabase/ottaorm` | Fat models, CRUD, relationships, auto-migrations | | `@ottabase/db` | Drizzle D1 driver (`createD1Driver`) | | `@ottabase/cf` | D1, KV, R2, Queues, Rate Limiting wrappers | -| `@ottabase/queue` | Job queue (dispatch, handlers, deduplication, chaining, priority) | | `@ottabase/auth` | Auth.js v5 with D1 adapter | -| `@ottabase/ui-shadcn` | shadcn/ui components | +| `@ottabase/ui-shadcn` | shadcn/ui components - use before custom UI | | `@ottabase/ui-mantine` | Mantine provider, pre-built themes | | `@ottabase/state` | Jotai atoms (theme, user, sidebar) | -| `@ottabase/utils` | timezone, string, file, url utilities | +| `@ottabase/utils` | timezone, string, file, url - check before new helpers | +| `@ottabase/queue` | Job queue (dispatch, handlers, deduplication, chaining, priority) | +| `@ottabase/ottablog` | Blog engine (Post, Tag) | | `@ottabase/ottaupload` | File upload (R2, CF Images) | | `@ottabase/cf-realtime` | WebSocket pub/sub (Durable Objects) | | `@ottabase/shortlinks` | URL shortener schema | @@ -163,16 +183,16 @@ export const { Add to catalog when: -- ✅ Used by 2+ packages/apps (react, typescript, drizzle-orm) -- ✅ Core framework libraries (mantine, tanstack, jotai) -- ✅ Shared tooling (tsup, vitest, eslint) +- Used by 2+ packages/apps (react, typescript, drizzle-orm) +- Core framework libraries (mantine, tanstack, jotai) +- Shared tooling (tsup, vitest, eslint) ```yaml # pnpm-workspace.yaml catalog: - react: ^19.1.0 - typescript: ~5.8.4 - drizzle-orm: ^0.44.2 + react: ^19.2.4 + typescript: ^5.9.3 + drizzle-orm: ^0.38.3 ``` Then reference in package.json: @@ -185,16 +205,30 @@ Then reference in package.json: Add locally when: -- ✅ Package-specific utility (e.g., `editorjs` only in `ottaeditor`) -- ✅ App-specific tool not needed elsewhere -- ✅ Experimental/testing before promoting to catalog +- Package-specific utility (e.g., `editorjs` only in `ottaeditor`) +- App-specific tool not needed elsewhere +- Experimental/testing before promoting to catalog ```bash # Add to specific package pnpm add --filter @ottabase/ottaeditor @editorjs/editorjs # Add to specific app -pnpm add --filter @ottabase/template-app-tanstack some-package +pnpm add --filter @ottabase/ottabase-template-app-tanstack some-package +``` + +### Adding to Catalog Steps + +```bash +# 1. Add to pnpm-workspace.yaml catalog section +# 2. Add to root package.json if needed for scripts +pnpm add -w new-package + +# 3. Reference in consuming package.json +{ "new-package": "catalog:" } + +# 4. Install +pnpm install ``` ### Workspace Protocol @@ -308,10 +342,6 @@ pnpm dev:worker # Worker only # Build pnpm build # Everything pnpm build:pkg # Packages only -pnpm build --filter=@ottabase/ui-mantine - -# Test -pnpm test pnpm test --filter=@ottabase/ottaorm # Quality @@ -322,36 +352,70 @@ pnpm type-check pnpm storybook ``` +--- + +## IMPORTANT NOTES FOR CODING AGENTS + +- **Build commands:** Agents should only run build and test commands for a particular package using the `--filter` flag + (e.g., `pnpm build:pkg --filter=` or `pnpm test --filter=`). Do not run full app builds + (`pnpm build`, `pnpm dev`, etc.) unless you are a cloud agent; only cloud agents may execute full builds or dev + workflows. Local development user is responsible for running full app builds, and shall share output with the agent as + required. +- Do not make stray .MD files after a task (like SUMMARY.MD). +- Add comments in code snippets to explain what they do, especially for complex logic. +- `README.MD`s should be concise, with examples. Don't be too verbose. Keep text sharply focused on the task at hand. Do + not add Possible Issues or Troubleshooting sections unless directly relevant. +- Use consistent formatting and indentation in code snippets (check `.prettierrc`). +- Make tests for new features, modify existing ones if needed, and ensure they pass before marking a task as complete. +- When adding new packages, ensure they follow the same structure and conventions as existing ones. +- **UI:** Minimal design; use components from `@ottabase/ui-shadcn` where possible. Add tailwind classes in components; + avoid new CSS files unless absolutely necessary. New UI should feel native (GitHub/Notion style - simple, clean, + functional). Always add dark mode classes. +- **Golden Rule:** KISS, DRY, SIMPLEST SOLUTIONS. Think about developer experience for future maintainers. +- Code snippets in documentation: complete and copy-paste ready; include necessary imports and context. + ## Agent Workflow Checklist -1. **Review documentation first** - Read this file and `AGENTS.MD` before making changes to confirm architecture and - dependency rules. +1. **Review documentation first** - Read this file and `AGENTS.MD` before making changes. 2. **Install dependencies** with `pnpm install` if needed; never use npm or yarn. 3. **Build packages first** with `pnpm build:pkg` when working with shared code. 4. **For code changes**, run quality checks: - `pnpm lint` - Lint all packages - `pnpm type-check` - TypeScript validation - - `pnpm test` - Run tests (use `--filter` to scope: `pnpm test --filter=@ottabase/ottaorm`) -5. **When adding dependencies**, follow the decision flow: - - Multiple packages/apps will use it → Add to `pnpm-workspace.yaml` catalog first, then reference as `"catalog:"` - - Single package/app only → Add directly to that package's `package.json` - - Internal packages → Use `"workspace:*"` -6. **Validate shared changes** don't break `apps/ottabase-template-app-tanstack`: - - Build the app: `pnpm build --filter=ottabase-template-app-tanstack` - - Run dev: `pnpm dev` and test affected features -7. **For model changes**, ensure: + - `pnpm test --filter=` - Run tests scoped to the package you changed +5. **When adding dependencies**, follow the decision flow above. +6. **For model changes**, ensure: - Model has `static entity` and `static table` - Table is exported in `ottabase/db/schema.ts` - Model is registered with `registerModels()` if CRUD API needed - Run `curl -X POST http://localhost:3004/api/ottaorm/init` to apply migrations +## CI / PR Checklist + +- **CI:** Run `pnpm build:pkg && pnpm test --filter=@ottabase/ottaorm` before opening a PR. +- **Checks:** Ensure `pnpm lint` and `pnpm type-check` pass locally or in CI. +- **Formatting:** Ensure Prettier formatting is applied (`pnpm format`). Enforce via pre-commit (husky + lint-staged). +- **PR Description:** Short summary, list affected packages, testing notes, migration steps if any. + +## Formatting / Pre-commit + +- **Format command:** `pnpm format` (runs `prettier --write .` on the whole repo). +- **Pre-commit:** Husky + lint-staged run format and basic lint on staged files. + ## Anti-Patterns -❌ Circular deps between packages -❌ Direct file imports across package boundaries -❌ Framework-specific code in generic packages -❌ Package-specific lock files -❌ Implicit dependencies -❌ Missing type definitions -❌ Models without `static entity` and `static table` -❌ Using npm or yarn instead of pnpm +- Circular deps between packages +- Direct file imports across package boundaries +- Framework-specific code in generic packages +- Package-specific lock files +- Implicit dependencies +- Missing type definitions +- Models without `static entity` and `static table` +- Using npm or yarn instead of pnpm +- Logic in Controllers/Services instead of models +- Stray SUMMARY.MD or noise docs after tasks + +## Maintainers + +- **Maintainer:** @thinkdj - architecture questions & exceptions. +- **Escalation:** Open an issue with the `architecture` label or ping `#dev-ops` for urgent infra problems. diff --git a/.github/scripts/discover-deployable-apps.mjs b/.github/scripts/discover-deployable-apps.mjs index 7ca5219fc..724350ca2 100644 --- a/.github/scripts/discover-deployable-apps.mjs +++ b/.github/scripts/discover-deployable-apps.mjs @@ -23,7 +23,7 @@ const DEFAULT_CONFIG = { wranglerConfig: 'wrangler.jsonc', wranglerEnv: 'production', healthCheckPath: '/', - requiresSecrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID', 'D1_DATABASE_ID', 'KV_NAMESPACE_ID'], + requiresSecrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'], }; function discoverApps() { diff --git a/.github/scripts/substitute-wrangler-secrets.py b/.github/scripts/substitute-wrangler-secrets.py new file mode 100644 index 000000000..944375337 --- /dev/null +++ b/.github/scripts/substitute-wrangler-secrets.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +""" +Substitute wrangler config placeholders with real values from GitHub Secrets. + +Auto-detects ALL_CAPS_SNAKE_CASE placeholder values in wrangler.jsonc and +substitutes them from SECRETS_JSON (the full GitHub secrets bag). No explicit +key list, no per-secret env vars, no mapping table. + +The only configuration needed per app: + 1. Use placeholder values in wrangler.jsonc (e.g. "database_id": "D1_DATABASE_ID") + 2. Add the corresponding GitHub Secret (D1_DATABASE_ID = ) + That's it. + +Multi-app support: + - Same placeholder value across apps → same GitHub Secret → same real ID → shared resource + - Different values → isolated (prefixing like APP_1_D1_DATABASE_ID is a convention, not required) + +Usage: + SECRETS_JSON='{"D1_DATABASE_ID":"abc123","KV_NAMESPACE_ID":"def456"}' \ + TARGET_ENV=production \ + OUTPUT_FILE=wrangler.production.jsonc \ + python substitute-wrangler-secrets.py + +List-only mode (for Verify step; outputs placeholders to stdout, one per line): + TARGET_ENV=production \ + WRANGLER_CONFIG=apps/my-app/wrangler.jsonc \ + python substitute-wrangler-secrets.py --list-only + +Required env vars (normal mode): + SECRETS_JSON - JSON object of all GitHub Secrets (via ${{ toJson(secrets) }}) + TARGET_ENV - Which wrangler env section to scan: "production" or "preview" + OUTPUT_FILE - Output path for generated config + WRANGLER_CONFIG - Input config path (default: wrangler.jsonc) +""" + +import json +import os +import re +import sys +from pathlib import Path + +# Matches JSON string values that look like placeholder secret names: +# - Fully uppercase letters, digits, underscores +# - Must contain at least one underscore (real secret names are compound: D1_DATABASE_ID, not PENDING) +# Excludes known non-placeholder prefixes (binding names, env names, etc.) +PLACEHOLDER_RE = re.compile(r'":\s*"([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)"') +SKIP_PREFIXES = ("OBCF_", "NODE_", "UTF", "HTTP") + + +def log(msg: str, stream=sys.stderr) -> None: + """Print to stderr (visible in workflow logs, not captured as output).""" + print(msg, file=stream) + stream.flush() + + +def err_exit(msg: str, code: int = 1) -> None: + """Log error and exit with code.""" + log(f"\n{msg}") + sys.exit(code) + + +def find_placeholders(content: str) -> list[str]: + """Find all ALL_CAPS_SNAKE_CASE values in JSON string fields.""" + matches = PLACEHOLDER_RE.findall(content) + seen = set() + result = [] + for m in matches: + if m not in seen and not any(m.startswith(p) for p in SKIP_PREFIXES): + seen.add(m) + result.append(m) + return result + + +def extract_env_section(content: str, env_name: str) -> tuple[int, int] | None: + """Extract the start and end positions of a specific env section. + + Finds the "production": { ... } block inside "env": { ... } by matching + braces. Returns (start, end) character positions or None if not found. + """ + # Find "env" key, then find the target environment within it + env_pattern = re.compile(rf'"env"\s*:\s*\{{', re.IGNORECASE) + env_match = env_pattern.search(content) + if not env_match: + return None + + # Find the target env inside the env block + target_pattern = re.compile(rf'"{env_name}"\s*:\s*\{{', re.IGNORECASE) + target_match = target_pattern.search(content, env_match.start()) + if not target_match: + return None + + # Find the opening brace of the target section and match to its close + brace_start = content.index("{", target_match.start() + len(env_name)) + depth = 0 + for i in range(brace_start, len(content)): + if content[i] == "{": + depth += 1 + elif content[i] == "}": + depth -= 1 + if depth == 0: + return (brace_start, i + 1) + return None + + +def main() -> None: + list_only = "--list-only" in sys.argv + config_path = os.environ.get("WRANGLER_CONFIG", "wrangler.jsonc").strip() + output_path = os.environ.get("OUTPUT_FILE", "").strip() + secrets_json = os.environ.get("SECRETS_JSON", "").strip() + target_env = os.environ.get("TARGET_ENV", "").strip() + + # List-only mode: output placeholders to stdout for workflow Verify step (no SECRETS_JSON/OUTPUT_FILE needed) + if list_only: + if not target_env: + err_exit("❌ ERROR: TARGET_ENV required for --list-only") + input_file = Path(config_path) + if not input_file.exists(): + err_exit( + f"❌ ERROR: Wrangler configuration file not found\n Expected: {input_file.absolute()}" + ) + content = input_file.read_text(encoding="utf-8") + bounds = extract_env_section(content, target_env) + if not bounds: + err_exit(f"❌ ERROR: env.{target_env} section not found in {config_path}") + env_text = content[bounds[0] : bounds[1]] + placeholders = find_placeholders(env_text) + for p in placeholders: + print(p) + return + + if not output_path: + err_exit( + "❌ ERROR: OUTPUT_FILE is required.\n" + " Example: OUTPUT_FILE=wrangler.production.jsonc" + ) + + if not secrets_json: + err_exit( + "❌ ERROR: SECRETS_JSON is required.\n" + " Pass all GitHub Secrets via: SECRETS_JSON: ${{ toJson(secrets) }}" + ) + + if not target_env: + err_exit( + "❌ ERROR: TARGET_ENV is required.\n" + " Set to 'production' or 'preview' to indicate which env section to scan." + ) + + # Parse secrets + try: + secrets: dict[str, str] = json.loads(secrets_json) + except json.JSONDecodeError as e: + err_exit(f"❌ ERROR: SECRETS_JSON is not valid JSON.\n {e}") + + # Resolve paths + input_file = Path(config_path) + output_file = Path(output_path) + + if not input_file.exists(): + err_exit( + f"❌ ERROR: Wrangler configuration file not found\n\n" + f" Expected: {input_file.absolute()}\n" + f" Current dir: {Path.cwd()}\n\n" + " Make sure your app has a wrangler.jsonc in its root." + ) + + content = input_file.read_text(encoding="utf-8") + + # Extract the target env section to find placeholders + bounds = extract_env_section(content, target_env) + if not bounds: + err_exit( + f"❌ ERROR: env.{target_env} section not found in {config_path}\n\n" + f" Make sure wrangler.jsonc has an \"env\": {{ \"{target_env}\": {{ ... }} }} block." + ) + + env_start, env_end = bounds + env_text = content[env_start:env_end] + + # Auto-detect placeholders from the env section only + placeholders = find_placeholders(env_text) + + if not placeholders: + log(f"⚠️ No placeholders detected in env.{target_env}. Nothing to substitute.") + output_file.write_text(content, encoding="utf-8") + log(f"✅ Configuration written (unchanged): {output_file}") + return + + log(f"🔍 Detected {len(placeholders)} placeholder(s) in env.{target_env}: {', '.join(placeholders)}") + + # Substitute placeholders ONLY within the target env section (not the whole file). + # This prevents: (1) leaking secrets into comments, (2) cross-env collision if + # production and preview share a placeholder name but with different secrets. + substituted = [] + missing = [] + + for key in placeholders: + value = secrets.get(key, "") + if not value: + missing.append(key) + continue + env_text = env_text.replace(key, value) + substituted.append(key) + + # Reassemble file: before env section + substituted env section + after env section + content = content[:env_start] + env_text + content[env_end:] + + if missing: + err_exit( + f"❌ ERROR: Secret substitution incomplete\n\n" + f" Placeholders detected in env.{target_env} but no matching GitHub Secret:\n" + + "".join(f" • {m}\n" for m in missing) + + "\n" + f" Add the missing secrets in GitHub repository settings:\n" + f" Settings → Secrets and variables → Actions → New repository secret" + ) + + # Basic validity check + if "{" not in content: + err_exit( + f"❌ ERROR: Generated config appears invalid\n\n" + f" Output does not contain valid JSON structure.\n" + f" Check source: {input_file}" + ) + + output_file.write_text(content, encoding="utf-8") + log(f"✅ Configuration generated: {output_file}") + log(f" Substituted: {', '.join(substituted)}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87a11e93c..7f298721f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,5 +76,5 @@ jobs: run: pnpm install --frozen-lockfile - name: Validate Cloudflare Configuration - run: pnpm cloudflare:validate + run: pnpm cf:validate -- --force continue-on-error: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ccb37c8b4..71f5eb243 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -215,8 +215,7 @@ jobs: "verifyPaths": ["dist", "cloudflare-worker.ts"], "wranglerConfig": "wrangler.jsonc", "wranglerEnv": "production", - "healthCheckPath": "/", - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "D1_DATABASE_ID", "KV_NAMESPACE_ID"] + "healthCheckPath": "/" }' fi @@ -307,77 +306,54 @@ jobs: echo "${VERIFY_PATHS}" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - # Store required secrets as space-separated list - REQUIRED_SECRETS=$(echo "${CONFIG}" | jq -r '.requiresSecrets[]?' | tr '\n' ' ') - echo "required-secrets=${REQUIRED_SECRETS}" >> $GITHUB_OUTPUT + # Optional: extra secrets from config (merged with derived placeholders in Verify step) + EXTRA_SECRETS=$(echo "${CONFIG}" | jq -r '.requiresSecrets[]?' | tr '\n' ' ') + echo "extra-secrets=${EXTRA_SECRETS}" >> $GITHUB_OUTPUT echo "📋 Configuration loaded for ${{ matrix.name }} (${{ matrix.config.appType }})" - name: Verify required secrets if: github.ref == 'refs/heads/main' + working-directory: apps/${{ matrix.folder }} + env: + SECRETS_JSON: ${{ toJson(secrets) }} + WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} + TARGET_ENV: production run: | echo "🔍 Verifying required secrets for ${{ matrix.name }}..." - MISSING_SECRETS=() - REQUIRED="${{ steps.config.outputs.required-secrets }}" - # Check each required secret + # Base secrets (wrangler-action needs these; not in wrangler.jsonc) + BASE="CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID" + + # Derive placeholders from wrangler.jsonc env.production (single source of truth; no drift) + PLACEHOLDERS=$(python ../../.github/scripts/substitute-wrangler-secrets.py --list-only) + + # Optional extra from cloudflare-config.json requiresSecrets + EXTRA="${{ steps.config.outputs.extra-secrets }}" + + # Merge: base + placeholders + extra, deduplicate, drop empty + REQUIRED=$(echo "$BASE $PLACEHOLDERS $EXTRA" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ') + + MISSING_SECRETS=() for SECRET_NAME in $REQUIRED; do - # Dynamically check if secret exists using indirect reference - case "$SECRET_NAME" in - "CLOUDFLARE_API_TOKEN") - if [ -z "${{ secrets.CLOUDFLARE_API_TOKEN }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ CLOUDFLARE_API_TOKEN is not set" - else - echo " ✅ CLOUDFLARE_API_TOKEN is configured" - fi - ;; - "CLOUDFLARE_ACCOUNT_ID") - if [ -z "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ CLOUDFLARE_ACCOUNT_ID is not set" - else - echo " ✅ CLOUDFLARE_ACCOUNT_ID is configured" - fi - ;; - "D1_DATABASE_ID") - if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ D1_DATABASE_ID is not set" - else - echo " ✅ D1_DATABASE_ID is configured" - fi - ;; - "KV_NAMESPACE_ID") - if [ -z "${{ secrets.KV_NAMESPACE_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ KV_NAMESPACE_ID is not set" - else - echo " ✅ KV_NAMESPACE_ID is configured" - fi - ;; - *) - echo " ⚠️ Warning: Unknown secret $SECRET_NAME - cannot verify" - ;; - esac + VAL=$(echo "$SECRETS_JSON" | jq -r --arg k "$SECRET_NAME" '.[$k] // empty') + if [ -z "$VAL" ]; then + MISSING_SECRETS+=("$SECRET_NAME") + echo " ❌ $SECRET_NAME is not set" + else + echo " ✅ $SECRET_NAME is configured" + fi done if [ ${#MISSING_SECRETS[@]} -ne 0 ]; then echo "" echo "❌ ERROR: Missing required GitHub secrets for deployment" echo "" - echo "The following secrets are required but not configured:" printf ' • %s\n' "${MISSING_SECRETS[@]}" echo "" - echo "To fix this, add the missing secrets in GitHub repository settings:" + echo "Add the missing secrets in GitHub repository settings:" echo " Settings → Secrets and variables → Actions → New repository secret" echo "" - echo "Required secret locations:" - echo " • CLOUDFLARE_API_TOKEN: Get from Cloudflare dashboard → My Profile → API Tokens" - echo " • CLOUDFLARE_ACCOUNT_ID: Get from Cloudflare dashboard → Workers & Pages → Account ID" - echo " • D1_DATABASE_ID: Run 'wrangler d1 create ' to create database" - echo " • KV_NAMESPACE_ID: Run 'wrangler kv:namespace create ' to create namespace" - echo "" exit 1 fi @@ -681,70 +657,32 @@ jobs: echo "✅ Worker bundle structure verified - all required paths exist" # Generate production wrangler config from template - # Creates a temporary config with secrets injected, without modifying source files + # Auto-detects placeholders in env.production and substitutes from GitHub Secrets - name: Generate production wrangler config if: github.ref == 'refs/heads/main' working-directory: apps/${{ matrix.folder }} run: | - WRANGLER_CONFIG="${{ steps.config.outputs.wrangler-config }}" - echo "🔧 Generating production wrangler configuration from ${WRANGLER_CONFIG}..." - - # Verify wrangler config exists - if [ ! -f "${WRANGLER_CONFIG}" ]; then - echo "" - echo "❌ ERROR: Wrangler configuration file not found" - echo "" - echo "Expected file: ${WRANGLER_CONFIG}" - echo "Current directory: $(pwd)" - echo "" - echo "Wrangler config is required for Cloudflare Workers deployment." - echo "Make sure your app has a wrangler.jsonc file in its root directory." - echo "" - exit 1 - fi - - # Create a temporary production config by substituting placeholders - # The output goes to a temporary file that won't be committed - cat ${WRANGLER_CONFIG} | \ - sed -e "s/PRODUCTION_D1_DATABASE_ID/${{ secrets.D1_DATABASE_ID }}/g" \ - -e "s/PRODUCTION_KV_NAMESPACE_ID/${{ secrets.KV_NAMESPACE_ID }}/g" \ - -e "s/YOUR_CLOUDFLARE_ACCOUNT_ID/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/g" \ - > wrangler.production.jsonc - - # Verify no placeholders remain in the generated file - if grep -q "PRODUCTION_" wrangler.production.jsonc; then - echo "" - echo "❌ ERROR: Secret substitution incomplete" - echo "" - echo "The following placeholders were not replaced:" - grep "PRODUCTION_" wrangler.production.jsonc - echo "" - echo "This usually means the corresponding GitHub secrets are not set." - echo "Check that all required secrets are configured in repository settings." - echo "" - rm -f wrangler.production.jsonc - exit 1 - fi + echo "🔧 Generating production wrangler configuration..." + python ../../.github/scripts/substitute-wrangler-secrets.py + env: + WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} + OUTPUT_FILE: wrangler.production.jsonc + TARGET_ENV: production + SECRETS_JSON: ${{ toJson(secrets) }} - # Validate JSON structure - if ! grep -q "{" wrangler.production.jsonc; then - echo "" - echo "❌ ERROR: Generated wrangler config appears invalid" - echo "" - echo "The generated config file doesn't contain valid JSON." - echo "Check the source file: ${WRANGLER_CONFIG}" - echo "" - rm -f wrangler.production.jsonc + # Verify generated config exists (substitution runs before deploy) + - name: Verify production wrangler config + if: github.ref == 'refs/heads/main' + working-directory: apps/${{ matrix.folder }} + run: | + if [ ! -f "wrangler.production.jsonc" ]; then + echo "❌ ERROR: wrangler.production.jsonc not found - Generate step must run first" exit 1 fi - - echo "✅ Production configuration generated successfully" - echo "📝 Using temporary config file: wrangler.production.jsonc" + echo "✅ wrangler.production.jsonc ready for deploy" # Deploy to Cloudflare Workers (NOT Cloudflare Pages) - # This uses wrangler to deploy directly to Workers - # Uses the generated production config file (wrangler.production.jsonc) - # The source wrangler.jsonc remains unmodified + # Uses wrangler.production.jsonc - secrets substituted by Generate step above - name: Deploy to Cloudflare Workers if: github.ref == 'refs/heads/main' uses: cloudflare/wrangler-action@v3 diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 7a262b71b..72380f90a 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -131,7 +131,7 @@ jobs: echo "⚠️ Warning: No cloudflare-config.json or wrangler.jsonc for ${APP_FOLDER}, skipping" continue fi - CONFIG='{"deployable":true,"appType":"tanstack","buildCommand":"build","workerBuildCommand":null,"outputDirectory":"dist","verifyPaths":["dist","cloudflare-worker.ts"],"wranglerConfig":"wrangler.jsonc","wranglerEnv":"production","healthCheckPath":"/","requiresSecrets":["CLOUDFLARE_API_TOKEN","CLOUDFLARE_ACCOUNT_ID","D1_DATABASE_ID","KV_NAMESPACE_ID"]}' + CONFIG='{"deployable":true,"appType":"tanstack","buildCommand":"build","workerBuildCommand":null,"outputDirectory":"dist","verifyPaths":["dist","cloudflare-worker.ts"],"wranglerConfig":"wrangler.jsonc","wranglerEnv":"production","healthCheckPath":"/"}' fi DEPLOYABLE=$(echo "$CONFIG" | jq -r '.deployable // true') @@ -216,55 +216,43 @@ jobs: echo "${VERIFY_PATHS}" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - REQUIRED_SECRETS=$(echo "${CONFIG}" | jq -r '.requiresSecrets[]?' | tr '\n' ' ') - echo "required-secrets=${REQUIRED_SECRETS}" >> $GITHUB_OUTPUT + # Optional: extra secrets from config (merged with derived placeholders in Verify step) + EXTRA=$(echo "${CONFIG}" | jq -r '.requiresPreviewSecrets[]?' | tr '\n' ' ') + echo "extra-secrets=${EXTRA}" >> $GITHUB_OUTPUT echo "📋 Configuration loaded for ${{ matrix.name }} (preview)" + # Secrets derived from wrangler.jsonc env.preview + base + optional extra (single source of truth; no drift) - name: Verify required secrets + working-directory: apps/${{ matrix.folder }} + env: + SECRETS_JSON: ${{ toJson(secrets) }} + WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} + TARGET_ENV: preview run: | echo "🔍 Verifying required secrets for ${{ matrix.name }}..." - MISSING_SECRETS=() - REQUIRED="${{ steps.config.outputs.required-secrets }}" + # Base secrets (wrangler-action needs these) + BASE="CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID" + + # Derive placeholders from wrangler.jsonc env.preview (single source of truth; no drift) + PLACEHOLDERS=$(python ../../.github/scripts/substitute-wrangler-secrets.py --list-only) + + # Optional extra from cloudflare-config.json requiresPreviewSecrets + EXTRA="${{ steps.config.outputs.extra-secrets }}" + + # Merge: base + placeholders + extra, deduplicate, drop empty + REQUIRED=$(echo "$BASE $PLACEHOLDERS $EXTRA" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ') + + MISSING_SECRETS=() for SECRET_NAME in $REQUIRED; do - case "$SECRET_NAME" in - "CLOUDFLARE_API_TOKEN") - if [ -z "${{ secrets.CLOUDFLARE_API_TOKEN }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ CLOUDFLARE_API_TOKEN is not set" - else - echo " ✅ CLOUDFLARE_API_TOKEN is configured" - fi - ;; - "CLOUDFLARE_ACCOUNT_ID") - if [ -z "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ CLOUDFLARE_ACCOUNT_ID is not set" - else - echo " ✅ CLOUDFLARE_ACCOUNT_ID is configured" - fi - ;; - "D1_DATABASE_ID") - if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ D1_DATABASE_ID is not set" - else - echo " ✅ D1_DATABASE_ID is configured" - fi - ;; - "KV_NAMESPACE_ID") - if [ -z "${{ secrets.KV_NAMESPACE_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ KV_NAMESPACE_ID is not set" - else - echo " ✅ KV_NAMESPACE_ID is configured" - fi - ;; - *) - echo " ⚠️ Warning: Unknown secret $SECRET_NAME - cannot verify" - ;; - esac + VAL=$(echo "$SECRETS_JSON" | jq -r --arg k "$SECRET_NAME" '.[$k] // empty') + if [ -z "$VAL" ]; then + MISSING_SECRETS+=("$SECRET_NAME") + echo " ❌ $SECRET_NAME is not set (run pnpm cf:setup, add to GitHub Secrets)" + else + echo " ✅ $SECRET_NAME is configured" + fi done if [ ${#MISSING_SECRETS[@]} -ne 0 ]; then @@ -469,29 +457,22 @@ jobs: - name: Generate preview wrangler config working-directory: apps/${{ matrix.folder }} run: | - WRANGLER_CONFIG="${{ steps.config.outputs.wrangler-config }}" - echo "🔧 Generating preview wrangler configuration from ${WRANGLER_CONFIG}..." - - if [ ! -f "${WRANGLER_CONFIG}" ]; then - echo "❌ ERROR: Wrangler configuration file not found" - exit 1 - fi - - python -c $'import os\nimport pathlib\n\nconfig_path = pathlib.Path(os.environ["WRANGLER_CONFIG"])\ncontent = config_path.read_text()\n\nreplacements = {\n "PRODUCTION_D1_DATABASE_ID": os.environ.get("D1_DATABASE_ID", ""),\n "PRODUCTION_KV_NAMESPACE_ID": os.environ.get("KV_NAMESPACE_ID", ""),\n "YOUR_CLOUDFLARE_ACCOUNT_ID": os.environ.get("CLOUDFLARE_ACCOUNT_ID", ""),\n}\n\nfor key, value in replacements.items():\n content = content.replace(key, value)\n\npathlib.Path("wrangler.preview.jsonc").write_text(content)\n' + echo "🔧 Generating preview wrangler config..." + python ../../.github/scripts/substitute-wrangler-secrets.py + env: + WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} + OUTPUT_FILE: wrangler.preview.jsonc + TARGET_ENV: preview + SECRETS_JSON: ${{ toJson(secrets) }} - if grep -q "PRODUCTION_" wrangler.preview.jsonc; then - echo "❌ ERROR: Secret substitution incomplete" - rm -f wrangler.preview.jsonc + - name: Verify preview wrangler config + working-directory: apps/${{ matrix.folder }} + run: | + if [ ! -f "wrangler.preview.jsonc" ]; then + echo "❌ ERROR: wrangler.preview.jsonc not found - Generate step must run first" exit 1 fi - - echo "✅ Preview configuration generated successfully" - env: - D1_DATABASE_ID: ${{ secrets.D1_DATABASE_ID }} - KV_NAMESPACE_ID: ${{ secrets.KV_NAMESPACE_ID }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} - PREVIEW_WORKER_NAME: ${{ steps.preview-name.outputs.preview-name }} + echo "✅ wrangler.preview.jsonc ready for deploy" - name: Deploy preview to Cloudflare Workers uses: cloudflare/wrangler-action@v3 @@ -500,8 +481,8 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: apps/${{ matrix.folder }} command: - deploy --env ${{ steps.config.outputs.wrangler-env }} --name ${{ - steps.preview-name.outputs.preview-name }} --config wrangler.preview.jsonc + deploy --env preview --name ${{ steps.preview-name.outputs.preview-name }} --config + wrangler.preview.jsonc continue-on-error: false - name: Preview deployment summary diff --git a/.gitignore b/.gitignore index 1efb27b23..8c2759bc0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ cloudflare-env.d.ts /wrangler.toml.backup /wrangler.jsonc.backup wrangler.production.jsonc +wrangler.preview.jsonc .open-next .dev.vars .wrangler/ diff --git a/AGENTS.MD b/AGENTS.MD index 8c847cd93..33459393b 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -7,8 +7,8 @@ Monorepo using **pnpm workspaces** + **Turborepo**. TanStack Router + Vite app w 1. **OttaORM First**: All data models MUST inherit from `BaseModel`. Do not write raw SQL or vanilla Drizzle queries unless absolutely necessary for performance. Logic belongs in the Model class, not in "Controllers" or "Services". -2. **Schema Integrity**: NEVER edit `schema.prisma` directly. Use the modular schema system in - `packages/db/prisma/schemas/` and run `db:generate`. +2. **Schema Integrity**: Drizzle schema lives in `apps/*/ottabase/db/schema.ts`. Export all tables there. Package tables + come from package schemas (e.g. `@ottabase/shortlinks/schema`) and are wired via `schemas-helper.ts`. 3. **Workspace Protocol**: Always use `workspace:*` for internal package dependencies. Use `catalog:` for shared external dependencies (React, Drizzle, etc.). 4. **Don't Reinvent**: Check `@ottabase/utils` for internal helpers (date, string, currency) and `@ottabase/ui-shadcn` @@ -342,9 +342,9 @@ Add to catalog when: ```yaml # pnpm-workspace.yaml catalog: - react: ^19.1.0 - typescript: ~5.8.4 - drizzle-orm: ^0.44.2 + react: ^19.2.4 + typescript: ^5.9.3 + drizzle-orm: ^0.38.3 ``` Then reference in package.json: @@ -366,7 +366,7 @@ Add locally when: pnpm add --filter @ottabase/ottaeditor @editorjs/editorjs # Add to specific app -pnpm add --filter @ottabase/template-app-tanstack some-package +pnpm add --filter @ottabase/ottabase-template-app-tanstack some-package ``` ### Workspace Protocol @@ -451,6 +451,11 @@ Install recommended extensions in `.vscode/extensions.json` for Prettier format- ### IMPORTANT NOTES FOR CODING AGENTS +- **Build commands:** Agents should only run build and test commands for a particular package using the `--filter` flag + (e.g., `pnpm build:pkg --filter=` or `pnpm test --filter=`). Do not run full app builds + (`pnpm build`, `pnpm dev`, etc.) unless you are a cloud agent; only cloud agents may execute full builds or dev + workflows. Local development user is responsible for running full app builds, and shall share output with the agent as + required. - Do not make stray .MD files after a task (like SUMMARY.MD) - Add comments in code snippets to explain what they do, especially for complex logic. - `README.MD`s should be concise, and with examples. Don't be too verbose. Keep text sharply focused on the task at @@ -483,7 +488,7 @@ Install recommended extensions in `.vscode/extensions.json` for Prettier format- ### Formatting / Pre-commit -- **Format command:** `pnpm -w prettier:check` (or `pnpm -w prettier:fix` to fix). +- **Format command:** `pnpm format` (runs `prettier --write .` on the whole repo). - **Pre-commit:** We recommend `husky` + `lint-staged` to run format and basic lint on staged files. diff --git a/CLOUDFLARE_CONFIGURATION_GUIDE.md b/CLOUDFLARE_CONFIGURATION_GUIDE.md index 8514b8e39..143ea79d3 100644 --- a/CLOUDFLARE_CONFIGURATION_GUIDE.md +++ b/CLOUDFLARE_CONFIGURATION_GUIDE.md @@ -29,14 +29,18 @@ This guide covers Cloudflare resource configuration, bindings, environment varia The following Cloudflare resources must be created and configured: -| Resource Type | Binding Name | Purpose | Created By | -| ------------------ | ------------------- | ------------------------------ | ------------------------- | -| **D1 Database** | `OBCF_D1` | Primary SQLite database | `cloudflare:setup` script | -| **KV Namespace** | `OBCF_KV` | Key-value storage | `cloudflare:setup` script | -| **R2 Bucket** | `OBCF_R2` | Object storage | `cloudflare:setup` script | -| **Queue** | `OBCF_QUEUE` | Async message processing | `cloudflare:setup` script | -| **Durable Object** | `OBCF_REALTIME` | WebSocket realtime connections | Auto-configured | -| **Rate Limiter** | `OBCF_RATE_LIMITER` | Request throttling | Auto-configured | +| Resource Type | Binding Name | Purpose | Created By | +| -------------------- | ------------------- | ------------------------------ | --------------------------- | +| **D1 Database** | `OBCF_D1` | Primary SQLite database | `cf:setup` script | +| **KV Namespace** | `OBCF_KV` | Key-value storage | `cf:setup` script | +| **R2 Bucket** | `OBCF_R2` | Object storage | `cf:setup` script | +| **Queue** | `OBCF_QUEUE` | Async message processing | `cf:setup` script | +| **Durable Object** | `OBCF_REALTIME` | WebSocket realtime connections | Auto-configured | +| **Rate Limiter** | `OBCF_RATE_LIMITER` | Request throttling | Auto-configured | +| **Analytics Engine** | `OBCF_ANALYTICS_*` | Event tracking | Auto-created on first write | + +**Note:** Run `pnpm cf:login` before `pnpm cf:setup` if not authenticated. cf:setup outputs resource IDs for GitHub +Secrets; it does not modify wrangler.jsonc. ### Optional Resources @@ -44,13 +48,16 @@ The following Cloudflare resources must be created and configured: | -------------- | ----------------- | ------------------------- | ------------------------------------------------------------- | | **Hyperdrive** | `OBCF_HYPERDRIVE` | External database pooling | `wrangler hyperdrive create --connection-string="..."` | +**For /analytics page:** Set `CLOUDFLARE_ACCOUNT_ID` (vars) and `CLOUDFLARE_ANALYTICS_API_TOKEN` (secret) with +`Account Analytics` Read permission. Analytics datasets are auto-created on first write. + --- ## 🔐 Environment Variables ### Local Development (`.env.local`) -Create `apps/ottabase-template-app/.env.local` with the following: +Create `apps/ottabase-template-app-tanstack/.env.local` with the following (if using .env for local auth/R2): ```bash @@ -125,9 +132,15 @@ wrangler secret put CF_R2_SECRET_ACCESS_KEY ## 📁 Configuration Files -### 1. `apps/ottabase-template-app/wrangler.jsonc` +### 1. `apps/ottabase-template-app-tanstack/wrangler.jsonc` -**Status:** ✅ Already configured +**Status:** ✅ Template (do not modify programmatically) + +`wrangler.jsonc` is a shared template. cf:setup does **not** modify it. `ALL_CAPS_SNAKE_CASE` placeholder values in +`env.production` and `env.preview` are **auto-detected** by `.github/scripts/substitute-wrangler-secrets.py` at deploy +time and substituted from GitHub Secrets, generating `wrangler.production.jsonc` or `wrangler.preview.jsonc`. The source +file is never modified. For local dev, `YOUR_*` top-level values are ignored (miniflare uses local simulators). For +multi-app: same placeholder name = shared resource; different names = isolated (prefixing is a convention). **Key Bindings:** @@ -137,26 +150,26 @@ wrangler secret put CF_R2_SECRET_ACCESS_KEY { "binding": "OBCF_D1", "database_name": "ottabase-db", - "database_id": "YOUR_D1_DATABASE_ID", // Set by cloudflare:setup + "database_id": "YOUR_D1_DATABASE_ID", // Top-level: local dev only (simulators ignore this) }, ], "kv_namespaces": [ { "binding": "OBCF_KV", - "id": "YOUR_KV_NAMESPACE_ID", // Set by cloudflare:setup + "id": "YOUR_KV_NAMESPACE_ID", // Top-level: local dev only (simulators ignore this) }, ], "r2_buckets": [ { "binding": "OBCF_R2", - "bucket_name": "ottabase-bucket", // Set by cloudflare:setup + "bucket_name": "ottabase-bucket", }, ], "queues": { "producers": [ { "binding": "OBCF_QUEUE", - "queue": "ottabase-queue", // Set by cloudflare:setup + "queue": "ottabase-queue", }, ], }, @@ -179,7 +192,7 @@ wrangler secret put CF_R2_SECRET_ACCESS_KEY } ``` -### 3. `apps/ottabase-template-app/types/cloudflare.d.ts` +### 3. `apps/ottabase-template-app-tanstack/types/cloudflare.d.ts` **Status:** ✅ Already configured @@ -212,7 +225,7 @@ export interface CloudflareEnv { } ``` -### 4. `apps/ottabase-template-app/cloudflare-worker.ts` +### 4. `apps/ottabase-template-app-tanstack/cloudflare-worker.ts` **Status:** ✅ Already configured @@ -310,7 +323,8 @@ AUTH_GOOGLE_SECRET=your-google-client-secret - [ ] Queue exists: `wrangler queues list` - [ ] **Configuration Files Updated** - - [ ] `wrangler.jsonc` has resource IDs (not placeholders) + - [ ] GitHub Secrets set for production: `D1_DATABASE_ID`, `KV_NAMESPACE_ID` + - [ ] GitHub Secrets set for PR preview: `D1_PREVIEW_DATABASE_ID`, `KV_PREVIEW_NAMESPACE_ID` - [ ] `types/cloudflare.d.ts` includes all OBCF\_\* bindings - [ ] **Environment Variables Set** @@ -360,8 +374,10 @@ export async function GET() { const queue = env.OBCF_QUEUE; // Queue const realtime = env.OBCF_REALTIME; // Durable Object - // Use with @ottabase/cf package - const prisma = createPrismaD1Client(db); + // Use with @ottabase packages + // D1 via OttaORM (preferred): + // import { createD1Driver } from '@ottabase/db/drizzle-d1'; + // const driver = createD1Driver(db); setDriver(driver); const kvClient = createKVClient({ namespace: kv }); const r2Client = createR2Client({ bucket: r2 }); } @@ -372,7 +388,7 @@ export async function GET() { All bindings are accessed via `@ottabase/cf` package: ```typescript -import { createPrismaD1Client } from '@ottabase/cf/d1-prisma'; +import { createD1Driver } from '@ottabase/db/drizzle-d1'; import { createKVClient } from '@ottabase/cf/kv'; import { createR2Client } from '@ottabase/cf/r2'; import { createQueuesClient } from '@ottabase/cf/queues'; @@ -383,39 +399,62 @@ import { createRateLimitingClient } from '@ottabase/cf/rate-limiting'; ## 🛠 Manual Configuration (Advanced) -If you prefer manual setup instead of `cloudflare:setup`: +If you prefer manual setup instead of `cf:setup`: -### 1. Create D1 Database +### 1. Login First + +```bash +pnpm cf:login # or wrangler login +``` + +### 2. Create D1 Database ```bash wrangler d1 create ottabase-db -# Copy database_id and update wrangler.jsonc +# Copy database_id → GitHub Secret D1_DATABASE_ID (for CI) or replace YOUR_D1_DATABASE_ID in wrangler.jsonc (local) ``` -### 2. Create KV Namespace +### 3. Create KV Namespace ```bash -wrangler kv:namespace create OBCF_KV -wrangler kv:namespace create OBCF_KV --preview -# Copy IDs and update wrangler.jsonc +wrangler kv namespace create OBCF_KV +wrangler kv namespace create OBCF_KV --preview +# Copy IDs → GitHub Secret KV_NAMESPACE_ID (for CI) or replace in wrangler.jsonc (local) ``` -### 3. Create R2 Bucket +### 4. Create R2 Bucket ```bash wrangler r2 bucket create ottabase-bucket wrangler r2 bucket create ottabase-bucket-preview ``` -### 4. Create Queue +### 5. Create Queue ```bash wrangler queues create ottabase-queue +wrangler queues create ottabase-queue-preview ``` -### 5. Update `wrangler.jsonc` +### 6. Create Preview Resources (for PR deploys) + +```bash +wrangler d1 create ottabase-db-preview +wrangler r2 bucket create ottabase-bucket-preview +``` + +### 7. Configure GitHub Secrets + +**Production** (main deploy — placeholder values in env.production are auto-detected): + +- `D1_DATABASE_ID`, `KV_NAMESPACE_ID` + +**Preview** (PR deploy — placeholder values in env.preview are auto-detected): + +- `D1_PREVIEW_DATABASE_ID`, `KV_PREVIEW_NAMESPACE_ID` -Replace all `YOUR_*_ID` placeholders with actual resource IDs and ensure binding names use `OBCF_*` convention. +Local dev does not need these — `wrangler dev` uses local simulators regardless of placeholder values. To add a new +secret: set the placeholder in `wrangler.jsonc`, add the secret to GitHub. CI auto-detects the rest. --- @@ -493,12 +532,11 @@ pnpm cf-typegen ### Required Configuration Files -- ✅ `wrangler.jsonc` - Cloudflare bindings configuration (OBCF\_\* names) -- ✅ `db.config.ts` - Database configuration (d1Database: "OBCF_D1") +- ✅ `wrangler.jsonc` - Cloudflare bindings (OBCF\_\* names); `ALL_CAPS` placeholder values are auto-detected and + substituted from GitHub Secrets via `substitute-wrangler-secrets.py` - ✅ `types/cloudflare.d.ts` - TypeScript definitions (OBCF\_\* interfaces) - ✅ `cloudflare-worker.ts` - Durable Object exports -- ✅ `.env.local` - Local environment variables -- ✅ `prisma/schema.prisma` - Generated database schema +- ✅ `.env.local` - Local environment variables (optional) ### Key Binding Names (OBCF\_\*) @@ -514,12 +552,12 @@ pnpm cf-typegen ### Key Environment Variables -| Variable | Required | Purpose | -| --------------- | ------------- | ------------------------- | -| `DATABASE_URL` | Yes (local) | Prisma CLI (local SQLite) | -| `AUTH_SECRET` | If using auth | Auth.js secret | -| `CF_ACCOUNT_ID` | Optional | Cloudflare API access | -| `CF_API_TOKEN` | Optional | Cloudflare API access | +| Variable | Required | Purpose | +| ---------------- | ------------- | --------------------------------------- | +| `D1_DATABASE_ID` | Yes (deploy) | D1 database UUID (wrangler placeholder) | +| `AUTH_SECRET` | If using auth | Auth.js secret | +| `CF_ACCOUNT_ID` | Optional | Cloudflare API access | +| `CF_API_TOKEN` | Optional | Cloudflare API access | --- diff --git a/CLOUDFLARE_DEPLOY.md b/CLOUDFLARE_DEPLOY.md index 2f242fbb2..e40a4cd55 100644 --- a/CLOUDFLARE_DEPLOY.md +++ b/CLOUDFLARE_DEPLOY.md @@ -1,6 +1,6 @@ # Deploy Ottabase to Cloudflare Workers -Complete guide for deploying `ottabase-template-app` to Cloudflare Workers with automated CI/CD. +Complete guide for deploying `ottabase-template-app-tanstack` to Cloudflare Workers with automated CI/CD. ## Prerequisites @@ -17,22 +17,36 @@ Complete guide for deploying `ottabase-template-app` to Cloudflare Workers with ```bash npm install -g wrangler wrangler login +# Or use project auth: pnpm cf:login ``` ### Create Resources (Automated) ```bash -pnpm cloudflare:setup -pnpm cloudflare:validate +pnpm cf:login # If not authenticated (required before cf:setup) +pnpm cf:setup # Interactive: select D1, KV, R2, Queue (use --force for all) +pnpm cf:validate ``` -**What this creates:** +**What cf:setup creates:** - D1 Database: `ottabase-db` - KV Namespace: `OBCF_KV` (+ preview) - R2 Buckets: `ottabase-bucket` (+ preview) - Queue: `ottabase-queue` -- Updates `wrangler.jsonc` with resource IDs +- **Does NOT modify wrangler.jsonc** (it's a template). Copy the output IDs for GitHub Secrets below. + +**Resource overview (prod vs preview):** + +| Resource | cf:setup creates (prod) | cf:setup creates (preview) | wrangler placeholder (prod) | wrangler placeholder (preview) | GitHub Secrets | +| -------- | ----------------------- | -------------------------- | --------------------------- | ----------------------------------- | ---------------------------------------- | +| D1 | ottabase-db | ottabase-db-preview | `D1_DATABASE_ID` | `D1_PREVIEW_DATABASE_ID` | D1_DATABASE_ID, D1_PREVIEW_DATABASE_ID | +| KV | OBCF_KV | OBCF_KV_preview | `KV_NAMESPACE_ID` | `KV_PREVIEW_NAMESPACE_ID` | KV_NAMESPACE_ID, KV_PREVIEW_NAMESPACE_ID | +| R2 | ottabase-bucket | ottabase-bucket-preview | `ottabase-bucket` (literal) | `ottabase-bucket-preview` (literal) | none | +| Queue | ottabase-queue | ottabase-queue-preview | `ottabase-queue` (literal) | `ottabase-queue-preview` (literal) | none | + +D1 and KV placeholder values = GitHub Secret names (auto-detected and substituted by CI). R2 and Queue use literal names +(no substitution needed). --- @@ -55,12 +69,15 @@ Or find it at: https://dash.cloudflare.com → Workers & Pages (right sidebar) ### Get Resource IDs +Copy from **cf:setup output** (printed at the end), or run: + ```bash wrangler d1 list # Get D1_DATABASE_ID -wrangler kv:namespace list # Get KV_NAMESPACE_ID +wrangler kv namespace list # Get KV_NAMESPACE_ID ``` -Or extract from `apps/ottabase-template-app/wrangler.jsonc` +Note: `wrangler.jsonc` contains `ALL_CAPS_SNAKE_CASE` placeholder values that are auto-detected and substituted from +GitHub Secrets at deploy time. No explicit key list needed. --- @@ -68,18 +85,26 @@ Or extract from `apps/ottabase-template-app/wrangler.jsonc` Go to: GitHub repository → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** -Add these **4 required secrets:** +**Production (main deploy):** + +| Secret Name | Description | Where to Get | +| ----------------------- | -------------------------- | ----------------------------------------------- | +| `CLOUDFLARE_API_TOKEN` | API token for deployments | Step 2 above | +| `CLOUDFLARE_ACCOUNT_ID` | Your account ID | `wrangler whoami` | +| `D1_DATABASE_ID` | Production D1 database ID | cf:setup output or `wrangler d1 list` | +| `KV_NAMESPACE_ID` | Production KV namespace ID | cf:setup output or `wrangler kv namespace list` | -| Secret Name | Description | Where to Get | -| ----------------------- | -------------------------- | ---------------------------- | -| `CLOUDFLARE_API_TOKEN` | API token for deployments | Step 2 above | -| `CLOUDFLARE_ACCOUNT_ID` | Your account ID | `wrangler whoami` | -| `D1_DATABASE_ID` | Production D1 database ID | `wrangler d1 list` | -| `KV_NAMESPACE_ID` | Production KV namespace ID | `wrangler kv:namespace list` | +**PR preview (uses isolated preview D1/KV/R2):** -**Optional:** +| Secret Name | Description | Where to Get | +| ------------------------- | -------------------------------- | --------------- | +| `D1_PREVIEW_DATABASE_ID` | Preview D1 (ottabase-db-preview) | cf:setup output | +| `KV_PREVIEW_NAMESPACE_ID` | Preview KV (OBCF_KV_preview) | cf:setup output | -- `D1_DATABASE_NAME` (defaults to `ottabase-db`) +> **Multi-app:** Same placeholder name across apps → same GitHub Secret → shared resource. Different names → isolated. +> Prefixing (e.g. `APP_1_D1_DATABASE_ID`) is a convention for clarity, not a requirement. Only 2 steps: set the +> placeholder in `wrangler.jsonc`, add the matching GitHub Secret. CI auto-detects the rest. See +> [.github/DEPLOYMENT.md](.github/DEPLOYMENT.md#extending-the-system) for a walkthrough. --- @@ -124,19 +149,19 @@ Watch in GitHub Actions: ### Find Your Worker URL ```bash -wrangler deployments list --name ottabase-template-app +wrangler deployments list --name ottabase-template-app-tanstack ``` -Or: https://dash.cloudflare.com → Workers & Pages → ottabase-template-app +Or: https://dash.cloudflare.com → Workers & Pages → ottabase-template-app-tanstack ### Test Your App ```bash # Visit in browser -https://ottabase-template-app.your-subdomain.workers.dev +https://ottabase-template-app-tanstack.your-subdomain.workers.dev # Check logs -wrangler tail ottabase-template-app +wrangler tail ottabase-template-app-tanstack ``` --- @@ -146,8 +171,8 @@ wrangler tail ottabase-template-app ### "Resource not found" errors ```bash -pnpm cloudflare:setup -pnpm cloudflare:validate +pnpm cf:setup +pnpm cf:validate ``` Then update GitHub secrets with new IDs. @@ -183,11 +208,11 @@ pnpm install pnpm dev # Manual deployment (bypass CI) -cd apps/ottabase-template-app -pnpm build && pnpm build:worker && pnpm wrangler deploy --env production +cd apps/ottabase-template-app-tanstack +pnpm build && pnpm wrangler deploy --env production # View logs -wrangler tail ottabase-template-app +wrangler tail ottabase-template-app-tanstack # Execute D1 commands wrangler d1 execute ottabase-db --remote --command="SELECT * FROM User LIMIT 5" @@ -210,9 +235,10 @@ Defined in `.github/workflows/deploy.yml` - triggers on push to `main`: ### Important Files -- `.github/workflows/deploy.yml` - CI/CD workflow -- `apps/ottabase-template-app/wrangler.jsonc` - Cloudflare config -- `apps/ottabase-template-app/db.config.ts` - Database config +- `.github/workflows/deploy.yml` - CI/CD workflow (auto-detects placeholders in wrangler.jsonc and substitutes from + GitHub Secrets via substitute-wrangler-secrets.py) +- `apps/ottabase-template-app-tanstack/wrangler.jsonc` - Cloudflare config (template with `ALL_CAPS` placeholder values; + CI generates wrangler.production.jsonc) ### Cloudflare Bindings @@ -232,11 +258,11 @@ See [CLOUDFLARE_CONFIGURATION_GUIDE.md](CLOUDFLARE_CONFIGURATION_GUIDE.md) for u ## Setup Checklist - [ ] Install wrangler: `npm install -g wrangler` -- [ ] Login: `wrangler login` -- [ ] Create resources: `pnpm cloudflare:setup` -- [ ] Validate: `pnpm cloudflare:validate` -- [ ] Get credentials (Account ID, API Token, Resource IDs) -- [ ] Add 4 GitHub secrets +- [ ] Login: `wrangler login` or `pnpm cf:login` +- [ ] Create resources: `pnpm cf:setup` (copy output IDs for GitHub Secrets) +- [ ] Validate: `pnpm cf:validate` +- [ ] Add production secrets (D1_DATABASE_ID, KV_NAMESPACE_ID) +- [ ] Add PR preview secrets (D1_PREVIEW_DATABASE_ID, KV_PREVIEW_NAMESPACE_ID) - [ ] Push to main branch - [ ] Verify deployment diff --git a/README.COMPENDIUM.DJ b/README.COMPENDIUM.DJ index 13eaab6d7..d9c2310d7 100644 --- a/README.COMPENDIUM.DJ +++ b/README.COMPENDIUM.DJ @@ -1,5 +1,12 @@ Quick reference for all things related to this monorepo; +#### Get started +pnpm i +pnpm build:pkg +pnpm dev --noopen # BE+FE Template App +pnpm dev:homepage # Start nextjs homepage app + + # ============================================================================= # TESTING @@ -45,15 +52,15 @@ pnpm test:ui # Vitest UI (interactive) Thoughts ============== -Worked on nextjs variant for 1.5 years. -Tweaking nuances +Worked on nextjs variant for 1.5 years. +Tweaking nuances Then it hit me: I have AI, and so does everyone who is going to use Ottabsse. Nextjs' advantages are not that important anymore. -Recoded the whole thing with a fresh approach in 6 months. +Recoded the whole thing with a fresh approach in 6 months. -Total time ~2 years in the making :-< +Total time ~2 years in the making :-< Ah well, the best time to plant a tree was 20 years ago. The second best time is `now`. diff --git a/REFERRAL_SYSTEM.md b/REFERRAL_SYSTEM.md index bdfc8cc0e..69c743eb6 100644 --- a/REFERRAL_SYSTEM.md +++ b/REFERRAL_SYSTEM.md @@ -36,7 +36,7 @@ tracking logs, and user-managed referral usernames. │ 4. Server (processReferralAttribution): │ │ - Looks up User by referralUsername │ │ - Sets new user's referredById │ -│ - Updates ReferralTracking: pending → completed │ +│ - Creates ReferralTracking record (completed) + full context │ │ ↓ │ │ 5. Done! ✅ │ └─────────────────────────────────────────────────────────────────┘ @@ -431,20 +431,26 @@ const referralCode = getStoredReferralCode(); // Or from registration form data const result = await processReferralAttribution({ newUserId: user.id, referralCode: referralCode, + ipAddress: getClientIpAddress(request), + userAgent: request.headers.get('user-agent'), + referer: request.headers.get('referer'), + meta: { utm: { source: body.utm_source, ... }, headers: { ... } }, }); if (result.attributed) { console.log(`User referred by ${result.referrerId}`); - console.log(`Updated ${result.trackingRecordsUpdated} tracking records`); + console.log(`Created ${result.trackingRecordsUpdated} conversion record(s)`); } ``` +**Options:** `ipAddress`, `userAgent`, `referer`, `meta` — passed from request at signup for full conversion context. + **What it does:** 1. Validates referralCode is provided 2. Looks up referrer by referralUsername 3. Sets new user's `referredById` field -4. Updates ReferralTracking records: `pending` → `completed` +4. Creates ReferralTracking record (status `completed`) with ipAddress, userAgent, referer, meta 5. Prevents self-referral ### Model Methods @@ -489,15 +495,19 @@ For production Auth.js integration, you have two options: ```typescript // In your registration form +import { extractUtmParams, getStoredReferralCode } from '@/lib/referrals'; + const referralCode = getStoredReferralCode(); -// Send to server +// Send to server (include UTM params from URL for conversion context) +const utm = extractUtmParams(); await fetch('/api/auth/register', { method: 'POST', body: JSON.stringify({ email, password, referralCode, // Pass from localStorage + ...utm, // utm_source, utm_medium, utm_campaign, etc. }), }); ``` @@ -507,14 +517,17 @@ await fetch('/api/auth/register', { ```typescript // In auth.config.ts or similar callbacks: { - async signIn({ user, account, profile }) { - // Get referral code from request/session context + async signIn({ user, account, profile }, request) { const referralCode = /* extract from request */; if (user.id && referralCode) { await processReferralAttribution({ newUserId: user.id, referralCode, + ipAddress: getClientIpAddress(request), + userAgent: request.headers?.get?.('user-agent'), + referer: request.headers?.get?.('referer'), + meta: { utm: {...}, headers: {...} }, // optional }); } return true; diff --git a/apps/ottabase-template-app-nextjs-homepage/app/providers.tsx b/apps/ottabase-template-app-nextjs-homepage/app/providers.tsx index b8a0339e5..867566684 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/providers.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/providers.tsx @@ -24,9 +24,7 @@ export function Providers({ if (brandKit) { // Use stored light/dark themes if available - const themeToApply = isDark - ? brandKit._darkTheme || brandKit.theme - : brandKit._lightTheme || brandKit.theme; + const themeToApply = isDark ? brandKit.darkTheme || brandKit.theme : brandKit.theme; applyBrandTheme(themeToApply); } diff --git a/apps/ottabase-template-app-nextjs-homepage/cloudflare-config.json b/apps/ottabase-template-app-nextjs-homepage/cloudflare-config.json index 4e16ebb94..53a04ce89 100644 --- a/apps/ottabase-template-app-nextjs-homepage/cloudflare-config.json +++ b/apps/ottabase-template-app-nextjs-homepage/cloudflare-config.json @@ -10,5 +10,7 @@ "wranglerConfig": "wrangler.jsonc", "wranglerEnv": "production", "healthCheckPath": "/", - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"] + "requiresSecrets": [], + "requiresPreviewSecrets": [], + "$comment": "requiresSecrets/requiresPreviewSecrets are optional — wrangler.jsonc placeholders are auto-detected. Add here only for non-wrangler secrets (e.g. build-time)." } diff --git a/apps/ottabase-template-app-nextjs-homepage/lib/brand-server.ts b/apps/ottabase-template-app-nextjs-homepage/lib/brand-server.ts index 6fd877458..f0c7f26c6 100644 --- a/apps/ottabase-template-app-nextjs-homepage/lib/brand-server.ts +++ b/apps/ottabase-template-app-nextjs-homepage/lib/brand-server.ts @@ -31,12 +31,10 @@ export function generateBrandConfig(mode: 'light' | 'dark' = 'light'): FullBrand mode: 'dark', }); - // Use requested mode for initial SSR - const resolvedTheme = mode === 'dark' ? darkTheme : lightTheme; + // Remove requested mode for initial SSR, BrandProvider handles this // Build the full config structure expected by BrandProvider const config: FullBrandConfig = { - mode, kit: 'default', routes: [ ['/', 'homepage', 100], @@ -54,16 +52,14 @@ export function generateBrandConfig(mode: 'light' | 'dark' = 'light'): FullBrand brandName: brandConfig.name || 'Ottabase', tagline: undefined, logos: {}, - theme: resolvedTheme, + theme: lightTheme, + darkTheme: darkTheme, themeBase: themePreset, tenantTheme: brandConfig, defaultColorScheme: 'system', allowDarkModeToggle: true, customCss: undefined, hideOttabaseBranding: false, - // Store both themes for client-side switching - _lightTheme: lightTheme, - _darkTheme: darkTheme, } as any, }, }; diff --git a/apps/ottabase-template-app-tanstack/README.md b/apps/ottabase-template-app-tanstack/README.md index 1c0ac9fcb..92b8aec99 100644 --- a/apps/ottabase-template-app-tanstack/README.md +++ b/apps/ottabase-template-app-tanstack/README.md @@ -91,6 +91,11 @@ AUTH_SESSION_MAX_AGE=2592000 ALLOW_NULL_TENANT=true # allow system-scope (single-founder) admin MULTI_TENANT_ENABLED=true # create personal org on first user (default true) BOOTSTRAP_OWNER_SECRET=supersecret-token + +# Analytics (for /analytics - shortlinks + referrals WAE queries) +CLOUDFLARE_ACCOUNT_ID= # 32-char account ID (wrangler vars) +CLOUDFLARE_ANALYTICS_API_TOKEN= # Secret: Account Analytics Read; set via: pnpm wrangler secret put CLOUDFLARE_ANALYTICS_API_TOKEN +# Bindings: OBCF_ANALYTICS_SHORTLINKS (shortlink_clicks), OBCF_ANALYTICS_REFERRALS (referral_clicks) ``` ### First-user + admin guard @@ -153,6 +158,7 @@ See [ottabase/migrations/README.md](./ottabase/migrations/README.md) for details - **Theme Presets** - 8 built-in presets (Default, Neo, Crisp, Funky, Artisan, Midnight, Rose, Verdant) - **Color Customization** - Override individual colors on top of presets - **Light + Dark Modes** - Separate color palettes for each mode +- **Cursors** - Custom SVG or native cursors, persisted across preset changes - **Logo Upload** - Support for logo, dark logo, icon, and OG image - **CSS Variable Injection** - Automatic theme application via CSS custom properties - **KV Cache** - 1-hour TTL cache for fast brand config reads @@ -160,12 +166,15 @@ See [ottabase/migrations/README.md](./ottabase/migrations/README.md) for details ### Admin UI -Access brand customization at `/admin/brand-kits/[id]`: +Access brand customization at `/admin/brand-engine/kits/[id]`: 1. **Theme Tab** - Select preset, generate palette, override colors -2. **Identity Tab** - Upload logos, customize name/tagline -3. **Typography Tab** - Configure font families -4. **Advanced Tab** - Spacing, radius, shadows, motion settings +2. **Brand Tab** - Name, tagline, parent kit +3. **Logo Tab** - Upload logos (primary, dark, icon, OG image) +4. **Fonts Tab** - Typography for heading, body, handwriting +5. **Motion Tab** - Duration, easing (light/dark split) +6. **Cursors Tab** - Custom cursors per state (shared or light/dark split) +7. **Advanced Tab** - Spacing, radius, shadows, custom CSS ### Architecture @@ -193,6 +202,7 @@ Apply to document via CSS variables - ✅ No runtime theme registry lookups - ✅ Works reliably in Cloudflare Workers (no isolate state issues) - ✅ Custom color overrides merge cleanly on preset base +- ✅ Cursors persist when switching presets (user-configured, not in presets) - ✅ Atomic updates (what you save = what renders) ### API Endpoints @@ -263,7 +273,7 @@ apps/ottabase-template-app-tanstack/ │ └── providers/ # App providers wrapper ├── index.html # HTML template ├── vite.config.ts # Vite configuration -├── wrangler.jsonc # Cloudflare Workers config +├── wrangler.jsonc # Cloudflare Workers config (template; CI substitutes placeholders) └── tailwind.config.cjs # Tailwind CSS config ``` @@ -276,8 +286,8 @@ apps/ottabase-template-app-tanstack/ - `/login` - Login (OAuth / Magic Link / Credentials) - `/register` - Registration (Credentials) - `/dashboard` - Protected route -- `/admin/brand-kits` - Brand kit management (admin only) -- `/admin/brand-kits/:id` - Brand kit editor (Theme/Identity/Typography/Advanced tabs) +- `/admin/brand-engine` - Brand kit list (admin only) +- `/admin/brand-engine/kits/:id` - Brand kit editor (Theme, Brand, Logo, Fonts, Motion, Cursors, Advanced tabs) - `/demo/mantine` - Mantine UI components demo - `/demo/shadcn` - shadcn/ui components demo - `/demo/ottaeditor` - Rich text editor demo @@ -290,6 +300,8 @@ apps/ottabase-template-app-tanstack/ - `/demo/cloudflare/queues` - Queues demo - `/demo/cloudflare/rate-limiting` - Rate limiting demo - `/demo/cloudflare/realtime` - Durable Objects realtime demo +- `/shortlinks` - Shortlink management +- `/analytics` - Unified analytics (Shortlinks + Referrals tabs, WAE) ### API Endpoints @@ -300,6 +312,8 @@ apps/ottabase-template-app-tanstack/ - `/api/auth/register` - Credentials registration - `/api/auth/config` - Auth UI configuration - `/api/ottaorm/*` - OttaORM CRUD endpoints +- `/api/shortlinks/analytics` - Shortlink clicks (powers /analytics Shortlinks tab) +- `/api/referrals/analytics` - Referral clicks (powers /analytics Referrals tab) ## Using Cloudflare Bindings @@ -376,7 +390,22 @@ Update the IDs in `wrangler.jsonc` with your actual: - R2 bucket name - Queue name -#### 3. Deploy +#### 3. Analytics (optional) + +Shortlink and referral click tracking uses **Cloudflare Analytics Engine** (WAE). Clicks are written automatically; the +unified analytics page at `/analytics` requires: + +1. **CLOUDFLARE_ACCOUNT_ID** – Set in `wrangler.jsonc` vars (32-char account ID from Cloudflare dashboard). + +2. **CLOUDFLARE_ANALYTICS_API_TOKEN** – Create a token with **Account | Account Analytics | Read**: + + ```bash + pnpm wrangler secret put CLOUDFLARE_ANALYTICS_API_TOKEN + ``` + + When prompted, paste your token. Without this, `/analytics` returns 503. + +#### 4. Deploy ```bash # Deploy to Cloudflare Workers diff --git a/apps/ottabase-template-app-tanstack/cloudflare-config.json b/apps/ottabase-template-app-tanstack/cloudflare-config.json index 555fa4c5d..0b3dc80c9 100644 --- a/apps/ottabase-template-app-tanstack/cloudflare-config.json +++ b/apps/ottabase-template-app-tanstack/cloudflare-config.json @@ -10,5 +10,7 @@ "wranglerConfig": "wrangler.jsonc", "wranglerEnv": "production", "healthCheckPath": "/", - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "D1_DATABASE_ID", "KV_NAMESPACE_ID"] + "requiresSecrets": [], + "requiresPreviewSecrets": [], + "$comment": "requiresSecrets/requiresPreviewSecrets are optional — wrangler.jsonc placeholders are auto-detected. Add here only for non-wrangler secrets (e.g. build-time)." } diff --git a/apps/ottabase-template-app-tanstack/drizzle.config.ts b/apps/ottabase-template-app-tanstack/drizzle.config.ts index 8a7120e51..47fb9f769 100644 --- a/apps/ottabase-template-app-tanstack/drizzle.config.ts +++ b/apps/ottabase-template-app-tanstack/drizzle.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ // D1 credentials (set via environment variables or wrangler) dbCredentials: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID || '', - databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID || '', + databaseId: process.env.D1_DATABASE_ID || '', token: process.env.CLOUDFLARE_API_TOKEN || '', }, diff --git a/apps/ottabase-template-app-tanstack/index.html b/apps/ottabase-template-app-tanstack/index.html index 89ace37b3..b5e0c775d 100644 --- a/apps/ottabase-template-app-tanstack/index.html +++ b/apps/ottabase-template-app-tanstack/index.html @@ -1,6 +1,15 @@ - + + + diff --git a/apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts b/apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts new file mode 100644 index 000000000..b3f5115e6 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts @@ -0,0 +1,105 @@ +import { processReferralAttribution } from '../referral-attribution'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@ottabase/ottaorm/models', () => ({ + User: { + findByReferralUsername: vi.fn(), + find: vi.fn(), + }, +})); + +vi.mock('@ottabase/referrals', () => ({ + ReferralTracking: { + create: vi.fn(), + }, +})); + +const { User } = await import('@ottabase/ottaorm/models'); +const { ReferralTracking } = await import('@ottabase/referrals'); + +describe('processReferralAttribution', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns attributed: false when no referralCode', async () => { + const result = await processReferralAttribution({ newUserId: 'u1', referralCode: '' }); + expect(result.attributed).toBe(false); + expect(result.trackingRecordsUpdated).toBe(0); + expect(User.findByReferralUsername).not.toHaveBeenCalled(); + }); + + it('returns attributed: false when referrer not found', async () => { + vi.mocked(User.findByReferralUsername).mockResolvedValue(null); + const result = await processReferralAttribution({ + newUserId: 'u1', + referralCode: 'unknown', + }); + expect(result.attributed).toBe(false); + expect(result.error).toBe('Referrer not found'); + expect(ReferralTracking.create).not.toHaveBeenCalled(); + }); + + it('returns attributed: false on self-referral', async () => { + const referrer = { get: (k: string) => (k === 'id' ? 'u1' : null) }; + vi.mocked(User.findByReferralUsername).mockResolvedValue(referrer as any); + const result = await processReferralAttribution({ + newUserId: 'u1', + referralCode: 'me', + }); + expect(result.attributed).toBe(false); + expect(result.error).toBe('Self-referral not allowed'); + expect(ReferralTracking.create).not.toHaveBeenCalled(); + }); + + it('creates ReferralTracking with context when attribution succeeds', async () => { + const referrer = { get: (k: string) => (k === 'id' ? 'ref-1' : null) }; + const newUser = { set: vi.fn(), save: vi.fn().mockResolvedValue(undefined) }; + vi.mocked(User.findByReferralUsername).mockResolvedValue(referrer as any); + vi.mocked(User.find).mockResolvedValue(newUser as any); + vi.mocked(ReferralTracking.create).mockResolvedValue({} as any); + + const result = await processReferralAttribution({ + newUserId: 'u1', + referralCode: 'johndoe', + ipAddress: '1.2.3.4', + userAgent: 'Mozilla/5.0', + referer: 'https://twitter.com/', + meta: { utm: { source: 'twitter' }, headers: { 'accept-language': 'en' } }, + }); + + expect(result.attributed).toBe(true); + expect(result.trackingRecordsUpdated).toBe(1); + expect(ReferralTracking.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'ref-1', + referralCode: 'johndoe', + referredUserId: 'u1', + status: 'completed', + ipAddress: '1.2.3.4', + userAgent: 'Mozilla/5.0', + referer: 'https://twitter.com/', + meta: { utm: { source: 'twitter' }, headers: { 'accept-language': 'en' } }, + }), + ); + }); + + it('passes null for optional context when not provided', async () => { + const referrer = { get: (k: string) => (k === 'id' ? 'ref-1' : null) }; + const newUser = { set: vi.fn(), save: vi.fn().mockResolvedValue(undefined) }; + vi.mocked(User.findByReferralUsername).mockResolvedValue(referrer as any); + vi.mocked(User.find).mockResolvedValue(newUser as any); + vi.mocked(ReferralTracking.create).mockResolvedValue({} as any); + + await processReferralAttribution({ newUserId: 'u1', referralCode: 'johndoe' }); + + expect(ReferralTracking.create).toHaveBeenCalledWith( + expect.objectContaining({ + ipAddress: null, + userAgent: null, + referer: null, + meta: null, + }), + ); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts b/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts index c97e2cb2f..ecd9acdbb 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts @@ -11,6 +11,15 @@ import { ReferralTracking } from '@ottabase/referrals'; export interface ReferralAttributionOptions { newUserId: string; referralCode: string; + /** Click/conversion context — captured at signup time */ + ipAddress?: string | null; + userAgent?: string | null; + referer?: string | null; + meta?: { + utm?: { source?: string; medium?: string; campaign?: string; term?: string; content?: string }; + headers?: Record; + [key: string]: unknown; + } | null; } export interface ReferralAttributionResult { @@ -27,9 +36,9 @@ export interface ReferralAttributionResult { * This function: * 1. Looks up the referrer by referralUsername * 2. Sets the new user's referredById field - * 3. Updates ReferralTracking records from pending to completed + * 3. Creates ReferralTracking record (status completed) with ipAddress, userAgent, referer, meta * - * @param options - Attribution options + * @param options - Attribution options (ipAddress, userAgent, referer, meta optional for conversion context) * @returns Attribution result */ export async function processReferralAttribution( @@ -77,14 +86,19 @@ export async function processReferralAttribution( await newUser.save(); } - // 3. Update ReferralTracking records from pending to completed - const pendingRecords = await ReferralTracking.findPendingByCode(referralCode); - - let updatedCount = 0; - for (const record of pendingRecords) { - await record.markCompleted(newUserId); - updatedCount++; - } + // 3. Create conversion record (clicks are in WAE; only conversions go to D1) + // Capture full context — ipAddress, userAgent, referer, meta (UTM params, headers) + await ReferralTracking.create({ + userId: referrerId, + referralCode, + referredUserId: newUserId, + status: 'completed', + conversionAt: Date.now(), + ipAddress: options.ipAddress ?? null, + userAgent: options.userAgent ?? null, + referer: options.referer ?? null, + meta: options.meta ?? null, + }); console.log(`Referral attribution successful: User ${newUserId} referred by ${referrerId} (${referralCode})`); @@ -92,7 +106,7 @@ export async function processReferralAttribution( attributed: true, referrerId, referralCode, - trackingRecordsUpdated: updatedCount, + trackingRecordsUpdated: 1, }; } catch (error) { console.error('Error processing referral attribution:', error); diff --git a/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts b/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts index c7eafd541..5c027af77 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts @@ -38,7 +38,7 @@ const coreMigrations: Migration[] = [ // These run AFTER automatic table creation from Models. // // Use cases: -// - Data seeding +// - Data seeding (prefer ORM in init/seed handlers — see ensureAppBrandDefaults) // - Custom indexes // - Database views // - Triggers @@ -57,6 +57,9 @@ const coreMigrations: Migration[] = [ // } const appSpecificMigrations: Migration[] = [ // Add your custom app-specific migrations here + // Default brand kit + route mappings seeded via ensureAppBrandDefaults (ORM) + // in bootstrap handleSeed and api/ottaorm init — uses BrandKit.getOrCreateDefault + // and DEFAULT_ROUTE_MAPPINGS from @ottabase/brand-engine ]; // ============================================================ diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index 00e6717a7..bed7b732c 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -27,6 +27,7 @@ "@mantine/hooks": "catalog:", "@mantine/modals": "catalog:", "@mantine/notifications": "catalog:", + "@ottabase/analytics": "workspace:*", "@ottabase/api": "workspace:*", "@ottabase/auth": "workspace:*", "@ottabase/brand-engine": "workspace:*", @@ -36,6 +37,7 @@ "@ottabase/config": "workspace:*", "@ottabase/cropper": "workspace:*", "@ottabase/db": "workspace:*", + "@ottabase/docs": "workspace:*", "@ottabase/email": "workspace:*", "@ottabase/forms": "workspace:*", "@ottabase/i18n": "workspace:*", diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/Shortlink.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/Shortlink.test.ts index 165c75f68..e9862ff3d 100644 --- a/apps/ottabase-template-app-tanstack/src/__tests__/Shortlink.test.ts +++ b/apps/ottabase-template-app-tanstack/src/__tests__/Shortlink.test.ts @@ -22,8 +22,6 @@ describe('Shortlink Model', () => { type: 'redirect', appId: 'test', expiryDate: null, - clicks: 0, - lastClickedAt: null, createdAt: Date.now(), updatedAt: Date.now(), }, @@ -52,8 +50,6 @@ describe('Shortlink Model', () => { type: 'redirect', appId: 'test', expiryDate: null, - clicks: 0, - lastClickedAt: null, createdAt: Date.now(), updatedAt: Date.now(), }, diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/router.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/router.test.ts new file mode 100644 index 000000000..0c8542e0e --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/__tests__/router.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { router } from '../router'; + +describe('Router config', () => { + it('root route has loader to trigger pending state for lazy routes', () => { + const rootRoute = router.routesById['__root__'] as any; + expect(rootRoute.options?.loader).toBeDefined(); + }); + + it('router uses RouteLoadingFallback as defaultPendingComponent', () => { + const comp = (router as any).options?.defaultPendingComponent; + expect(comp).toBeDefined(); + expect(typeof comp).toBe('function'); + }); + + it('router has defaultPendingMs 0 to show loading immediately', () => { + expect((router as any).options?.defaultPendingMs).toBe(0); + }); + + it('root route uses NotFoundPage as notFoundComponent', () => { + const rootRoute = router.routesById['__root__'] as any; + expect(rootRoute.options?.notFoundComponent).toBeDefined(); + // NotFoundPage is a named export - we check the component exists + const comp = rootRoute.options.notFoundComponent; + expect(typeof comp).toBe('function'); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts index dc329161f..75a19bb5e 100644 --- a/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts +++ b/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts @@ -172,8 +172,6 @@ describe('Cloudflare Worker API', () => { type: 'redirect', appId: null, expiryDate: null, - clicks: 0, - lastClickedAt: null, createdAt: Date.now(), updatedAt: Date.now(), }, @@ -197,9 +195,7 @@ describe('Cloudflare Worker API', () => { fullUrl: 'https://example.com', type: 'redirect', appId: 'test', - clicks: 0, expiryDate: null, - lastClickedAt: null, createdAt: Date.now(), updatedAt: Date.now(), }; diff --git a/apps/ottabase-template-app-tanstack/src/components/NotFoundPage.tsx b/apps/ottabase-template-app-tanstack/src/components/NotFoundPage.tsx new file mode 100644 index 000000000..af48965f5 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/NotFoundPage.tsx @@ -0,0 +1,39 @@ +import { Button } from '@ottabase/ui-shadcn'; +import { IconHome, IconMapPinOff } from '@tabler/icons-react'; +import { Link } from '@tanstack/react-router'; + +/** + * 404 / Not Found page shown when a route doesn't match. + * Used by TanStack Router's notFoundComponent on the root route. + */ +export function NotFoundPage() { + return ( +
+
+
+ +
+ + 404 + +

Page not found

+

+ The page you're looking for doesn't exist or has been moved. +

+
+
+ + +
+
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/components/RouteLoadingFallback.tsx b/apps/ottabase-template-app-tanstack/src/components/RouteLoadingFallback.tsx new file mode 100644 index 000000000..c94ee4e57 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/RouteLoadingFallback.tsx @@ -0,0 +1,13 @@ +import { Spinner } from '@ottabase/ui-shadcn'; + +/** + * Shown while a lazy route chunk loads (e.g. /docs, /demo). + * Used as router's defaultPendingComponent to avoid showing the previous page during navigation. + */ +export function RouteLoadingFallback() { + return ( +
+ +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/components/__tests__/NotFoundPage.test.tsx b/apps/ottabase-template-app-tanstack/src/components/__tests__/NotFoundPage.test.tsx new file mode 100644 index 000000000..c8048cee7 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/__tests__/NotFoundPage.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ to, children, ...props }: any) => ( + + {children} + + ), +})); + +vi.mock('@ottabase/ui-shadcn', () => ({ + Button: ({ children, asChild, variant, ...props }: any) => ( + + ), +})); + +vi.mock('@tabler/icons-react', () => ({ + IconHome: () => , + IconMapPinOff: () => , +})); + +import { NotFoundPage } from '../NotFoundPage'; + +describe('NotFoundPage', () => { + it('renders 404 heading and message', () => { + render(); + expect(screen.getByText('404')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /page not found/i })).toBeInTheDocument(); + expect(screen.getByText(/the page you're looking for doesn't exist or has been moved/i)).toBeInTheDocument(); + }); + + it('renders icon', () => { + render(); + expect(screen.getByTestId('icon-map-pin-off')).toBeInTheDocument(); + }); + + it('renders Back home link pointing to /', () => { + render(); + const homeLink = screen.getByTestId('link-back-home'); + expect(homeLink).toBeInTheDocument(); + expect(homeLink).toHaveAttribute('href', '/'); + expect(screen.getByText('Back home')).toBeInTheDocument(); + }); + + it('renders Docs link pointing to docs', () => { + render(); + const docsLink = screen.getByTestId('link-docs'); + expect(docsLink).toBeInTheDocument(); + expect(docsLink.getAttribute('href')).toMatch(/\/docs/); + expect(screen.getByText('Docs')).toBeInTheDocument(); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/src/components/__tests__/RouteLoadingFallback.test.tsx b/apps/ottabase-template-app-tanstack/src/components/__tests__/RouteLoadingFallback.test.tsx new file mode 100644 index 000000000..44e2e963b --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/__tests__/RouteLoadingFallback.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@ottabase/ui-shadcn', () => ({ + Spinner: ({ 'aria-label': ariaLabel, ...props }: any) => ( +
+ ), +})); + +import { RouteLoadingFallback } from '../RouteLoadingFallback'; + +describe('RouteLoadingFallback', () => { + it('renders container with route-loading-fallback testid', () => { + render(); + expect(screen.getByTestId('route-loading-fallback')).toBeInTheDocument(); + }); + + it('renders spinner', () => { + render(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); + + it('spinner has loading accessibility label', () => { + render(); + expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/src/hooks/usePageViewTracking.ts b/apps/ottabase-template-app-tanstack/src/hooks/usePageViewTracking.ts new file mode 100644 index 000000000..c0fb4d8fb --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/hooks/usePageViewTracking.ts @@ -0,0 +1,29 @@ +import { useRouterState } from '@tanstack/react-router'; +import { useEffect } from 'react'; + +/** + * Global hook to track page views automatically on route changes. + * Sends a beacon to the WAE analytics endpoint. + */ +export function usePageViewTracking() { + // Get the current location safely + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + useEffect(() => { + if (!pathname) return; + + try { + // Fire-and-forget beacon for analytics + const payload = JSON.stringify({ + event: 'page_view', + metadata: [pathname], + }); + + // sendBeacon is safe to call during component unmount or navigation + navigator.sendBeacon('/api/analytics/track', payload); + } catch (err) { + // Silently ignore tracking errors so they don't break the app + console.warn('Analytics tracking failed:', err); + } + }, [pathname]); +} diff --git a/apps/ottabase-template-app-tanstack/src/main.tsx b/apps/ottabase-template-app-tanstack/src/main.tsx index f4d975bf8..b0965158a 100644 --- a/apps/ottabase-template-app-tanstack/src/main.tsx +++ b/apps/ottabase-template-app-tanstack/src/main.tsx @@ -1,3 +1,4 @@ +import { registerBuiltInThemes } from '@ottabase/brand-engine'; import { RouterProvider } from '@tanstack/react-router'; import React from 'react'; import ReactDOM from 'react-dom/client'; @@ -6,6 +7,9 @@ import { Providers } from './providers/Providers'; import { router } from './router'; import './styles/globals.css'; +// Register built-in themes (default, neo, midnight, etc.) before any component uses getThemeOrDefault +registerBuiltInThemes(); + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/SidebarNav.tsx b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/SidebarNav.tsx index 9b823df7a..4a6924628 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/SidebarNav.tsx +++ b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/SidebarNav.tsx @@ -36,8 +36,8 @@ export const SidebarNav = memo(function SidebarNav({ widthClass = 'w-56' }: { wi to={link.to} className={`px-3 py-2 text-sm rounded-md transition-colors whitespace-nowrap md:whitespace-normal ${ isActive - ? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' - : 'text-sidebar-foreground hover:bg-sidebar-accent/50' + ? 'bg-accent text-accent-foreground font-medium' + : 'text-muted-foreground hover:text-foreground hover:bg-accent/50' }`} > {link.label} diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts index 8fde3aa69..8b396c4ab 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts +++ b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts @@ -9,6 +9,7 @@ export const NAV_LINKS: NavLink[] = [ { to: '/blog', label: 'Blog' }, { to: '/demo', label: 'Demo' }, { to: '/shortlinks', label: 'Links' }, + { to: '/analytics', label: 'Analytics', authRequired: true }, { to: '/admin', label: 'Admin' }, { to: '/dashboard', label: 'Dashboard', authRequired: true }, { to: '/referrals', label: 'Referrals', authRequired: true }, diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBrandKitDetailPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBrandKitDetailPage.tsx index 553e643c4..9109b7bde 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBrandKitDetailPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBrandKitDetailPage.tsx @@ -7,10 +7,13 @@ import { useBrand } from '@ottabase/brand-engine-react'; import { useApiQuery } from '@ottabase/ottaorm/client'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@ottabase/ui-shadcn'; import { + IconActivity, IconArrowLeft, IconBadge, + IconDownload, IconPalette, IconPhoto, + IconPointer, IconSettings, IconTrash, IconTypography, @@ -22,13 +25,15 @@ import { toast } from 'sonner'; import { brandKitApi, type BrandKitItem } from './brand/brandApi'; import { BrandKitAdvancedTab } from './brand/BrandKitAdvancedTab'; import { BrandKitBrandTab } from './brand/BrandKitBrandTab'; +import { BrandKitCursorsTab } from './brand/BrandKitCursorsTab'; import { BrandKitFontsTab } from './brand/BrandKitFontsTab'; import { BrandKitLogoTab } from './brand/BrandKitLogoTab'; -import { BrandKitThemeTab } from './brand/BrandKitThemeTab'; +import { BrandKitMotionTab } from './brand/BrandKitMotionTab'; +import { BrandKitThemeTab, colorSwatchClass } from './brand/BrandKitThemeTab'; -const VALID_TABS = ['brand', 'logo', 'theme', 'fonts', 'advanced'] as const; +const VALID_TABS = ['brand', 'logo', 'theme', 'fonts', 'motion', 'cursors', 'advanced'] as const; -/** Preview panel – reflects current draft (colors, fonts) in realtime */ +/** Preview panel – reflects current draft (colors, fonts, motion, shadows) in realtime */ function BrandKitPreviewPanel({ kitData, mode = 'light', @@ -44,6 +49,7 @@ function BrandKitPreviewPanel({ ); const varMap = useMemo(() => buildCSSVarMap(theme), [theme]); const logoUrl = mode === 'dark' ? (logos?.dark ?? logos?.primary) : logos?.primary; + useEffect(() => { const urls: string[] = []; if (theme.typography?.heading?.url) urls.push(theme.typography.heading.url); @@ -51,18 +57,32 @@ function BrandKitPreviewPanel({ if (theme.typography?.handwriting?.url) urls.push(theme.typography.handwriting.url); urls.forEach((url) => injectFont(url)); }, [theme.typography]); + return (
-
+ {/* Background animated element to demonstrate motion tokens */} +
+ +
{logoUrl ? ( Logo @@ -73,46 +93,110 @@ function BrandKitPreviewPanel({ )}

- Preview + Preview UI

- Changes reflect here + Changes reflect instantly

-
+ +

Color palette

{['primary', 'secondary', 'accent', 'muted', 'destructive'].map((token) => (
))}
-
-

Sample UI

-
+ +
+

Shadows & Interactive Elements

+
+ +
+ {['xs', 'sm', 'md', 'lg'].map((level) => ( +
+ --shadow-{level} +
+ ))} +
+
+ +
+

+ Heading Typography +

+

+ Body typography preview demonstrating the selected Google Fonts and precise typographic scaling. +

+

+ Handwriting sample showing custom web fonts +

-

- Handwriting sample -

); @@ -245,6 +329,54 @@ export function AdminBrandKitDetailPage() { if (window.confirm('Delete this Brand Kit? This cannot be undone.')) deleteMutation.mutate(); }; + /** Download kit as ottabase__YYYYMMDD.json – complete backup */ + const handleDownloadKit = () => { + const themeName = + (draft.name || draft.brandName || kitForView.name || 'brand-kit') + .replace(/[^a-zA-Z0-9-_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') || 'brand-kit'; + const appId = kitForView.appId ?? 'default'; + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const filename = `ottabase_${themeName}_${appId}_${date}.json`; + + const payload: Record = { + meta: { exportedAt: new Date().toISOString(), version: 'ottabase-brand-kit-v1' }, + id: kitForView.id, + appId: kitForView.appId, + isDefault: kitForView.isDefault, + parentBrandKitId: draft.parentBrandKitId ?? kitForView.parentBrandKitId, + createdBy: kitForView.createdBy, + updatedBy: kitForView.updatedBy, + name: draft.name || kitForView.name, + slug: kitForView.slug, + brandName: draft.brandName || kitForView.brandName, + tagline: draft.tagline ?? kitForView.tagline, + themePresetId: draft.themePresetId ?? kitForView.themePresetId, + tokensJson: draft.tokensJson?.trim() || kitForView.tokensJson, + defaultColorScheme: draft.defaultColorScheme ?? kitForView.defaultColorScheme, + allowDarkModeToggle: draft.allowDarkModeToggle ?? kitForView.allowDarkModeToggle, + customCss: draft.customCss ?? kitForView.customCss, + hideOttabaseBranding: draft.hideOttabaseBranding ?? kitForView.hideOttabaseBranding, + logoKey: draft.logoKey ?? kitForView.logoKey, + logoDarkKey: draft.logoDarkKey ?? kitForView.logoDarkKey, + iconKey: draft.iconKey ?? kitForView.iconKey, + ogImageKey: draft.ogImageKey ?? kitForView.ogImageKey, + emailLogoKey: draft.emailLogoKey ?? kitForView.emailLogoKey, + createdAt: kitForView.createdAt, + updatedAt: kitForView.updatedAt, + }; + + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + toast.success('Brand Kit downloaded'); + }; + // Stable handlers for child tab components (avoids re-render on every draft change) const handleDraftMerge = useCallback((d: Partial) => setDraft((s) => ({ ...s, ...d })), []); const handleThemePresetChange = useCallback( @@ -304,9 +436,9 @@ export function AdminBrandKitDetailPage() { const saving = isNew ? createMutation.isPending : updateMutation.isPending; return ( -
+
-
+
{isNew || isDefaultKit ? null : (
-
+
setTab(v as (typeof VALID_TABS)[number])} className="w-full"> @@ -368,6 +509,14 @@ export function AdminBrandKitDetailPage() { Fonts + + + Motion + + + + Cursors + Advanced @@ -422,6 +571,12 @@ export function AdminBrandKitDetailPage() { hasParent={!!draft.parentBrandKitId} /> + + + + + + {/* Realtime preview – light and dark stacked */} -
+

Live preview

{hasColorOverrides ? ( diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminReferralTrackingPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminReferralTrackingPage.tsx index a4a110f8a..133b6189e 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminReferralTrackingPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminReferralTrackingPage.tsx @@ -1,6 +1,7 @@ import { useApiQuery } from '@ottabase/ottaorm/client'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@ottabase/ui-shadcn'; -import { Activity, CheckCircle, Clock, XCircle } from 'lucide-react'; +import { CheckCircle, XCircle } from 'lucide-react'; +import { Link } from '@tanstack/react-router'; interface ReferralStats { total: number; @@ -22,6 +23,13 @@ interface ReferralTrackingData { conversionAt: string | null; } +interface TrackingListResponse { + data: ReferralTrackingData[]; + total: number; + page: number; + perPage: number; +} + export function AdminReferralTrackingPage() { const { data: stats, isLoading: statsLoading } = useApiQuery({ entity: 'referrals', @@ -29,12 +37,14 @@ export function AdminReferralTrackingPage() { endpoint: '/api/referrals/stats', }); - const { data: recentTracking, isLoading: trackingLoading } = useApiQuery({ + const { data: trackingResponse, isLoading: trackingLoading } = useApiQuery({ entity: 'referrals', queryKey: ['tracking', 'recent'], - endpoint: '/api/referrals/tracking/recent?limit=20', + endpoint: '/api/referrals/tracking?page=1&perPage=20', }); + const recentTracking = trackingResponse?.data ?? []; + return (
@@ -45,63 +55,51 @@ export function AdminReferralTrackingPage() {
{/* Stats Cards */} -
- - - Total Clicks - - - -
{statsLoading ? '...' : stats?.total || 0}
-

All referral link clicks

-
-
- +
Conversions -
{statsLoading ? '...' : stats?.completed || 0}
-

Successful user signups

+
{statsLoading ? '...' : (stats?.completed ?? 0)}
+

Successful signups from referrals

- Pending - + Invalid + -
{statsLoading ? '...' : stats?.pending || 0}
-

Awaiting conversion

+
{statsLoading ? '...' : (stats?.invalid ?? 0)}
+

Marked invalid

- + - Conversion Rate - + Click Analytics -
- {statsLoading - ? '...' - : stats && stats.total > 0 - ? `${((stats.completed / stats.total) * 100).toFixed(1)}%` - : '0%'} -
-

Clicks to conversions

+ + View analytics (WAE) + +

Clicks by country, code, day

- {/* Recent Tracking Table */} + {/* Recent Conversions Table */} - Recent Referral Activity - Latest 20 referral clicks and conversions + Recent Conversions + Latest 20 referral conversions (D1); clicks are in WAE analytics {trackingLoading ? ( @@ -117,8 +115,7 @@ export function AdminReferralTrackingPage() { Code Referrer Referred User - Click Date - Conversion Date + Converted @@ -152,11 +149,10 @@ export function AdminReferralTrackingPage() { : '-'} - {new Date(tracking.createdAt).toLocaleString()} - - - {tracking.conversionAt - ? new Date(tracking.conversionAt).toLocaleString() + {tracking.conversionAt || tracking.createdAt + ? new Date( + tracking.conversionAt || tracking.createdAt, + ).toLocaleString() : '-'} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogListPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogListPage.tsx index 2443c51c8..d9c289451 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogListPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogListPage.tsx @@ -49,7 +49,6 @@ interface BlogPost { status: PostStatus; authorName: string | null; isFeatured: boolean; - viewCount: number; readingTimeMinutes: number | null; publishedAt: string | null; createdAt: string; @@ -303,10 +302,6 @@ export function AdminBlogListPage() { {post.readingTimeMinutes ? `${post.readingTimeMinutes} min read` : '—'} - - - {post.viewCount} views - {post.authorName && by {post.authorName}} {post.status === 'published' diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/brand/BrandKitCursorsTab.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/brand/BrandKitCursorsTab.tsx new file mode 100644 index 000000000..e53005231 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/brand/BrandKitCursorsTab.tsx @@ -0,0 +1,390 @@ +import type { TokenCursors } from '@ottabase/brand-engine'; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Switch, +} from '@ottabase/ui-shadcn'; +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import { useCallback, useMemo, useState } from 'react'; + +/** Convert raw SVG string to CSS cursor data URI */ +function svgToCursorUri(svg: string): string { + const trimmed = svg.trim(); + const encoded = encodeURIComponent(trimmed); + return `url("data:image/svg+xml;utf8,${encoded}"), auto`; +} + +/** Extract raw SVG from stored cursor value (url data URI or svg: prefix) */ +function extractSvgFromCursor(val: string): string { + if (!val) return ''; + const trimmed = val.trim(); + if (trimmed.toLowerCase().startsWith('svg:')) return trimmed.slice(4).trim(); + // Match url("data:image/svg+xml;utf8,ENCODED") or similar + const urlMatch = trimmed.match(/url\s*\(\s*["']?data:image\/svg\+xml(?:;utf8)?,([^"')]+)["']?\s*\)/i); + if (urlMatch) { + try { + return decodeURIComponent(urlMatch[1].replace(/"/g, '')); + } catch { + return ''; + } + } + return ''; +} + +/** Basic SVG validation – must contain 0 && /]/i.test(trimmed); +} + +/** Dangerous patterns that could execute JS or cause XSS */ +const UNSAFE_SVG_PATTERNS: { pattern: RegExp; msg: string }[] = [ + { pattern: /]/i, msg: 'Script tags are not allowed' }, + { pattern: /javascript\s*:/i, msg: 'javascript: URLs are not allowed' }, + { pattern: /vbscript\s*:/i, msg: 'vbscript: URLs are not allowed' }, + { pattern: /data\s*:\s*text\s*\/\s*html/i, msg: 'Embedded HTML data URIs are not allowed' }, + { pattern: /\bon\w+\s*=/i, msg: 'Event handlers (onload, onclick, etc.) are not allowed' }, + { pattern: /<\s*foreignObject/i, msg: 'foreignObject is not allowed (can embed HTML)' }, + { pattern: /<\s*object[\s>]/i, msg: 'object tags are not allowed' }, + { pattern: /<\s*embed[\s>]/i, msg: 'embed tags are not allowed' }, + { pattern: /<\s*iframe/i, msg: 'iframe tags are not allowed' }, + { pattern: /<\s*\?xml/i, msg: 'XML processing instructions are not allowed' }, +]; + +/** Validate SVG is safe (no script, event handlers, or embedded HTML). Returns error message if unsafe. */ +function validateSvgSafety(svg: string): string | null { + const trimmed = svg.trim(); + if (!trimmed) return null; + for (const { pattern, msg } of UNSAFE_SVG_PATTERNS) { + if (pattern.test(trimmed)) return msg; + } + return null; +} + +interface BrandKitCursorsTabProps { + tokensJson: string; + onTokensChange: (tokensJson: string) => void; +} + +export function BrandKitCursorsTab({ tokensJson, onTokensChange }: BrandKitCursorsTabProps) { + // Edit modal state: which cursor state + mode we're editing + const [editModal, setEditModal] = useState<{ + open: boolean; + mode: 'light' | 'dark' | 'shared'; + state: string; + svgContent: string; + error: string | null; + } | null>(null); + + // Parse the current cursors from the main tokensJson config + const parsed = useMemo(() => { + try { + const p = JSON.parse(tokensJson || '{}'); + // Cursors are stored at the root of the theme alongside tokens, wait no... + // the implementation plan says TokenCursors is in tokens.ts DesignTokens. + // Oh right, `cursors` was added to `tokens` vs root BrandTheme. + // Actually resolver.ts maps merged.cursors to resolved.cursors. + return p.cursors || {}; + } catch { + return {}; + } + }, [tokensJson]); + + // Check if cursors are split into light/dark mode + const isSplitMode = useMemo(() => { + return Boolean(parsed.light || parsed.dark); + }, [parsed]); + + const activeLight = isSplitMode ? parsed.light || {} : parsed; + const activeDark = isSplitMode ? parsed.dark || activeLight : activeLight; + + const handleOverrideToggle = useCallback( + (enabled: boolean) => { + if (!enabled) { + try { + const p = JSON.parse(tokensJson || '{}'); + const lightConfig = p.cursors?.light || {}; + p.cursors = { ...lightConfig }; + onTokensChange(JSON.stringify(p, null, 2)); + } catch { + onTokensChange('{}'); + } + } else { + try { + const p = JSON.parse(tokensJson || '{}'); + const baseConfig = p.cursors || {}; + p.cursors = { + light: { ...baseConfig }, + dark: { ...baseConfig }, + }; + onTokensChange(JSON.stringify(p, null, 2)); + } catch { + onTokensChange('{}'); + } + } + }, + [tokensJson, onTokensChange], + ); + + const handleUpdate = (mode: 'light' | 'dark' | 'shared', state: string, val: string) => { + try { + const p = JSON.parse(tokensJson || '{}'); + p.cursors = p.cursors || {}; + + if (mode === 'shared') { + p.cursors = { ...p.cursors, [state]: val || undefined }; + } else { + p.cursors[mode] = { ...(p.cursors[mode] || {}), [state]: val || undefined }; + } + onTokensChange(JSON.stringify(p, null, 2)); + } catch { + // Error silently on manual typed JSON failures + } + }; + + const openSvgEditModal = useCallback( + (mode: 'light' | 'dark' | 'shared', state: string) => { + const config = mode === 'shared' ? activeLight : mode === 'light' ? activeLight : activeDark; + const currentVal = config[state] || ''; + const svgContent = extractSvgFromCursor(currentVal); + setEditModal({ + open: true, + mode, + state, + svgContent, + error: null, + }); + }, + [activeLight, activeDark], + ); + + const applySvgFromModal = useCallback(() => { + if (!editModal) return; + const { mode, state, svgContent } = editModal; + if (!isValidSvgInput(svgContent)) { + setEditModal((m) => (m ? { ...m, error: 'Enter valid SVG content (must contain )' } : m)); + return; + } + const safetyError = validateSvgSafety(svgContent); + if (safetyError) { + setEditModal((m) => (m ? { ...m, error: `Unsafe SVG: ${safetyError}` } : m)); + return; + } + const cursorUri = svgToCursorUri(svgContent); + handleUpdate(mode, state, cursorUri); + setEditModal(null); + }, [editModal, tokensJson, onTokensChange]); + + const clearSvgFromModal = useCallback(() => { + if (!editModal) return; + handleUpdate(editModal.mode, editModal.state, ''); + setEditModal(null); + }, [editModal, tokensJson, onTokensChange]); + + /** All cursor states that can be themed (matches globals.css bindings) */ + const CURSOR_STATES = [ + 'default', + 'pointer', + 'text', + 'grab', + 'grabbing', + 'crosshair', + 'not-allowed', + 'help', + 'wait', + 'move', + ] as const; + + const renderControls = (mode: 'light' | 'dark' | 'shared', config: Partial) => { + return ( +
+ {mode !== 'shared' &&

{mode} Mode Overrides

} + + {CURSOR_STATES.map((state) => { + const currentVal = config[state] || ''; + const svgContent = extractSvgFromCursor(currentVal); + const hasSvg = !!svgContent; + const isSafeSvg = hasSvg && !validateSvgSafety(svgContent); + return ( +
+ +
+ handleUpdate(mode, state, e.target.value)} + className="flex-1" + /> + +
+ {hasSvg && ( +
+ Preview: + {isSafeSvg ? ( +
+ +
+ ) : ( +
+ Unsafe +
+ )} +
+ )} + {state === 'default' && ( +

+ Accepts native CSS cursors, or use Edit to paste SVG content. +

+ )} +
+ ); + })} +
+ ); + }; + + return ( + <> + + + Cursors + + Configure native or entirely customized mouse cursors using Data URIs. Supports defining + distinct cursors for light vs dark mode backgrounds. + + + +
+
+ +

+ For custom SVG cursors, you likely need a different coloured SVG for dark backgrounds. +

+
+ +
+ + {!isSplitMode ? ( + renderControls('shared', activeLight) + ) : ( +
+ {renderControls('light', activeLight)} + {renderControls('dark', activeDark)} +
+ )} +
+
+ + {/* SVG Edit Modal */} + !open && setEditModal(null)}> + + + Edit SVG Cursor{editModal ? ` – ${editModal.state}` : ''} + + Paste SVG markup below. It will be used as a custom cursor. Recommended size: 24×24 or + 32×32. + + +
+
+ +