diff --git a/.agents/skills/data/SKILL.md b/.agents/skills/data/SKILL.md index d4fd64f3..bc7df6ed 100644 --- a/.agents/skills/data/SKILL.md +++ b/.agents/skills/data/SKILL.md @@ -136,7 +136,7 @@ reward amounts — goes in `/server/constants/.ts`, not mixed into a service file. ``` -layers/auth/server/constants/defaults.ts SYSTEM_ORG, DEMO_ORG, SYSTEM_GRANTS, DEMO_ORG_GRANTS, SEED_USERS +layers/auth/server/constants/defaults.ts SYSTEM_ORG, DEMO_ORG, SYSTEM_GRANTS, SEED_USERS layers/notification/server/constants/defaults.ts DEMO_NOTIFICATIONS layers/support/server/constants/defaults.ts DEMO_TICKETS layers/referral/server/constants/defaults.ts REFERRAL_REWARD @@ -219,7 +219,7 @@ item, or a server endpoint **must** be paired with a permission review: `TENANT_ABILITY_KEYS`, or `SELF_ABILITY_KEYS`). 2. Does any default grant set need updating? `DEFAULT_PERSONAL_ORG_ABILITIES`, `DEFAULT_MEMBER_ABILITIES`, - `SYSTEM_GRANTS`, `DEMO_ORG_GRANTS` — each may need the new key. + `DEFAULT_ROLE_ABILITIES`, `SYSTEM_GRANTS` — each may need the new key. 3. Are existing live users in production missing the new ability? Write an `update:` task that backfills it (e.g. `update/user-abilities.ts`). diff --git a/.agents/skills/data/references/permission-aware-data.md b/.agents/skills/data/references/permission-aware-data.md index abd06d58..19182140 100644 --- a/.agents/skills/data/references/permission-aware-data.md +++ b/.agents/skills/data/references/permission-aware-data.md @@ -12,7 +12,8 @@ abilities live in three places that must stay synchronised: | File | Role | |---|---| | `layers/auth/shared/permissions.ts` | The **catalog** — which ability keys exist and which org kind (system vs tenant) may hold them | -| `layers/auth/server/services/seed.ts` | The **default grants** — `SYSTEM_GRANTS`, `DEMO_ORG_GRANTS`, plus `DEFAULT_PERSONAL_ORG_ABILITIES` / `DEFAULT_MEMBER_ABILITIES` exported from `shared/permissions.ts` | +| `layers/auth/server/constants/defaults.ts` | `SYSTEM_GRANTS` (system-org only grants) | +| `layers/auth/shared/permissions.ts` | `DEFAULT_ROLE_ABILITIES` — demo/tenant tier grants (admin/member/guest); also `DEFAULT_PERSONAL_ORG_ABILITIES` / `DEFAULT_MEMBER_ABILITIES` | | `layers/auth/server/tasks/create/admin.ts` (via `createSystemAdmin`) | The **production admin's grant set** — derived from `SYSTEM_GRANTS.admin` | Drift between any of these three is a permission bug waiting to ship. @@ -63,7 +64,7 @@ Walk the four grant sources: | Grant | Who gets it | When to add the new key | |---|---|---| | `SYSTEM_GRANTS.admin` (in `services/seed.ts`) | Production system admin (via `create:admin`) and dev/demo admins | If this is a system-level power (`*:manage`, `*:audit`) | -| `DEMO_ORG_GRANTS.{admin, member, guest}` | Demo tenant org seed users | If the new key is a tenant ability — pick which tier needs it | +| `DEFAULT_ROLE_ABILITIES[DefaultRole.{ADMIN,MEMBER,GUEST}]` (in `shared/permissions.ts`) | Demo tenant org seed users and `roles:sync` materialized grants | If the new key is a tenant ability — pick which tier needs it | | `DEFAULT_PERSONAL_ORG_ABILITIES` (in `shared/permissions.ts`) | Owner of a freshly-created personal org | If new tenants should have it by default | | `DEFAULT_MEMBER_ABILITIES` (in `shared/permissions.ts`) | A user joining an org as a plain member (direct add or invitation accept) | If members (not just admins) should have it | @@ -139,7 +140,7 @@ When deprecating a feature: 1. Remove all `` / `defineAuthorizedHandler` references to the key first (the UI and routes stop using it) -2. Remove the key from `SYSTEM_GRANTS` / `DEMO_ORG_GRANTS` / +2. Remove the key from `SYSTEM_GRANTS` / `DEFAULT_ROLE_ABILITIES` / `DEFAULT_*_ABILITIES` (the seed defaults stop granting it) 3. Write a `refactor/` task that removes the key from existing memberships' `abilities` arrays (idempotent: read row, filter diff --git a/.agents/skills/data/references/task-conventions.md b/.agents/skills/data/references/task-conventions.md index 4fab7ffc..313c2f0b 100644 --- a/.agents/skills/data/references/task-conventions.md +++ b/.agents/skills/data/references/task-conventions.md @@ -162,7 +162,7 @@ service file. ``` layers/auth/server/constants/defaults.ts - SYSTEM_ORG, DEMO_ORG, SYSTEM_GRANTS, DEMO_ORG_GRANTS, SEED_USERS, SeedUserDef + SYSTEM_ORG, DEMO_ORG, SYSTEM_GRANTS, SEED_USERS, SeedUserDef layers/notification/server/constants/defaults.ts DEMO_NOTIFICATIONS, DemoNotificationDef diff --git a/.agents/skills/prep/SKILL.md b/.agents/skills/prep/SKILL.md index 8fc2dde0..633c473a 100644 --- a/.agents/skills/prep/SKILL.md +++ b/.agents/skills/prep/SKILL.md @@ -73,7 +73,7 @@ Pick an approach. Use `references/solution-design.md`. "INSERT ..."`. Load the `data` skill for the full convention. - **Permission impact**: list every ability key the plan touches. Identify catalog updates (`layers/auth/shared/permissions.ts`), - grant-set updates (`SYSTEM_GRANTS`, `DEMO_ORG_GRANTS`, + grant-set updates (`SYSTEM_GRANTS`, `DEFAULT_ROLE_ABILITIES`, `DEFAULT_*_ABILITIES`), and whether a live-env backfill (`update:` task) is needed. See `data` skill's `references/permission-aware-data.md`. @@ -119,7 +119,7 @@ Before writing the plan file, verify: writes - Every permission-touching step names the catalog file (`layers/auth/shared/permissions.ts`) and the grant set - (`SYSTEM_GRANTS` / `DEMO_ORG_GRANTS` / `DEFAULT_*_ABILITIES`) it + (`SYSTEM_GRANTS` / `DEFAULT_ROLE_ABILITIES` / `DEFAULT_*_ABILITIES`) it updates Use `references/handoff-to-cook.md` for the full checklist. diff --git a/.agents/skills/verify/references/permission-matrix.md b/.agents/skills/verify/references/permission-matrix.md index 1faec1ac..f312e6f5 100644 --- a/.agents/skills/verify/references/permission-matrix.md +++ b/.agents/skills/verify/references/permission-matrix.md @@ -31,7 +31,7 @@ verifies everyone else. | anonymous | none | public routes only | The demo org seeds three membership tiers (`admin`, `member`, `guest`) -— see `DEMO_ORG_GRANTS` in `seed.ts`. Use those tiers when the change +— see `DEFAULT_ROLE_ABILITIES` in `shared/permissions.ts`. Use those tiers when the change adds tenant-scoped abilities. If a seed user is missing in the local DB: diff --git a/.env.example b/.env.example index 592786b2..8276937f 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,6 @@ NUXT_PUBLIC_BASE_DOMAIN=localhost:3002 NUXT_PUBLIC_SSL_ENABLED=false NUXT_PUBLIC_DEMO_MODE=false -# Server-side gate for dev/demo-only auth routes and module stripping -# (eslint/test-utils/nuxt-security). Keep `false` outside the demo deploy. -NUXT_DEMO_MODE=false - # ============================================================================= # Auth secrets — generate with `pnpm auth:generate` # ============================================================================= @@ -45,6 +41,12 @@ NUXT_GOOGLE_CLIENT_SECRET= NUXT_GITHUB_CLIENT_ID= NUXT_GITHUB_CLIENT_SECRET= +# ============================================================================= +# Github release repo +# ============================================================================= +NUXT_GITHUB_REPOSITORY="thecodeorigin/nuxt-template" +NUXT_GITHUB_TOKEN="" + # ============================================================================= # SePay (Vietnamese bank transfer payments) # ============================================================================= diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 6be8d9dc..a7c1d43a 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -144,7 +144,11 @@ jobs: # cron email links; OAuth redirects derive from the live request) and SSL=true. # This step sets only the genuinely secret / preview-specific values: # - NUXT_AUTH_SECRET — shared preview secret (stable so sessions survive re-deploys) - # - NUXT_DEMO_MODE / NUXT_PUBLIC_DEMO_MODE — keep the demo login enabled on previews. + # - NUXT_PUBLIC_BASE_DOMAIN — per-branch workers.dev host, derived from the deploy URL + # (used by non-request contexts like cron email links; OAuth + # redirects auto-derive from the live request). + # - NUXT_PUBLIC_DEMO_MODE — keep the demo login enabled on previews. + # - NUXT_PUBLIC_SSL_ENABLED — workers.dev is always HTTPS. - name: Provision preview Worker secrets env: PREVIEW_NUXT_AUTH_SECRET: ${{ secrets.PREVIEW_NUXT_AUTH_SECRET }} @@ -152,7 +156,8 @@ jobs: cat > "$RUNNER_TEMP/preview-secrets.json" <> "$GITHUB_OUTPUT" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: | + bundle.json + nuxt-template-*-cloudflare.tar.gz + body: | + Self-host deployable bundle for `${{ github.ref_name }}`. + + sha256:${{ steps.hash.outputs.sha256 }} + + **For users self-hosting:** the application running on the upstream + instance will fetch `bundle.json` from this release automatically + when you click Deploy on the Self-hosting settings page. The SHA-256 + above is verified before the worker is uploaded to your account. + + **For manual deploy:** download `nuxt-template-${{ github.ref_name }}-cloudflare.tar.gz`, + extract, and run `wrangler deploy` against your account. diff --git a/.gitignore b/.gitignore index b3e54f2b..3ffdd4c8 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ test-results/ ux/ .wrangler + +.tmp/* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index fae8e3d8..c97e491f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,89 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true -} + "typescript.enablePromptUseWorkspaceTsdk": true, + // Disable the default formatter, use eslint instead + "prettier.enable": false, + "editor.formatOnSave": false, + // Auto fix + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" + }, + // Silent the stylistic rules in your IDE, but still auto fix them + "eslint.rules.customizations": [ + { + "rule": "style/*", + "severity": "off", + "fixable": true + }, + { + "rule": "format/*", + "severity": "off", + "fixable": true + }, + { + "rule": "*-indent", + "severity": "off", + "fixable": true + }, + { + "rule": "*-spacing", + "severity": "off", + "fixable": true + }, + { + "rule": "*-spaces", + "severity": "off", + "fixable": true + }, + { + "rule": "*-order", + "severity": "off", + "fixable": true + }, + { + "rule": "*-dangle", + "severity": "off", + "fixable": true + }, + { + "rule": "*-newline", + "severity": "off", + "fixable": true + }, + { + "rule": "*quotes", + "severity": "off", + "fixable": true + }, + { + "rule": "*semi", + "severity": "off", + "fixable": true + } + ], + // Enable eslint for all supported languages + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "jsonc", + "yaml", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ] +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 8282d904..f8d57ba3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ suggest `/onboard` before any feature work. ## Stack - **Nuxt 4** with **layers** for feature isolation (`layers/auth/`, - `layers/todo/`). Each layer is auto-discovered and contributes its own + `layers/product/`, `layers/project/`, etc.). Each layer is auto-discovered and contributes its own `app/`, `server/`, `shared/`. Cross-cutting infrastructure stays in the project root. - Vue 3, TypeScript @@ -153,9 +153,9 @@ suggest `/onboard` before any feature work. - `layers/auth/shared/permissions.ts` — catalog (`SYSTEM_ABILITY_KEYS`, `TENANT_ABILITY_KEYS`, `DEFAULT_PERSONAL_ORG_ABILITIES`, `DEFAULT_MEMBER_ABILITIES`) - - `layers/auth/server/services/seed.ts` — `SYSTEM_GRANTS` - (production system admin) and `DEMO_ORG_GRANTS` (demo tenant - tiers) + - `layers/auth/server/constants/defaults.ts` — `SYSTEM_GRANTS` + (production system admin); `layers/auth/shared/permissions.ts` — + `DEFAULT_ROLE_ABILITIES` (demo/new-tenant tiers, source of truth) - Live users in deployed envs — need a permission-lifecycle task to backfill the new ability so it reaches existing rows. Sessions auto-refresh; no re-login needed. @@ -296,8 +296,8 @@ ready. Full field reference and the current priority table are in ## API route conventions - File: `/server/api//.{method}.ts` (e.g. - `layers/todo/server/api/todos/index.get.ts`, - `layers/todo/server/api/todos/[id].patch.ts`). + `layers/product/server/api/products/index.get.ts`, + `layers/product/server/api/products/[id].patch.ts`). - Handler: use `defineEventHandler` (or `defineAuthenticatedHandler` for routes that require a session, or `defineAuthorizedHandler` for ability-gated routes). - Validate body with a Zod schema imported from `/shared/schemas/`. @@ -475,8 +475,8 @@ suite). The rate-limiter driver is an in-process `lru-cache` (per Worker isolate). Don't add a `$development.security` or `$test.security` block — the goal is for dev/preview to behave just like production. -`NUXT_DEMO_MODE=true` on the Cloudflare preview/production env ungates -`/api/auth/demo-login` so the deployed app accepts the +`NUXT_PUBLIC_DEMO_MODE=true` on the Cloudflare preview/production env ungates +`/api/auth/demo/login` so the deployed app accepts the "Sign in as Admin/User Agent" buttons. **Don't set it on a non-demo deployment** — it's a deliberate backdoor for the demo project. diff --git a/README.md b/README.md index 4ac69874..5a77ef74 100644 --- a/README.md +++ b/README.md @@ -13,26 +13,35 @@ A Nuxt 4 + NuxtHub starter on a **full Cloudflare stack** (D1 SQLite · KV · R2 baked in. PR → Cloudflare Workers preview. Merge to `main` → production ships. D1 migrations apply during each build. -Everything below is driven by the **`packages/cli` harness** — a non-interactive -CLI exposed as `pnpm cli` that handles env, secrets, local dev, and one-shot -Cloudflare/GitHub provisioning. No manual `wrangler` ceremony required. - > Deep version (conventions, layer ownership, hard rules): > [`CLAUDE.md`](./CLAUDE.md). CLI reference: [`packages/cli/README.md`](./packages/cli/README.md). --- -## Quickstart (local, ~2 minutes) +## Get started with AI + +This is a **generic template**. Two slash commands adapt it to your actual product — no manual file editing required: + +``` +/onboard # business interview → rewrites brand strings, colors, .env, user-facing labels +/go-live # walks you through Cloudflare + GitHub credentials → provisions all resources + wires CI +``` + +Run `/onboard` first if you're starting fresh (the brand still says "Nuxt Template"). Then `/go-live` once you're ready to ship. Both commands are re-runnable. + +--- + +## Quick start (local, ~2 minutes) ```bash pnpm install -pnpm cli dev setup # creates .env from .env.example + generates auth secrets -pnpm cli dev up # nuxt :3002 + maildev (smtp :1025, web :1080) +pnpm cli dev setup # creates .env from .env.example + generates auth secrets +pnpm cli dev up # nuxt :3002 + maildev (smtp :1025, web :1080) ``` Open and click **Sign in as Admin Agent** -or **Sign in as User Agent** — the demo-login route auto-creates the user -with the right ability preset. No seeding step required for the demo flow. +or **Sign in as User Agent** — the `/api/auth/demo/login` route auto-creates the +user with the right ability preset. No seeding step required for the demo flow. `pnpm cli dev setup` is **idempotent**: rerun it any time; it updates the three auth secrets in place rather than appending duplicates. @@ -43,52 +52,19 @@ D1 migrations on boot. --- -## Environment variables +## Dev workflow -`pnpm cli dev setup` writes the three required auth secrets. The rest live in -[`.env.example`](./.env.example) — copy what you need into `.env` and fill in -the values. +### Backdoor helpers -| Var | Set by | Purpose | -|---|---|---| -| `NUXT_AUTH_SECRET` | `cli dev setup` | Session / CSRF encryption | -| `NUXT_TASK_SECRET` | `cli dev setup` | Bearer token for Nitro tasks | -| `NUXT_WEBHOOK_SIGNING_SECRET` | `cli dev setup` | HMAC for inbound webhooks | -| `NUXT_PUBLIC_BASE_DOMAIN` | manual | Defaults to `localhost:3002` | -| `NUXT_DEMO_MODE` | manual | `true` enables the demo-login backdoor + relaxed CSP | -| `NUXT_PUBLIC_DEMO_MODE` | manual | `true` exposes the demo block on the login page | -| `NUXT_SMTP_*` | manual | Local dev defaults to MailDev (`localhost:1025`) | -| `NUXT_GOOGLE_CLIENT_ID` / `NUXT_GOOGLE_CLIENT_SECRET` | manual | Google OAuth (optional) | -| `NUXT_GITHUB_CLIENT_ID` / `NUXT_GITHUB_CLIENT_SECRET` | manual | GitHub OAuth (optional) | -| `NUXT_SEPAY_*` | manual | SePay bank-transfer payments (optional) | -| `CLOUDFLARE_API_TOKEN` | manual | Required for `cli deploy setup` | -| `CLOUDFLARE_ACCOUNT_ID` | manual | Required for `cli deploy setup` | - -Check what you have at any time: +These commands POST to dev-only routes (only mounted in development mode) and require the dev server to be running: ```bash -pnpm cli doctor # human-readable table of every env/tool check -pnpm cli doctor --json # machine-readable; exit 1 if any check fails -``` - ---- - -## Local dev workflow - -```bash -pnpm cli dev up # nuxt + maildev together (Ctrl-C kills both) -pnpm cli dev up --port 3000 --smtp 2025 --web 8080 # override ports - -pnpm cli dev seed # POST /api/auth/dev-seed (creates demo users) +pnpm cli dev seed # POST /api/auth/demo/dev-seed (creates seed users) pnpm cli dev provision --email you@example.com # create a user + session pnpm cli dev login --email you@example.com # issue a session for an existing user pnpm cli dev cleanup --emails you@example.com # delete users + sessions ``` -The `seed`/`provision`/`login`/`cleanup` commands require the dev server to -be running — they POST to backdoor routes that are only mounted when -`NUXT_DEMO_MODE=true` (set automatically by `cli dev setup`). - ### Database ```bash @@ -108,6 +84,37 @@ pnpm cli verify --json # per-step pass/fail --- +## Environment variables + +`pnpm cli dev setup` writes the three required auth secrets. The rest live in +[`.env.example`](./.env.example) — copy what you need into `.env` and fill in +the values. + +| Var | Set by | Purpose | +|---|---|---| +| `NUXT_AUTH_SECRET` | `cli dev setup` | Session / CSRF encryption | +| `NUXT_TASK_SECRET` | `cli dev setup` | Bearer token for Nitro tasks (`POST /api/auth/roles/sync`) | +| `NUXT_WEBHOOK_SIGNING_SECRET` | `cli dev setup` | HMAC for inbound webhooks | +| `NUXT_PUBLIC_BASE_DOMAIN` | manual | Defaults to `localhost:3002` | +| `NUXT_PUBLIC_DEMO_MODE` | manual | `true` ungates `/api/auth/demo/login` on deployed instances (demo backdoor) | +| `NUXT_SMTP_*` | manual | Local dev defaults to MailDev (`localhost:1025`) | +| `NUXT_GOOGLE_CLIENT_ID` / `NUXT_GOOGLE_CLIENT_SECRET` | manual | Google OAuth (optional) | +| `NUXT_GITHUB_CLIENT_ID` / `NUXT_GITHUB_CLIENT_SECRET` | manual | GitHub OAuth (optional) | +| `NUXT_GITHUB_REPOSITORY` | manual | `owner/repo` — used by the self-host layer to fetch release bundles | +| `NUXT_GITHUB_TOKEN` | manual | PAT for private-repo release downloads (optional) | +| `NUXT_SEPAY_*` | manual | SePay bank-transfer payments (optional) | +| `CLOUDFLARE_API_TOKEN` | manual | Required for `cli deploy setup` | +| `CLOUDFLARE_ACCOUNT_ID` | manual | Required for `cli deploy setup` | + +Check what you have at any time: + +```bash +pnpm cli doctor # human-readable table of every env/tool check +pnpm cli doctor --json # machine-readable; exit 1 if any check fails +``` + +--- + ## Deploy (one-time setup, ~5 minutes) You need a **GitHub** account and a **Cloudflare** account. @@ -174,6 +181,41 @@ pnpm cli deploy logs # tail the production Worker (streams until Ctrl-C) --- +## Self-hosting + +The **selfhost layer** (`layers/selfhost/`) lets your users deploy this app into +their own Cloudflare account directly from the product's **Settings → Self-hosting** +page — no CLI or DevOps knowledge required. + +**How it works:** + +1. The user pastes a Cloudflare API token with Workers + D1 + KV + R2 edit permissions. +2. The app verifies the token, probes write capabilities, and idempotently creates the + required Cloudflare resources (D1, KV namespace, R2 bucket). +3. It fetches the latest `bundle.json` from this repo's GitHub releases, verifies the + SHA-256 checksum, applies D1 migrations, and uploads the Worker. +4. On subsequent deploys the stored token (AES-GCM encrypted at rest) is reused — + one click to update. + +**Publishing a release bundle:** + +```bash +git tag v1.2.3 && git push --tags # triggers .github/workflows/release.yml +``` + +The release workflow builds the Cloudflare Worker and runs +`node scripts/make-deploy-bundle.mjs`, which packages the output into a +`bundle.json` asset attached to the GitHub release. + +**Required env vars for self-hosting to work:** + +| Var | Purpose | +|---|---| +| `NUXT_GITHUB_REPOSITORY` | `owner/repo` pointing to the release source | +| `NUXT_GITHUB_TOKEN` | PAT — only needed if the release repo is private | + +--- + ## Agent-driven feature workflow For non-trivial features, run: @@ -203,7 +245,7 @@ ability model, the deploy-pipeline tradeoffs — are in | `pnpm cli doctor` | Diagnose tools / Cloudflare / GitHub / OAuth / auth secrets | | `pnpm cli dev setup` | Create `.env` + generate three auth secrets (idempotent) | | `pnpm cli dev up` | Run `nuxt dev` + `maildev` together (Ctrl-C kills both) | -| `pnpm cli dev seed` | Seed local DB via `/api/auth/dev-seed` | +| `pnpm cli dev seed` | Seed local DB via `/api/auth/demo/dev-seed` | | `pnpm cli dev provision --email …` | Create a user + session | | `pnpm cli dev login --email …` | Issue a session for an existing user | | `pnpm cli dev cleanup --emails …` | Delete users + sessions | diff --git a/app/composables/useLayerRegistry.ts b/app/composables/useLayerRegistry.ts index 6b141ba7..7d0f7856 100644 --- a/app/composables/useLayerRegistry.ts +++ b/app/composables/useLayerRegistry.ts @@ -1,4 +1,4 @@ -import type { Component } from 'vue' +import type { Component, InjectionKey } from 'vue' export interface RegistryNavItem { id: string @@ -26,16 +26,29 @@ export interface RegistryNavbarItem { priority: number } +export interface RegistryInvitationField { + id: string + component: Component +} + +/** + * Provided by the invite form; injected by contributed fields to write their + * slice of the invitation payload. Scoped per form instance. + */ +export const invitationMetadataKey: InjectionKey> = Symbol('invitation-metadata') + export interface LayerContribution { navItems?: RegistryNavItem[] overlays?: RegistryOverlay[] navbarItems?: RegistryNavbarItem[] + invitationFields?: RegistryInvitationField[] } export function useLayerRegistry() { const navItems = useState('layerRegistry.nav', () => []) const overlays = useState('layerRegistry.overlays', () => []) const navbarItems = useState('layerRegistry.navbar', () => []) + const invitationFields = useState('layerRegistry.invitationFields', () => []) function contribute(input: LayerContribution) { if (input.navItems?.length) { @@ -63,7 +76,16 @@ export function useLayerRegistry() { .map(i => ({ ...i, component: markRaw(i.component) })), ] } + if (input.invitationFields?.length) { + const existing = new Set(invitationFields.value.map(i => i.id)) + invitationFields.value = [ + ...invitationFields.value, + ...input.invitationFields + .filter(i => !existing.has(i.id)) + .map(i => ({ ...i, component: markRaw(i.component) })), + ] + } } - return { navItems, overlays, navbarItems, contribute } + return { navItems, overlays, navbarItems, invitationFields, contribute } } diff --git a/app/layouts/default.vue b/app/layouts/default.vue index f86e9a0a..5f203c4f 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -4,12 +4,11 @@ import type { RegistryNavItem } from '~/composables/useLayerRegistry' import ImpersonateMenu from '#layers/auth/app/components/Impersonate/ImpersonateMenu.vue' import OrganizationMenu from '#layers/auth/app/components/Organization/OrganizationMenu.vue' import UserMenu from '#layers/auth/app/components/User/UserMenu.vue' -import { satisfiesAbility } from '#layers/auth/shared/ability' const open = ref(false) const colorMode = useColorMode() -const authStore = useAuthStore() const registry = useLayerRegistry() +const { $ability } = useNuxtApp() onMounted(() => { useCookieConsent().prompt() @@ -22,13 +21,14 @@ function setTheme(pref: string) { colorMode.preference = pref } -const abilities = computed(() => authStore.currentUser?.abilities ?? []) - function canShow(item: RegistryNavItem): boolean { if (!item.ability) return true const abs = Array.isArray(item.ability) ? item.ability : [item.ability] - return abs.some(a => satisfiesAbility(abilities.value, a)) + return abs.some((a) => { + const [s = '', ac = ''] = a.split(':') + return $ability.can(ac, s) + }) } function toMenuItem(item: RegistryNavItem): NavigationMenuItem { diff --git a/cloudflare/README.md b/cloudflare/README.md new file mode 100644 index 00000000..b8f39b9b --- /dev/null +++ b/cloudflare/README.md @@ -0,0 +1,79 @@ +# Cloudflare-specific infrastructure + +Platform glue that isn't domain logic and doesn't belong in a feature layer. +Each file here is a thin, reusable wrapper over a Cloudflare Workers primitive; +the domain code lives in the layers and calls into it. + +| File | What it is | +|------|------------| +| `queue.ts` | Producer-side helper for **Cloudflare Queues** (`QueueProducer`, `getQueueProducer`, `enqueue`, `chunk`). Pure/runtime; unit-tested in `test/unit/cloudflare-queue.test.ts`. | + +Why a root `cloudflare/` folder instead of `server/utils/`: these helpers are +Cloudflare-specific and may be consumed by any layer (server **or**, in +principle, edge code). Server code imports them with the project-root alias, +e.g. `import { getQueueProducer } from '~~/cloudflare/queue'`. + +> The matching **consumer** is a Nitro plugin, not a file here — it must live +> under a scanned `server/` dir to be registered. See +> `layers/system/server/plugins/dispatch-queue.consumer.ts`, which listens on +> the `cloudflare:queue` hook (fired by Nitro's `cloudflare_module` preset). The +> hook's type augmentation is in `layers/system/server/types/hooks.d.ts`. + +--- + +## Cloudflare Queues — bulk email dispatch + +The admin "Send email" tool (`layers/system`) can target up to 500 recipients. +Sending them inline inside one request risks the Worker CPU/wall-time limit. The +queue path fans recipients out as messages drained in the background, with +per-message retries and a dead-letter queue. + +**Design** + +1. The route composes the email once, stores it in KV (`dispatch:job:{id}`, + 1‑hour TTL), and enqueues one small message per recipient + (`{ dispatchId, user }`) wrapped in a `{ type, payload }` envelope. +2. The consumer plugin loads the shared email blob by `dispatchId` and sends to + each recipient via the `sendUserEmail` opt-out chokepoint. It `ack()`s on + success and `retry()`s on failure (single-recipient granularity). + +**Graceful fallback** — if the `DISPATCH_QUEUE` binding is absent (local +`pnpm dev`, tests, or simply not yet configured), `getQueueProducer` returns +`null` and the route sends synchronously, exactly as before. So the feature is +safe-by-default and *activates the moment the binding exists* — no code change. + +### Enabling it (production) + +Requires a **paid Workers plan** (Queues is not on the free tier). + +```bash +# 1. Create the queue and its dead-letter queue +npx wrangler queues create nuxt-template-dispatch +npx wrangler queues create nuxt-template-dispatch-dlq + +# 2. Uncomment the "queues" block in wrangler.jsonc (producer + consumer + DLQ). +# NuxtHub merges it into .output/server/wrangler.json at build. + +# 3. Deploy as usual (CI `wrangler deploy`, or locally): +pnpm build && npx wrangler --cwd .output deploy +``` + +The binding name (`DISPATCH_QUEUE`), queue name (`nuxt-template-dispatch`), and +message type live in `layers/system/server/services/dispatch.ts` — keep +wrangler.jsonc in sync with those constants. + +### Environment isolation ⚠️ + +Preview and production share this top-level `queues` config (there is no +per-`CLOUDFLARE_ENV` indirection as there is for D1/KV). If you run real preview +deploys that send mail, create a **separate** queue per environment (e.g. +`nuxt-template-dispatch-preview`) and switch the names via a wrangler `env` +block, or leave the queue unconfigured on preview so it falls back to inline +send. + +### Local testing + +`wrangler dev` can emulate queues, but `pnpm dev` runs Nitro's Node dev server +where the binding is absent — dispatch sends synchronously there, which is the +intended dev behavior. Exercise the queue path against a real preview/prod +Worker, or with `wrangler dev` + a bound queue. diff --git a/cloudflare/queue.ts b/cloudflare/queue.ts new file mode 100644 index 00000000..5a96950e --- /dev/null +++ b/cloudflare/queue.ts @@ -0,0 +1,57 @@ +import type { H3Event } from 'h3' + +/** + * Cloudflare-specific producer-side glue for Cloudflare Queues. Kept out of the + * layers because it is platform infrastructure, not domain logic: any layer can + * enqueue work through it. The consumer side is a Nitro plugin listening on the + * `cloudflare:queue` hook (see layers//server/plugins/*.consumer.ts). + * + * Setup (paid Workers plan required) lives in cloudflare/README.md. + */ + +/** Minimal shape of a Cloudflare Queue producer binding (`env.`). */ +export interface QueueProducer { + send: (body: unknown, options?: { contentType?: string, delaySeconds?: number }) => Promise + sendBatch: (messages: Iterable<{ body: unknown }>) => Promise +} + +/** Envelope so one queue can carry several message kinds, routed by `type`. */ +export interface QueueEnvelope { + type: string + payload: T +} + +/** Cloudflare caps a single sendBatch at 100 messages (and 256 KB total). */ +export const QUEUE_BATCH_LIMIT = 100 + +export function chunk(items: readonly T[], size: number): T[][] { + if (size < 1) + throw new Error('chunk size must be >= 1') + const out: T[][] = [] + for (let i = 0; i < items.length; i += size) + out.push(items.slice(i, i + size)) + return out +} + +/** + * Resolve a Cloudflare Queue producer binding from the request context. Returns + * `null` off-Cloudflare (`pnpm dev`, Node/Vitest), where the binding is absent — + * callers detect this and fall back to running the work inline. + */ +export function getQueueProducer(event: H3Event, binding: string): QueueProducer | null { + const env = (event.context as { cloudflare?: { env?: Record } }).cloudflare?.env + const queue = env?.[binding] as QueueProducer | undefined + return queue ?? null +} + +/** Enqueue payloads as typed envelopes, respecting the per-batch cap. Returns the count sent. */ +export async function enqueue( + queue: QueueProducer, + type: string, + payloads: readonly T[], + chunkSize: number = QUEUE_BATCH_LIMIT, +): Promise { + for (const group of chunk(payloads, chunkSize)) + await queue.sendBatch(group.map(payload => ({ body: { type, payload } satisfies QueueEnvelope }))) + return payloads.length +} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 00000000..e702ba21 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitepress' + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: 'Nuxt Template Documentation', + description: 'AI-Native Nuxt template that scales', + themeConfig: { + // https://vitepress.dev/reference/default-theme-config + nav: [ + { text: 'Home', link: '/' }, + { text: 'Examples', link: '/markdown-examples' }, + ], + + sidebar: [ + { + text: 'Examples', + items: [ + { text: 'Markdown Examples', link: '/markdown-examples' }, + { text: 'Runtime API Examples', link: '/api-examples' }, + ], + }, + ], + + socialLinks: [ + { icon: 'github', link: 'https://github.com/vuejs/vitepress' }, + ], + }, +}) diff --git a/docs/api-examples.md b/docs/api-examples.md new file mode 100644 index 00000000..6bd8bb5c --- /dev/null +++ b/docs/api-examples.md @@ -0,0 +1,49 @@ +--- +outline: deep +--- + +# Runtime API Examples + +This page demonstrates usage of some of the runtime APIs provided by VitePress. + +The main `useData()` API can be used to access site, theme, and page data for the current page. It works in both `.md` and `.vue` files: + +```md + + +## Results + +### Theme Data +
{{ theme }}
+ +### Page Data +
{{ page }}
+ +### Page Frontmatter +
{{ frontmatter }}
+``` + + + +## Results + +### Theme Data +
{{ theme }}
+ +### Page Data +
{{ page }}
+ +### Page Frontmatter +
{{ frontmatter }}
+ +## More + +Check out the documentation for the [full list of runtime APIs](https://vitepress.dev/reference/runtime-api#usedata). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..52a171fd --- /dev/null +++ b/docs/index.md @@ -0,0 +1,25 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "Nuxt Template Documentation" + text: "AI-Native Nuxt template that scales" + tagline: My great project tagline + actions: + - theme: brand + text: Markdown Examples + link: /markdown-examples + - theme: alt + text: API Examples + link: /api-examples + +features: + - title: Feature A + details: Lorem ipsum dolor sit amet, consectetur adipiscing elit + - title: Feature B + details: Lorem ipsum dolor sit amet, consectetur adipiscing elit + - title: Feature C + details: Lorem ipsum dolor sit amet, consectetur adipiscing elit +--- + diff --git a/docs/markdown-examples.md b/docs/markdown-examples.md new file mode 100644 index 00000000..f9258a55 --- /dev/null +++ b/docs/markdown-examples.md @@ -0,0 +1,85 @@ +# Markdown Extension Examples + +This page demonstrates some of the built-in markdown extensions provided by VitePress. + +## Syntax Highlighting + +VitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting: + +**Input** + +````md +```js{4} +export default { + data () { + return { + msg: 'Highlighted!' + } + } +} +``` +```` + +**Output** + +```js{4} +export default { + data () { + return { + msg: 'Highlighted!' + } + } +} +``` + +## Custom Containers + +**Input** + +```md +::: info +This is an info box. +::: + +::: tip +This is a tip. +::: + +::: warning +This is a warning. +::: + +::: danger +This is a dangerous warning. +::: + +::: details +This is a details block. +::: +``` + +**Output** + +::: info +This is an info box. +::: + +::: tip +This is a tip. +::: + +::: warning +This is a warning. +::: + +::: danger +This is a dangerous warning. +::: + +::: details +This is a details block. +::: + +## More + +Check out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown). diff --git a/layers/auth/CLAUDE.md b/layers/auth/CLAUDE.md index ffe05e07..982ffcb9 100644 --- a/layers/auth/CLAUDE.md +++ b/layers/auth/CLAUDE.md @@ -13,9 +13,9 @@ | Authenticated handler wrapper | `server/services/auth.ts` (`defineAuthenticatedHandler`) | | Authorization (`defineAuthorizedHandler`, `defineSubject`, ability parsing) | `server/services/casl.ts` | | Impersonation (start/stop/list, session swap utilities) | `server/services/impersonate.ts`, `server/api/auth/impersonate/*` | -| Ability presets + seed fixtures (used by demo-login + e2e tests) | `server/services/seed.ts` | +| Seed fixtures + task runners | `server/tasks/seed/{permissions,users,organizations,all}.ts` | | OAuth (Google, GitHub) | `server/api/auth/{google,github}{,/callback}.get.ts` | -| Demo/dev login backdoors | `server/api/auth/{demo-login,dev-login,dev-cleanup,dev-seed,agent}.*.ts` | +| Demo/dev login backdoors | `server/api/auth/demo/{login,dev-login,dev-provision,dev-seed,cleanup,agent}.*` | | Frontend session store + ability rules sync | `app/stores/auth.ts`, `app/plugins/casl.ts` | | Route guards | `app/middleware/{auth,casl}.global.ts` | | Login + 403 + impersonation UI | `app/pages/{auth/login,forbidden,sandbox/impersonate}.vue` | @@ -24,7 +24,8 @@ | Invitations (token links, create/list/revoke, public join + accept) | `server/api/organization/invitations/*`, `server/api/invitations/[token]/*`, `app/pages/join/[token].vue`, `shared/schemas/invitation.ts` | | `PageMeta` extensions (`public`, `unauthenticatedOnly`, `can`) | `app/types/router.d.ts` | | Schemas (auth, member, organization, user, invitation) | `shared/schemas/*` | -| Ability catalog + default grants (`DEFAULT_*_ABILITIES`) | `shared/permissions.ts` | +| Ability catalog + role→permission source of truth (`DEFAULT_ROLE_ABILITIES`) | `shared/permissions.ts` — edit here, then run `roles:sync` | +| Role permission reconcile command + prod trigger | `server/tasks/roles/sync.ts` (logic + task), `server/api/auth/roles/sync.post.ts` (bearer route) | ## Conventions @@ -158,7 +159,9 @@ layers/auth/ types/router.d.ts PageMeta + RouteMeta augmentations server/ api/ - auth/ OAuth, me, logout, phone, impersonate/*, demo/dev backdoors + auth/ OAuth, me, logout, phone, impersonate/* + demo/ Backdoor routes: login, dev-login, dev-provision, dev-seed, cleanup, agent + roles/sync.post.ts Bearer-guarded prod trigger for roles:sync organization/ Active-org: members/*, invitations/*, index.{get,patch} organizations/ Multi-org: list, switch invitations/[token]/ Public index.get + authenticated accept.post @@ -170,7 +173,9 @@ layers/auth/ impersonate.ts Session swap helpers + IMPERSONATE_ABILITY constant organization.ts Org/membership/invitation queries + helpers session.ts buildSession + refreshUserSessions (live KV rewrite) - seed.ts ABILITY_PRESETS + SEED_USERS fixtures (e2e/dev only) + tasks/ + seed/ permissions.ts, users.ts, organizations.ts, all.ts + roles/sync.ts planRolePermissionSync (pure) + syncDefaultRolePermissions + task shared/ permissions.ts Ability catalog + DEFAULT_{PERSONAL_ORG,MEMBER}_ABILITIES schemas/ impersonate, member, organization, user, invitation diff --git a/layers/auth/app/api/useAuthApi.ts b/layers/auth/app/api/useAuthApi.ts index 248f7fa7..688f0df0 100644 --- a/layers/auth/app/api/useAuthApi.ts +++ b/layers/auth/app/api/useAuthApi.ts @@ -10,6 +10,12 @@ export interface ImpersonationCandidate { is_suspended: boolean | null } +export interface AuthProviders { + credential: boolean + google: boolean + github: boolean +} + export function useAuthApi() { function fetchCurrentUser() { return $http('/api/auth/me') @@ -72,6 +78,32 @@ export function useAuthApi() { return $http('/api/auth/logout') } + function fetchAuthProviders() { + return $http('/api/auth/providers') + } + + function login(email: string, password: string) { + return $http<{ session_id: string, user_id: string }>('/api/auth/login', { + method: 'POST', + body: { email, password }, + }) + } + + // Dev-only backdoors (routes 404 outside `import.meta.dev`). Used by the + // dev login block on the sign-in card to exercise CASL as a seeded user. + function devSeedUsers() { + return $http<{ users: Array<{ id: string, primary_email: string }> }>('/api/auth/demo/dev-seed', { + method: 'POST', + }) + } + + function devLogin(email: string) { + return $http<{ session_id: string, user_id: string }>('/api/auth/demo/dev-login', { + method: 'POST', + body: { email }, + }) + } + return { fetchCurrentUser, updateCurrentUser, @@ -83,5 +115,9 @@ export function useAuthApi() { startImpersonation, stopImpersonation, logout, + fetchAuthProviders, + login, + devSeedUsers, + devLogin, } } diff --git a/layers/auth/app/api/useOrganizationApi.ts b/layers/auth/app/api/useOrganizationApi.ts index f6a1c0c2..f4294056 100644 --- a/layers/auth/app/api/useOrganizationApi.ts +++ b/layers/auth/app/api/useOrganizationApi.ts @@ -1,5 +1,5 @@ +import type { Role } from '@nuxthub/db/schema' import type { ListQuery, Page } from '~~/shared/schemas/pagination' -import type { Role } from '#layers/auth/server/db/schema' import type { CreateInvitation } from '#layers/auth/shared/schemas/invitation' import type { AddMember, UpdateMemberAbilities } from '#layers/auth/shared/schemas/member' import type { CreateOrganization, SwitchOrganization, UpdateOrganization } from '#layers/auth/shared/schemas/organization' diff --git a/layers/auth/app/components/Auth/AuthLoginCard.vue b/layers/auth/app/components/Auth/AuthLoginCard.vue index 39fb7a0f..4efa22fc 100644 --- a/layers/auth/app/components/Auth/AuthLoginCard.vue +++ b/layers/auth/app/components/Auth/AuthLoginCard.vue @@ -1,14 +1,107 @@