diff --git a/.github/workflows/ai-saas-starter.yml b/.github/workflows/ai-saas-starter.yml new file mode 100644 index 0000000..b8deb08 --- /dev/null +++ b/.github/workflows/ai-saas-starter.yml @@ -0,0 +1,56 @@ +name: AI SaaS starter + +on: + pull_request: + paths: + - ".github/workflows/ai-saas-starter.yml" + - "ai_saas_starter/**" + push: + branches: [main] + paths: + - ".github/workflows/ai-saas-starter.yml" + - "ai_saas_starter/**" + +permissions: + contents: read + +concurrency: + group: ai-saas-starter-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: starter_test_password + POSTGRES_DB: starter_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d starter_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + defaults: + run: + working-directory: ai_saas_starter + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: ai_saas_starter/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + env: + TEST_DATABASE_URL: postgres://postgres:starter_test_password@127.0.0.1:5432/starter_test + - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e diff --git a/ai_saas_starter/.env.example b/ai_saas_starter/.env.example new file mode 100644 index 0000000..5698d7c --- /dev/null +++ b/ai_saas_starter/.env.example @@ -0,0 +1,10 @@ +# Required for persistent production storage. Without it, the app uses an explicitly labelled in-memory demo store. +DATABASE_URL= + +# demo | openai-compatible +AI_PROVIDER=demo +AI_BASE_URL=https://api.openai.com/v1 +AI_MODEL=replace-with-your-model +AI_API_KEY= +AI_MONTHLY_TOKEN_LIMIT=50000 +AI_REQUESTS_PER_MINUTE=20 diff --git a/ai_saas_starter/.github/social-preview.png b/ai_saas_starter/.github/social-preview.png new file mode 100644 index 0000000..a9c1fd8 Binary files /dev/null and b/ai_saas_starter/.github/social-preview.png differ diff --git a/ai_saas_starter/.gitignore b/ai_saas_starter/.gitignore new file mode 100644 index 0000000..eb1742f --- /dev/null +++ b/ai_saas_starter/.gitignore @@ -0,0 +1,10 @@ +.env +.env.local +.env.*.local +.next/ +coverage/ +node_modules/ +playwright-report/ +test-results/ +*.tsbuildinfo +!.env.example diff --git a/ai_saas_starter/AGENTS.md b/ai_saas_starter/AGENTS.md new file mode 100644 index 0000000..3333eed --- /dev/null +++ b/ai_saas_starter/AGENTS.md @@ -0,0 +1,13 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + + +## Runtime boundaries + +- The included request limiter is process-local. For a multi-instance production deployment, replace it with a shared rate limiter backed by infrastructure such as Redis or the deployment platform's edge rate-limiting service. diff --git a/ai_saas_starter/CLAUDE.md b/ai_saas_starter/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/ai_saas_starter/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/ai_saas_starter/README.md b/ai_saas_starter/README.md new file mode 100644 index 0000000..aec524a --- /dev/null +++ b/ai_saas_starter/README.md @@ -0,0 +1,77 @@ +# Tuurio AI SaaS Starter + +A production-oriented, multi-tenant AI workspace with streamed conversations, tenant-isolated persistence, usage controls, role-aware administration, and Tuurio ID already wired in. + +![Tuurio Canvas product preview](docs/screenshot.png) + +## Product journey + +- Public landing and pricing pages that explain the product before sign-in. +- Tuurio ID Authorization Code with PKCE for browser authentication. +- Server-side JWT validation against the tenant issuer and JWKS before any data access. +- Organization-scoped conversations, usage, audit events, and administration. +- A provider-neutral streaming adapter with a safe built-in demo provider. +- Postgres persistence in production and an explicitly labelled, reset-on-restart memory store for local evaluation. + +## Quickstart + +1. Install, start the app, and explore the browser-only product demo: + +```bash +npm ci +cp .env.example .env.local +npm run dev +``` + +Open `http://localhost:3000/demo`. The demo requires no account, makes no API or model request, and resets on refresh. Open `/setup` to get a pinned provisioning command generated for the exact origin currently in your browser. + +2. To enable the real protected workspace for local development, review and run: + +```bash +npx manage-tuurio-id@1.1.6 init --framework nextjs --project-dir . --base-url http://localhost:3000 --redirect-uri http://localhost:3000/auth/callback --post-logout-redirect-uri http://localhost:3000/logout/callback --public-config src/tuurio.public.json --auth browser --yes --output json --campaign github_ai_saas --no-open --no-wait +``` + +Complete the browser handoff yourself. Never give an agent credentials, tokens, authorization codes, session cookies, legal acceptance, or environment-file contents. + +3. Restart the app after provisioning: + +```bash +npm run dev +``` + +Without `DATABASE_URL`, authenticated requests use a visibly labelled in-memory demo store. Without an AI key, `AI_PROVIDER=demo` streams deterministic local copy and never pretends to call a production model. + +## Production setup + +1. Deploy once to obtain the stable HTTPS origin. +2. Re-run the pinned CLI with exact production `--base-url`, `--redirect-uri`, and `--post-logout-redirect-uri` values. Commit only `src/tuurio.public.json`; it contains public OIDC client data and no secret. +3. Set `DATABASE_URL`, run `npm run db:migrate`, and store `AI_API_KEY` only in the deployment's server-side secret store. +4. Set `AI_PROVIDER=openai-compatible`, `AI_BASE_URL`, and `AI_MODEL` for a compatible streaming chat-completions endpoint. +5. Verify one real sign-in, one streamed response, tenant isolation, and sign-out. + +Tuurio identity is hosted in the EU. This does not imply that your application host, database, AI provider, prompts, or model outputs are EU-hosted; choose and disclose those services separately. + +## Security boundaries + +- The browser is a public OIDC client and never receives a client secret. +- The established OIDC library owns state, nonce, PKCE, callback processing, and token expiry. +- APIs reject requests until the access token signature, exact issuer, intended audience, and time claims validate. +- The issuer origin is the tenant boundary; every storage query also requires that tenant key. +- AI provider credentials stay server-only. Prompts, model output, access tokens, and authorization codes are not logged. +- The included in-process request limiter is a safe starter default, not a substitute for a shared production rate limiter across multiple instances. + +## Verification + +```bash +npm run typecheck +npm test +npm run build +npx playwright install chromium +npm run test:e2e +``` + +The Postgres adapter has an integration suite as well. Start a disposable database and run `TEST_DATABASE_URL=postgres://... npm test`; the suite verifies persisted tenant isolation and atomic quota enforcement. + +## License + +Apache-2.0. See `LICENSE` in the generated template repository. diff --git a/ai_saas_starter/db/schema.sql b/ai_saas_starter/db/schema.sql new file mode 100644 index 0000000..c1b7a78 --- /dev/null +++ b/ai_saas_starter/db/schema.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL, + owner_subject TEXT NOT NULL, + title TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS conversations_tenant_updated_idx + ON conversations (tenant_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL, + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS messages_tenant_conversation_idx + ON messages (tenant_id, conversation_id, created_at ASC); + +CREATE TABLE IF NOT EXISTS monthly_usage ( + tenant_id TEXT NOT NULL, + period TEXT NOT NULL, + input_units INTEGER NOT NULL DEFAULT 0 CHECK (input_units >= 0), + output_units INTEGER NOT NULL DEFAULT 0 CHECK (output_units >= 0), + request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0), + PRIMARY KEY (tenant_id, period) +); + +CREATE TABLE IF NOT EXISTS audit_events ( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL, + actor_subject TEXT NOT NULL, + action TEXT NOT NULL, + target_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS audit_events_tenant_created_idx + ON audit_events (tenant_id, created_at DESC); diff --git a/ai_saas_starter/docs/screenshot.png b/ai_saas_starter/docs/screenshot.png new file mode 100644 index 0000000..81d40d3 Binary files /dev/null and b/ai_saas_starter/docs/screenshot.png differ diff --git a/ai_saas_starter/next-env.d.ts b/ai_saas_starter/next-env.d.ts new file mode 100644 index 0000000..a419cbe --- /dev/null +++ b/ai_saas_starter/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/ai_saas_starter/next.config.ts b/ai_saas_starter/next.config.ts new file mode 100644 index 0000000..13f6a7f --- /dev/null +++ b/ai_saas_starter/next.config.ts @@ -0,0 +1,26 @@ +import type { NextConfig } from "next"; + +// Next.js static rendering requires `unsafe-inline` unless the app adopts +// per-request nonces, which make every page dynamic. This starter stays static; +// deployments that require a nonce-based CSP can follow the official Next.js guide. +const scriptSources = process.env.NODE_ENV === "development" + ? "'self' 'unsafe-inline' 'unsafe-eval'" + : "'self' 'unsafe-inline'"; + +const nextConfig: NextConfig = { + poweredByHeader: false, + async headers() { + return [{ + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Content-Security-Policy", value: `default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src ${scriptSources}; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://id.tuurio.com https://*.id.tuurio.com` }, + ], + }]; + }, +}; + +export default nextConfig; diff --git a/ai_saas_starter/package-lock.json b/ai_saas_starter/package-lock.json new file mode 100644 index 0000000..faa2dbd --- /dev/null +++ b/ai_saas_starter/package-lock.json @@ -0,0 +1,2252 @@ +{ + "name": "tuurio-ai-saas-starter", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tuurio-ai-saas-starter", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "jose": "6.2.8", + "next": "16.3.0", + "oidc-client-ts": "3.5.0", + "postgres": "3.4.9", + "react": "19.2.8", + "react-dom": "19.2.8", + "zod": "4.4.3" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "typescript": "5.9.3", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@next/env": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.0", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.0", + "@next/swc-darwin-x64": "16.3.0", + "@next/swc-linux-arm64-gnu": "16.3.0", + "@next/swc-linux-arm64-musl": "16.3.0", + "@next/swc-linux-x64-gnu": "16.3.0", + "@next/swc-linux-x64-musl": "16.3.0", + "@next/swc-win32-arm64-msvc": "16.3.0", + "@next/swc-win32-x64-msvc": "16.3.0", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/ai_saas_starter/package.json b/ai_saas_starter/package.json new file mode 100644 index 0000000..d646303 --- /dev/null +++ b/ai_saas_starter/package.json @@ -0,0 +1,37 @@ +{ + "name": "tuurio-ai-saas-starter", + "version": "1.0.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "db:migrate": "node --experimental-strip-types scripts/migrate.ts" + }, + "dependencies": { + "jose": "6.2.8", + "next": "16.3.0", + "oidc-client-ts": "3.5.0", + "postgres": "3.4.9", + "react": "19.2.8", + "react-dom": "19.2.8", + "zod": "4.4.3" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/ai_saas_starter/playwright.config.ts b/ai_saas_starter/playwright.config.ts new file mode 100644 index 0000000..75100f8 --- /dev/null +++ b/ai_saas_starter/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + retries: process.env.CI ? 2 : 0, + use: { + baseURL: "http://127.0.0.1:3000", + trace: "on-first-retry", + }, + webServer: { + command: "npm run dev -- --hostname 127.0.0.1", + url: "http://127.0.0.1:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/ai_saas_starter/scripts/migrate.ts b/ai_saas_starter/scripts/migrate.ts new file mode 100644 index 0000000..34b6d93 --- /dev/null +++ b/ai_saas_starter/scripts/migrate.ts @@ -0,0 +1,15 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import postgres from "postgres"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for migrations"); + +const sql = postgres(databaseUrl, { max: 1 }); +try { + const migration = await readFile(resolve("db/schema.sql"), "utf8"); + await sql.unsafe(migration); + console.log("Applied db/schema.sql"); +} finally { + await sql.end(); +} diff --git a/ai_saas_starter/src/app/api/admin/route.ts b/ai_saas_starter/src/app/api/admin/route.ts new file mode 100644 index 0000000..4952319 --- /dev/null +++ b/ai_saas_starter/src/app/api/admin/route.ts @@ -0,0 +1,17 @@ +import { apiError } from "@/lib/api"; +import { authenticateRequest } from "@/lib/auth/server"; +import { workspaceStore } from "@/lib/storage"; + +export async function GET(request: Request) { + try { + const identity = await authenticateRequest(request); + if (identity.role !== "admin") return Response.json({ error: "Administrator access is required" }, { status: 403 }); + return Response.json({ + actor: { email: identity.email, name: identity.name, role: identity.role }, + audit: await workspaceStore().listAudit(identity), + membershipManagementUrl: `${identity.tenantId}/admin`, + }); + } catch (error) { + return apiError(error); + } +} diff --git a/ai_saas_starter/src/app/api/config/route.ts b/ai_saas_starter/src/app/api/config/route.ts new file mode 100644 index 0000000..0b0492a --- /dev/null +++ b/ai_saas_starter/src/app/api/config/route.ts @@ -0,0 +1,17 @@ +import { aiProvider } from "@/lib/ai"; +import { apiError } from "@/lib/api"; +import { authenticateRequest } from "@/lib/auth/server"; +import { workspaceStore } from "@/lib/storage"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + await authenticateRequest(request); + return Response.json({ storageMode: workspaceStore().mode, aiProvider: aiProvider().name }, { + headers: { "cache-control": "no-store" }, + }); + } catch (error) { + return apiError(error); + } +} diff --git a/ai_saas_starter/src/app/api/conversations/[conversationId]/messages/route.ts b/ai_saas_starter/src/app/api/conversations/[conversationId]/messages/route.ts new file mode 100644 index 0000000..65eb2ed --- /dev/null +++ b/ai_saas_starter/src/app/api/conversations/[conversationId]/messages/route.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { aiProvider } from "@/lib/ai"; +import { apiError } from "@/lib/api"; +import { authenticateRequest } from "@/lib/auth/server"; +import { serverEnv } from "@/lib/env"; +import { SlidingWindowLimiter } from "@/lib/rate-limit"; +import { workspaceStore } from "@/lib/storage"; +import { estimateUnits } from "@/lib/storage/shared"; + +const inputSchema = z.object({ content: z.string().trim().min(1).max(8_000) }); +const env = serverEnv(); +const limiter = new SlidingWindowLimiter(env.AI_REQUESTS_PER_MINUTE); +const RESERVED_OUTPUT_UNITS = 512; + +interface Context { params: Promise<{ conversationId: string }> } + +export async function POST(request: Request, context: Context) { + try { + const identity = await authenticateRequest(request); + const rate = limiter.take(`${identity.tenantId}:${identity.subject}`); + if (!rate.allowed) { + return Response.json({ error: "Too many AI requests. Try again shortly." }, { + status: 429, + headers: { "retry-after": String(rate.retryAfterSeconds) }, + }); + } + const input = inputSchema.parse(await request.json()); + const { conversationId } = await context.params; + const store = workspaceStore(); + if (!await store.getConversation(identity, conversationId)) { + return Response.json({ error: "Conversation not found" }, { status: 404 }); + } + const inputUnits = estimateUnits(input.content); + const usage = await store.consumeUsage(identity, { + inputUnits, + outputUnits: RESERVED_OUTPUT_UNITS, + }, env.AI_MONTHLY_TOKEN_LIMIT); + if (!usage) return Response.json({ error: "This workspace reached its monthly AI quota" }, { status: 402 }); + + let reservationActive = true; + const refundReservedOutput = async () => { + if (!reservationActive) return; + reservationActive = false; + await store.refundUsage(identity, { inputUnits: 0, outputUnits: RESERVED_OUTPUT_UNITS }); + }; + const rollbackUsage = async () => { + if (!reservationActive) return; + reservationActive = false; + await store.refundUsage(identity, { inputUnits, outputUnits: RESERVED_OUTPUT_UNITS }, true); + }; + + let conversation; + try { + await store.appendMessage(identity, conversationId, "user", input.content); + conversation = await store.getConversation(identity, conversationId); + } catch (error) { + await rollbackUsage(); + throw error; + } + if (!conversation) { + await rollbackUsage(); + return Response.json({ error: "Conversation changed while the request was starting" }, { status: 409 }); + } + try { + await store.addAudit(identity, "ai.requested", conversationId); + } catch (error) { + await rollbackUsage(); + throw error; + } + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + let complete = ""; + try { + for await (const chunk of aiProvider().stream({ + messages: (conversation?.messages ?? []).map(({ role, content }) => ({ role, content })), + signal: request.signal, + })) { + complete += chunk; + controller.enqueue(encoder.encode(chunk)); + } + if (complete.trim()) await store.appendMessage(identity, conversationId, "assistant", complete); + reservationActive = false; + await store.addAudit(identity, "ai.completed", conversationId); + controller.close(); + } catch { + if (!complete.trim()) await refundReservedOutput(); + else reservationActive = false; + await store.addAudit(identity, "ai.failed", conversationId); + controller.error(new Error("AI provider stream failed")); + } + }, + cancel() { /* The provider observes request.signal directly. */ }, + }); + return new Response(stream, { + headers: { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + "x-content-type-options": "nosniff", + }, + }); + } catch (error) { + if (error instanceof z.ZodError) return Response.json({ error: "A message between 1 and 8,000 characters is required" }, { status: 400 }); + return apiError(error); + } +} diff --git a/ai_saas_starter/src/app/api/conversations/[conversationId]/route.ts b/ai_saas_starter/src/app/api/conversations/[conversationId]/route.ts new file mode 100644 index 0000000..f3e43fc --- /dev/null +++ b/ai_saas_starter/src/app/api/conversations/[conversationId]/route.ts @@ -0,0 +1,28 @@ +import { apiError } from "@/lib/api"; +import { authenticateRequest } from "@/lib/auth/server"; +import { workspaceStore } from "@/lib/storage"; + +interface Context { params: Promise<{ conversationId: string }> } + +export async function GET(request: Request, context: Context) { + try { + const identity = await authenticateRequest(request); + const { conversationId } = await context.params; + const conversation = await workspaceStore().getConversation(identity, conversationId); + if (!conversation) return Response.json({ error: "Conversation not found" }, { status: 404 }); + return Response.json({ conversation }); + } catch (error) { + return apiError(error); + } +} + +export async function DELETE(request: Request, context: Context) { + try { + const identity = await authenticateRequest(request); + const { conversationId } = await context.params; + const deleted = await workspaceStore().deleteConversation(identity, conversationId); + return deleted ? new Response(null, { status: 204 }) : Response.json({ error: "Conversation not found" }, { status: 404 }); + } catch (error) { + return apiError(error); + } +} diff --git a/ai_saas_starter/src/app/api/conversations/route.ts b/ai_saas_starter/src/app/api/conversations/route.ts new file mode 100644 index 0000000..50ef808 --- /dev/null +++ b/ai_saas_starter/src/app/api/conversations/route.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { apiError } from "@/lib/api"; +import { authenticateRequest } from "@/lib/auth/server"; +import { workspaceStore } from "@/lib/storage"; + +const createSchema = z.object({ title: z.string().trim().min(1).max(160) }); + +export async function GET(request: Request) { + try { + const identity = await authenticateRequest(request); + return Response.json({ conversations: await workspaceStore().listConversations(identity) }); + } catch (error) { + return apiError(error); + } +} + +export async function POST(request: Request) { + try { + const identity = await authenticateRequest(request); + const input = createSchema.parse(await request.json()); + return Response.json({ conversation: await workspaceStore().createConversation(identity, input.title) }, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) return Response.json({ error: "A title between 1 and 160 characters is required" }, { status: 400 }); + return apiError(error); + } +} diff --git a/ai_saas_starter/src/app/api/usage/route.ts b/ai_saas_starter/src/app/api/usage/route.ts new file mode 100644 index 0000000..cb5b6c3 --- /dev/null +++ b/ai_saas_starter/src/app/api/usage/route.ts @@ -0,0 +1,13 @@ +import { apiError } from "@/lib/api"; +import { authenticateRequest } from "@/lib/auth/server"; +import { serverEnv } from "@/lib/env"; +import { workspaceStore } from "@/lib/storage"; + +export async function GET(request: Request) { + try { + const identity = await authenticateRequest(request); + return Response.json({ usage: await workspaceStore().getUsage(identity, serverEnv().AI_MONTHLY_TOKEN_LIMIT) }); + } catch (error) { + return apiError(error); + } +} diff --git a/ai_saas_starter/src/app/auth/callback/page.tsx b/ai_saas_starter/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..55536c7 --- /dev/null +++ b/ai_saas_starter/src/app/auth/callback/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { completeSigninOnce } from "@/lib/auth/client"; + +export default function AuthCallbackPage() { + const router = useRouter(); + const [error, setError] = useState(null); + useEffect(() => { + completeSigninOnce() + .then((user) => { + const state = user.state as { returnTo?: unknown } | undefined; + const returnTo = typeof state?.returnTo === "string" && state.returnTo.startsWith("/") && !state.returnTo.startsWith("//") + ? state.returnTo + : "/workspace"; + router.replace(returnTo); + }) + .catch(() => setError("The sign-in callback could not be validated. Start a new sign-in.")); + }, [router]); + return

{error ?? "Validating your secure sign-in…"}

{error && Return home}
; +} diff --git a/ai_saas_starter/src/app/demo/page.tsx b/ai_saas_starter/src/app/demo/page.tsx new file mode 100644 index 0000000..27fed44 --- /dev/null +++ b/ai_saas_starter/src/app/demo/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import { DemoWorkspace } from "@/components/demo-workspace"; + +export const metadata: Metadata = { title: "Local product demo" }; + +export default function DemoPage() { return ; } diff --git a/ai_saas_starter/src/app/error.tsx b/ai_saas_starter/src/app/error.tsx new file mode 100644 index 0000000..89dcff1 --- /dev/null +++ b/ai_saas_starter/src/app/error.tsx @@ -0,0 +1,5 @@ +"use client"; + +export default function ErrorPage({ reset }: { reset(): void }) { + return
Recoverable error

The workspace paused safely.

No credentials were exposed. Retry the failed view or return home.

; +} diff --git a/ai_saas_starter/src/app/globals.css b/ai_saas_starter/src/app/globals.css new file mode 100644 index 0000000..7c700d0 --- /dev/null +++ b/ai_saas_starter/src/app/globals.css @@ -0,0 +1,202 @@ +:root { + --ink: #112b2a; + --muted: #657a76; + --paper: #f7f4ea; + --line: rgba(17, 43, 42, .14); + --forest: #173f3d; + --forest-2: #205653; + --mint: #a7ead5; + --gold: #e5aa37; + --white: #fffdf7; + --danger: #a83b32; + --shadow: 0 24px 70px rgba(23, 63, 61, .14); +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; color: var(--ink); background: var(--paper); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +a { color: inherit; text-decoration: none; } +button, textarea { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button:focus-visible, a:focus-visible, textarea:focus-visible { outline: 3px solid var(--gold); outline-offset: 3px; } +.site-header { width: min(1180px, calc(100% - 40px)); margin: 0 auto; height: 84px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); } +.brand { display: inline-flex; align-items: center; gap: 10px; font-weight: 750; letter-spacing: -.03em; } +.brand-mark { width: 30px; height: 30px; display: grid; place-items: center; background: var(--forest); color: var(--mint); border-radius: 9px; font-family: Georgia, serif; font-style: italic; } +.site-header nav { display: flex; align-items: center; gap: 28px; color: var(--muted); font-size: 14px; } +.nav-cta { border: 1px solid var(--line); padding: 10px 15px; border-radius: 999px; color: var(--ink); background: rgba(255,255,255,.45); } +.hero { min-height: 670px; display: grid; place-items: center; align-content: center; text-align: center; padding: 88px 20px 110px; position: relative; overflow: hidden; } +.hero::before { content: ""; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(circle, rgba(167,234,213,.48), transparent 66%); top: -210px; right: -130px; pointer-events: none; } +.eyebrow { display: block; color: var(--forest-2); font-size: 11px; text-transform: uppercase; letter-spacing: .2em; font-weight: 800; margin-bottom: 18px; } +.hero h1, .pricing-hero h1 { font-family: Georgia, "Times New Roman", serif; font-weight: 400; font-size: clamp(54px, 7vw, 96px); line-height: .96; letter-spacing: -.055em; margin: 0; } +.hero h1 em, .pricing-hero h1 em { color: var(--forest-2); font-weight: 400; } +.hero-copy { max-width: 690px; margin: 32px auto 28px; color: var(--muted); font-size: 18px; line-height: 1.7; } +.hero-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; } +.button { border: 0; border-radius: 12px; background: var(--forest); color: white; min-height: 46px; padding: 13px 22px; display: inline-flex; align-items: center; justify-content: center; font-weight: 750; cursor: pointer; box-shadow: 0 8px 20px rgba(23,63,61,.16); transition: transform .15s ease, background .15s ease; } +.button:hover { transform: translateY(-2px); background: var(--forest-2); } +.button:disabled { opacity: .5; cursor: wait; transform: none; } +.button.secondary { color: var(--ink); background: transparent; border: 1px solid var(--line); box-shadow: none; } +.trust-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 22px; margin-top: 32px; color: var(--muted); font-size: 12px; } +.trust-row span::before { content: "✓"; color: var(--forest-2); margin-right: 7px; } +.inline-error { max-width: 700px; margin: 16px auto; padding: 12px 16px; color: var(--danger); background: #fff0ed; border: 1px solid rgba(168,59,50,.2); border-radius: 10px; line-height: 1.45; } +.setup-hint { max-width: 620px; margin: 14px auto 0; color: var(--muted); font-size: 13px; line-height: 1.55; } +.product-preview { width: min(1080px, calc(100% - 40px)); min-height: 580px; margin: 0 auto 130px; display: grid; grid-template-columns: 260px 1fr; overflow: hidden; border-radius: 28px; background: var(--white); box-shadow: var(--shadow); border: 1px solid var(--line); } +.product-preview aside { background: var(--forest); color: white; padding: 36px 24px; } +.mini-brand { font-family: Georgia, serif; font-size: 24px; margin-bottom: 60px; } +.mock-nav { padding: 14px 16px; color: rgba(255,255,255,.6); border-radius: 10px; margin-bottom: 6px; } +.mock-nav.active { color: white; background: rgba(167,234,213,.12); } +.mock-chat { padding: 64px clamp(30px, 7vw, 100px); } +.mock-chat h2 { font-family: Georgia, serif; font-size: clamp(38px, 5vw, 62px); font-weight: 400; margin: 0 0 54px; } +.bubble { width: fit-content; max-width: 78%; border-radius: 16px; padding: 16px 18px; line-height: 1.6; margin: 14px 0; } +.bubble.user { margin-left: auto; background: var(--forest); color: white; } +.bubble.assistant { background: #edf0e8; } +.mock-composer { margin-top: 60px; padding: 18px; display: flex; justify-content: space-between; border: 1px solid var(--line); border-radius: 14px; color: var(--muted); } +.feature-grid { width: min(1120px, calc(100% - 40px)); margin: 0 auto 140px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } +.feature-grid article { padding: 34px; border-top: 1px solid var(--line); } +.feature-grid article > span { color: var(--gold); font-family: ui-monospace, monospace; } +.feature-grid h2 { font-family: Georgia, serif; font-size: 30px; font-weight: 400; } +.feature-grid p, .pricing-hero p, .plans p { color: var(--muted); line-height: 1.7; } +.closing { background: var(--forest); color: white; min-height: 340px; padding: 70px max(24px, calc((100% - 1100px)/2)); display: flex; align-items: center; justify-content: space-between; gap: 30px; } +.closing .eyebrow { color: var(--mint); } +.closing h2 { font-family: Georgia, serif; font-size: clamp(42px, 6vw, 72px); font-weight: 400; margin: 0; } +.closing .button { background: var(--mint); color: var(--forest); } +.closing .button.secondary { border-color: rgba(255,255,255,.3); color: white; background: transparent; } +footer { padding: 28px max(20px, calc((100% - 1120px)/2)); display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; } +.pricing-hero { width: min(920px, calc(100% - 40px)); margin: 100px auto 70px; text-align: center; } +.pricing-hero p { max-width: 620px; margin: 30px auto; font-size: 17px; } +.plans { width: min(980px, calc(100% - 40px)); margin: 0 auto 120px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.plans article { padding: 42px; border: 1px solid var(--line); border-radius: 22px; background: rgba(255,255,255,.45); } +.plans article.featured { background: var(--forest); color: white; } +.plans article > span { color: var(--gold); text-transform: uppercase; letter-spacing: .15em; font-size: 11px; } +.plans h2 { font-family: Georgia, serif; font-size: 38px; margin-bottom: 12px; } +.plans strong { font-size: 22px; } +.plans ul { padding: 0; list-style: none; margin: 28px 0; } +.plans li { padding: 10px 0; border-bottom: 1px solid var(--line); } +.plans li::before { content: "✓"; margin-right: 10px; color: var(--gold); } +.centered-state { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 50% 20%, #e2f4eb, var(--paper) 55%); } +.empty-card { max-width: 580px; background: var(--white); border: 1px solid var(--line); border-radius: 22px; padding: 44px; box-shadow: var(--shadow); text-align: center; } +.empty-card h1 { font-family: Georgia, serif; font-size: 44px; font-weight: 400; margin: 10px 0; } +.empty-card p { color: var(--muted); line-height: 1.7; } +.stacked-actions { display: grid; gap: 12px; margin-top: 24px; } +.technical-detail { margin-top: 24px; text-align: left; color: var(--muted); font-size: 12px; } +.technical-detail summary { cursor: pointer; font-weight: 700; } +.technical-detail code { display: block; margin-top: 10px; overflow-wrap: anywhere; } +.setup-page { min-height: 100vh; padding: 34px max(20px, calc((100% - 900px) / 2)) 70px; background: radial-gradient(circle at 85% 10%, rgba(167,234,213,.48), transparent 38%), var(--paper); } +.setup-page > .brand { margin-bottom: 56px; } +.setup-card { padding: clamp(28px, 6vw, 62px); border: 1px solid var(--line); border-radius: 26px; background: var(--white); box-shadow: var(--shadow); } +.setup-card h1 { max-width: 680px; margin: 0 0 18px; font: 400 clamp(42px, 6vw, 68px)/1 Georgia, serif; letter-spacing: -.04em; } +.setup-lead { max-width: 680px; color: var(--muted); line-height: 1.7; } +.setup-steps { display: grid; gap: 20px; padding: 0; margin: 36px 0; list-style: none; } +.setup-steps li { display: grid; grid-template-columns: 34px 1fr; gap: 15px; align-items: start; } +.setup-steps li > span { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; color: var(--forest); background: var(--mint); font-weight: 800; } +.setup-steps p { margin: 5px 0 0; color: var(--muted); line-height: 1.55; } +.command-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 20px; border-radius: 14px; background: var(--forest); color: white; } +.command-card span { display: block; margin-bottom: 10px; color: var(--mint); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.command-card code { display: block; overflow-wrap: anywhere; color: rgba(255,255,255,.86); line-height: 1.6; } +.command-card .button { color: var(--forest); background: var(--mint); } +.setup-safety { margin: 22px 0; padding: 18px; border: 1px solid #ead38c; border-radius: 12px; background: #fff6d8; } +.setup-safety p { margin: 6px 0 0; color: #705b24; line-height: 1.55; } +.workspace-shell { min-height: 100vh; display: grid; grid-template-columns: 285px 1fr; background: #f3f1e8; } +.workspace-sidebar { background: var(--forest); color: white; padding: 28px 18px; display: flex; flex-direction: column; min-height: 100vh; } +.brand.light .brand-mark { background: var(--mint); color: var(--forest); } +.new-chat { margin: 38px 0 18px; padding: 13px; border-radius: 10px; border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.08); color: white; text-align: left; cursor: pointer; } +.conversation-list { display: grid; gap: 5px; } +.conversation-list button { border: 0; background: none; color: rgba(255,255,255,.68); text-align: left; padding: 12px; border-radius: 9px; cursor: pointer; } +.conversation-list button.active, .conversation-list button:hover { background: rgba(167,234,213,.12); color: white; } +.conversation-list small { display: block; opacity: .55; margin-top: 4px; } +.muted { color: var(--muted); } +.sidebar-footer { margin-top: auto; padding-top: 24px; border-top: 1px solid rgba(255,255,255,.12); display: grid; gap: 12px; font-size: 13px; color: rgba(255,255,255,.72); } +.sidebar-footer button { background: none; color: inherit; border: 0; padding: 0; text-align: left; cursor: pointer; } +.demo-sidebar-label { margin: 46px 12px 14px; color: var(--mint); font-size: 10px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; } +.demo-navigation { display: grid; gap: 6px; } +.demo-navigation button { padding: 13px 14px; border: 0; border-radius: 10px; background: transparent; color: rgba(255,255,255,.65); text-align: left; cursor: pointer; } +.demo-navigation button:hover, .demo-navigation button.active { color: white; background: rgba(167,234,213,.12); } +.workspace-main { min-width: 0; padding: 42px clamp(24px, 5vw, 76px); display: flex; flex-direction: column; } +.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.workspace-header h1 { margin: 0; font-family: Georgia, serif; font-weight: 400; font-size: 36px; } +.account-pill { font-size: 12px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 999px; background: white; } +.account-pill span { color: var(--forest-2); margin-left: 8px; } +.demo-banner { margin: 24px 0; padding: 13px 16px; background: #fff6d8; border: 1px solid #ead38c; border-radius: 10px; font-size: 13px; } +.demo-disclosure { margin-bottom: 28px; padding: 13px 16px; display: flex; justify-content: space-between; gap: 16px; border: 1px solid #ead38c; border-radius: 10px; background: #fff6d8; color: #705b24; font-size: 12px; } +.demo-disclosure span { text-align: right; } +.demo-messages { min-height: 330px; } +.demo-panel { width: min(900px, 100%); margin: 54px auto auto; } +.demo-panel h2 { margin: 0 0 12px; font: 400 clamp(38px, 5vw, 58px)/1.05 Georgia, serif; } +.demo-panel > div > p { max-width: 670px; color: var(--muted); line-height: 1.65; } +.demo-stat-grid { margin: 34px 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.demo-stat-grid article { padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: white; } +.demo-stat-grid strong { display: block; font: 400 38px Georgia, serif; } +.demo-stat-grid span { color: var(--muted); font-size: 12px; } +.demo-table { overflow: hidden; border: 1px solid var(--line); border-radius: 14px; background: white; } +.demo-table [role="row"] { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 18px; padding: 16px 20px; border-bottom: 1px solid var(--line); } +.demo-table [role="row"]:last-child { border: 0; } +.demo-table strong, .demo-table span { font-size: 13px; } +.demo-table strong { color: var(--muted); text-transform: uppercase; letter-spacing: .1em; font-size: 10px; } +.demo-audit { margin-top: 32px; } +.demo-audit p { display: flex; justify-content: space-between; margin: 0; padding: 14px 0; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 13px; } +.demo-settings-grid { margin: 34px 0; } +.demo-settings-grid p { margin: 0; color: var(--muted); line-height: 1.55; } +.workspace-empty { margin: auto; max-width: 590px; text-align: center; padding: 60px 20px; } +.workspace-empty h2 { font-family: Georgia, serif; font-size: 44px; font-weight: 400; margin: 18px 0 8px; } +.workspace-empty p { color: var(--muted); line-height: 1.6; } +.orb { width: 64px; height: 64px; display: grid; place-items: center; margin: auto; border-radius: 50%; background: var(--mint); color: var(--forest); font-size: 24px; } +.messages { width: min(760px, 100%); margin: 35px auto 20px; flex: 1; } +.message { margin: 28px 0; } +.message span { color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 10px; font-weight: 800; } +.message p { line-height: 1.75; white-space: pre-wrap; } +.message.user { padding-left: 18%; } +.message.assistant { padding-right: 12%; } +.composer { width: min(790px, 100%); margin: auto auto 0; display: grid; grid-template-columns: 1fr 48px; padding: 12px; background: white; border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 10px 32px rgba(23,63,61,.08); } +.composer textarea { resize: none; border: 0; outline: 0; min-height: 48px; padding: 12px; background: transparent; color: var(--ink); } +.composer button { width: 44px; height: 44px; border: 0; border-radius: 12px; background: var(--forest); color: white; cursor: pointer; } +.composer button:disabled { opacity: .35; } +.composer small { grid-column: 1 / -1; color: var(--muted); padding: 4px 12px; font-size: 10px; } +.danger-link { margin: 16px auto; background: none; border: 0; color: var(--danger); cursor: pointer; font-size: 12px; } +.usage-line { margin-top: 18px; border-top: 1px solid var(--line); padding-top: 14px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; } +.settings-page { width: min(940px, calc(100% - 40px)); margin: 0 auto; padding: 70px 0; } +.settings-page > a:first-child { color: var(--muted); display: inline-block; margin-bottom: 60px; } +.settings-page h1 { font-family: Georgia, serif; font-size: 58px; font-weight: 400; margin: 0 0 30px; } +.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } +.settings-grid article { min-height: 140px; padding: 26px; background: white; border: 1px solid var(--line); border-radius: 15px; display: flex; flex-direction: column; gap: 14px; } +.settings-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .13em; } +.audit-list { margin-top: 30px; border-top: 1px solid var(--line); } +.audit-list article { display: grid; grid-template-columns: 1fr auto auto; gap: 20px; padding: 18px 0; border-bottom: 1px solid var(--line); } +.audit-list span, .audit-list code { color: var(--muted); font-size: 12px; } +@media (max-width: 800px) { + .site-header nav a:not(.nav-cta) { display: none; } + .hero { min-height: 590px; padding-top: 60px; } + .product-preview { grid-template-columns: 1fr; } + .product-preview aside { display: none; } + .feature-grid, .plans { grid-template-columns: 1fr; } + .closing { align-items: flex-start; flex-direction: column; } + .workspace-shell { grid-template-columns: 1fr; } + .workspace-sidebar { min-height: auto; padding: 16px; flex-direction: row; align-items: center; gap: 15px; } + .workspace-sidebar .new-chat, .conversation-list { display: none; } + .demo-workspace .workspace-sidebar { flex-wrap: wrap; } + .demo-sidebar-label { display: none; } + .demo-navigation { order: 3; width: 100%; grid-template-columns: repeat(3, 1fr); } + .demo-navigation button { text-align: center; } + .sidebar-footer { margin: 0 0 0 auto; padding: 0; border: 0; display: flex; } + .sidebar-footer a { display: none; } + .workspace-main { min-height: calc(100vh - 65px); padding: 24px 18px; } + .account-pill { display: none; } + .settings-grid { grid-template-columns: 1fr; } + .demo-stat-grid { grid-template-columns: 1fr; } + .settings-page h1 { font-size: 44px; } +} +@media (max-width: 520px) { + .hero h1, .pricing-hero h1 { font-size: 48px; } + .hero-actions, .hero-actions .button { width: 100%; } + .product-preview { width: calc(100% - 20px); } + .mock-chat { padding: 36px 20px; } + .bubble { max-width: 94%; } + .feature-grid { width: calc(100% - 20px); } + footer { flex-direction: column; gap: 8px; } + .empty-card { padding: 28px 20px; } + .workspace-header h1 { font-size: 28px; } + .setup-card { padding: 28px 20px; } + .command-card { grid-template-columns: 1fr; } + .command-card .button { width: 100%; } + .demo-disclosure { flex-direction: column; } + .demo-disclosure span { text-align: left; } + .demo-table [role="row"] { grid-template-columns: 1.5fr 1fr; } + .demo-table [role="row"] > :last-child { display: none; } +} diff --git a/ai_saas_starter/src/app/layout.tsx b/ai_saas_starter/src/app/layout.tsx new file mode 100644 index 0000000..0137886 --- /dev/null +++ b/ai_saas_starter/src/app/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import { AuthProvider } from "@/components/auth-provider"; +import "./globals.css"; + +export const metadata: Metadata = { + title: { default: "Tuurio Canvas — AI workspace starter", template: "%s · Tuurio Canvas" }, + description: "A production-oriented multi-tenant AI SaaS starter with Tuurio ID.", +}; + +export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { + return {children}; +} diff --git a/ai_saas_starter/src/app/logout/callback/page.tsx b/ai_saas_starter/src/app/logout/callback/page.tsx new file mode 100644 index 0000000..72ff80c --- /dev/null +++ b/ai_saas_starter/src/app/logout/callback/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { completeSignoutOnce } from "@/lib/auth/client"; + +export default function LogoutCallbackPage() { + const router = useRouter(); + const [error, setError] = useState(null); + useEffect(() => { + completeSignoutOnce().then(() => router.replace("/")).catch(() => setError("Sign-out could not be completed. Clear this site's session storage and retry.")); + }, [router]); + return

{error ?? "Completing sign-out…"}

{error && Return home}
; +} diff --git a/ai_saas_starter/src/app/not-found.tsx b/ai_saas_starter/src/app/not-found.tsx new file mode 100644 index 0000000..550dfad --- /dev/null +++ b/ai_saas_starter/src/app/not-found.tsx @@ -0,0 +1,5 @@ +import Link from "next/link"; + +export default function NotFound() { + return
404 · Not found

This path is outside the workspace.

Return to the product home or open your authenticated workspace.

Product homeWorkspace
; +} diff --git a/ai_saas_starter/src/app/page.tsx b/ai_saas_starter/src/app/page.tsx new file mode 100644 index 0000000..7223ee4 --- /dev/null +++ b/ai_saas_starter/src/app/page.tsx @@ -0,0 +1,41 @@ +import { HeroActions } from "@/components/hero-actions"; +import { SiteHeader } from "@/components/site-header"; + +const features = [ + ["Identity that respects the boundary", "Every API request validates the issuer, audience, signature, and expiry before touching tenant data."], + ["A complete AI product slice", "Stream conversations, persist history, enforce quotas, and swap providers without moving credentials into the browser."], + ["Operations already considered", "Role-aware administration, audit events, deletion, provider errors, empty states, and production migration guidance."], +]; + +export default function HomePage() { + return
+ +
+
Open-source AI SaaS foundation
+

Turn a useful idea into a
trusted workspace.

+

A deployable multi-tenant AI product with streaming, persistence, usage controls, and EU-hosted Tuurio identity built in.

+ +
Authorization Code + PKCEServer-validated tokensApache-2.0
+
+
+ +
+ Workspace / Product +

What should we ship first?

+
Turn our customer notes into a focused launch plan.
+
Start with one measurable activation outcome. I would sequence the work into three small releases…
+
Ask a follow-up
+
+
+
+ {features.map(([title, body], index) =>
0{index + 1}

{title}

{body}

)} +
+
Your product, not an auth demo

Build the differentiated part.

+
Tuurio CanvasIdentity hosted in the European Union · App infrastructure chosen by you
+
; +} diff --git a/ai_saas_starter/src/app/pricing/page.tsx b/ai_saas_starter/src/app/pricing/page.tsx new file mode 100644 index 0000000..6f97637 --- /dev/null +++ b/ai_saas_starter/src/app/pricing/page.tsx @@ -0,0 +1,9 @@ +import { HeroActions } from "@/components/hero-actions"; +import { SiteHeader } from "@/components/site-header"; + +export default function PricingPage() { + return
Simple starter boundaries

Start in demo mode.
Scale deliberately.

Authentication, AI usage, and billing stay separate so you can choose the right provider for each responsibility.

+
Local

Demo

€0

Memory storage and deterministic local responses. Real Tuurio authentication is still required for protected data.

  • Reset-on-restart storage
  • No external AI calls
  • Full product UI
+
Deploy

Production foundation

Your infrastructure

Bring Postgres and an OpenAI-compatible provider while Tuurio secures user and organization identity.

  • Tenant-scoped persistence
  • Usage and request limits
  • Audit and administration
+
; +} diff --git a/ai_saas_starter/src/app/setup/page.tsx b/ai_saas_starter/src/app/setup/page.tsx new file mode 100644 index 0000000..cb0f5f6 --- /dev/null +++ b/ai_saas_starter/src/app/setup/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import { SetupGuide } from "@/components/setup-guide"; + +export const metadata: Metadata = { title: "Set up Tuurio ID" }; + +export default function SetupPage() { return ; } diff --git a/ai_saas_starter/src/app/workspace/admin/page.tsx b/ai_saas_starter/src/app/workspace/admin/page.tsx new file mode 100644 index 0000000..fbaeac4 --- /dev/null +++ b/ai_saas_starter/src/app/workspace/admin/page.tsx @@ -0,0 +1,3 @@ +import { AdminPanel } from "@/components/admin-panel"; + +export default function AdminPage() { return ; } diff --git a/ai_saas_starter/src/app/workspace/page.tsx b/ai_saas_starter/src/app/workspace/page.tsx new file mode 100644 index 0000000..99c4067 --- /dev/null +++ b/ai_saas_starter/src/app/workspace/page.tsx @@ -0,0 +1,3 @@ +import { WorkspaceApp } from "@/components/workspace-app"; + +export default function WorkspacePage() { return ; } diff --git a/ai_saas_starter/src/app/workspace/settings/page.tsx b/ai_saas_starter/src/app/workspace/settings/page.tsx new file mode 100644 index 0000000..8500177 --- /dev/null +++ b/ai_saas_starter/src/app/workspace/settings/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import Link from "next/link"; +import { useAuth } from "@/components/auth-provider"; + +export default function SettingsPage() { + const { user, loading, signOut } = useAuth(); + if (loading) return
Loading…
; + return
← WorkspaceProfile & organization

Workspace settings

{user ?
Email{String(user.profile.email ?? "Not provided")}
Issuer{String(user.profile.iss ?? "Not provided")}
Data retentionDelete conversations from the workspace at any time.
Session
:

Sign in to view settings.

}
; +} diff --git a/ai_saas_starter/src/components/admin-panel.tsx b/ai_saas_starter/src/components/admin-panel.tsx new file mode 100644 index 0000000..cd97ae2 --- /dev/null +++ b/ai_saas_starter/src/components/admin-panel.tsx @@ -0,0 +1,16 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/components/auth-provider"; +import type { AuditEvent } from "@/lib/domain"; + +export function AdminPanel() { + const { user, loading, apiFetch, signIn } = useAuth(); + const [data, setData] = useState<{ audit: AuditEvent[]; membershipManagementUrl: string } | null>(null); + const [error, setError] = useState(null); + useEffect(() => { if (user) void apiFetch("/api/admin").then(async (response) => { if (!response.ok) throw new Error(response.status === 403 ? "Your validated token does not grant workspace administration." : "Could not load administration."); return response.json() as Promise<{ audit: AuditEvent[]; membershipManagementUrl: string }>; }).then(setData).catch((caught: unknown) => setError(caught instanceof Error ? caught.message : "Could not load administration.")); }, [apiFetch, user]); + if (loading) return
Loading…
; + if (!user) return
; + return
← WorkspaceAdministration

Security activity

Membership and identity roles stay in Tuurio. Application audit events stay scoped to this organization.

{error &&
{error}
}{data && <>Manage identity in Tuurio
{data.audit.length ? data.audit.map((event) =>
{event.action}{new Date(event.createdAt).toLocaleString()}{event.targetId ?? "workspace"}
) :

No application audit events yet.

}
}
; +} diff --git a/ai_saas_starter/src/components/auth-provider.tsx b/ai_saas_starter/src/components/auth-provider.tsx new file mode 100644 index 0000000..c991b6c --- /dev/null +++ b/ai_saas_starter/src/components/auth-provider.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import type { User } from "oidc-client-ts"; +import { authManager } from "@/lib/auth/client"; + +interface AuthContextValue { + user: User | null; + loading: boolean; + configured: boolean | null; + configurationError: string | null; + error: string | null; + signIn(): Promise; + signOut(): Promise; + apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [configured, setConfigured] = useState(null); + const [configurationError, setConfigurationError] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + try { + const manager = authManager(); + setConfigured(true); + const clear = () => { if (active) setUser(null); }; + manager.events.addUserUnloaded(clear); + manager.events.addAccessTokenExpired(clear); + manager.getUser().then((current) => { + if (!active) return; + setUser(current && !current.expired ? current : null); + }).catch(() => active && setError("Could not restore the Tuurio session.")).finally(() => active && setLoading(false)); + return () => { + active = false; + manager.events.removeUserUnloaded(clear); + manager.events.removeAccessTokenExpired(clear); + }; + } catch (caught) { + setConfigured(false); + setConfigurationError(caught instanceof Error ? caught.message : "Tuurio is not configured for this origin."); + setLoading(false); + return () => { active = false; }; + } + }, []); + + const signIn = useCallback(async () => { + setError(null); + try { + await authManager().signinRedirect({ state: { returnTo: "/workspace" } }); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Could not start sign-in."); + } + }, []); + + const signOut = useCallback(async () => { + setError(null); + const manager = authManager(); + const current = await manager.getUser(); + await manager.signoutRedirect({ id_token_hint: current?.id_token }); + }, []); + + const apiFetch = useCallback(async (input: RequestInfo | URL, init: RequestInit = {}) => { + const current = await authManager().getUser(); + if (!current || current.expired) throw new Error("Your session expired. Sign in again."); + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${current.access_token}`); + return fetch(input, { ...init, headers }); + }, []); + + const value = useMemo( + () => ({ user, loading, configured, configurationError, error, signIn, signOut, apiFetch }), + [user, loading, configured, configurationError, error, signIn, signOut, apiFetch], + ); + return {children}; +} + +export function useAuth(): AuthContextValue { + const value = useContext(AuthContext); + if (!value) throw new Error("useAuth must be used inside AuthProvider"); + return value; +} diff --git a/ai_saas_starter/src/components/demo-workspace.tsx b/ai_saas_starter/src/components/demo-workspace.tsx new file mode 100644 index 0000000..d8dc2d1 --- /dev/null +++ b/ai_saas_starter/src/components/demo-workspace.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; + +type DemoView = "chat" | "admin" | "settings"; +type DemoMessage = { id: string; role: "user" | "assistant"; content: string }; + +const initialMessages: DemoMessage[] = [ + { id: "welcome-user", role: "user", content: "Turn our customer notes into a focused launch plan." }, + { id: "welcome-assistant", role: "assistant", content: "Start with one measurable activation outcome. I would sequence the work into three releases: validate the core workflow, remove onboarding friction, then expand collaboration." }, +]; + +const demoReply = "For this local preview, I would turn that into a one-week experiment: name the user outcome, choose one leading metric, and ship the smallest workflow that can prove or disprove the assumption."; + +export function DemoWorkspace() { + const [view, setView] = useState("chat"); + const [messages, setMessages] = useState(initialMessages); + const [prompt, setPrompt] = useState(""); + const [streaming, setStreaming] = useState(false); + + const title = useMemo(() => ({ chat: "Product launch plan", admin: "Organization overview", settings: "Workspace settings" })[view], [view]); + + async function sendMessage() { + const content = prompt.trim(); + if (!content || streaming) return; + const assistantId = crypto.randomUUID(); + setPrompt(""); + setStreaming(true); + setMessages((current) => [...current, { id: crypto.randomUUID(), role: "user", content }, { id: assistantId, role: "assistant", content: "" }]); + for (const word of demoReply.split(" ")) { + await new Promise((resolve) => window.setTimeout(resolve, 18)); + setMessages((current) => current.map((message) => message.id === assistantId ? { ...message, content: `${message.content}${message.content ? " " : ""}${word}` } : message)); + } + setStreaming(false); + } + + function resetDemo() { + setMessages(initialMessages); + setPrompt(""); + setStreaming(false); + setView("chat"); + } + + return
+ +
+
Local product previewNo authentication · Browser-only · Resets on refresh
+
Demo organization

{title}

demo@local.invalidnot authenticated
+ + {view === "chat" && <> +
+ {messages.map((message) =>
{message.role === "user" ? "You" : "Canvas"}

{message.content || "Thinking…"}

)} +
+